From c7585e37c9346048eac0646b342e11b36a806b0f Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 4 Dec 2025 20:51:57 +0100 Subject: [PATCH 01/54] Release v0.12.10 --- package-lock.json | 38 ++++----- packages/agent/package.json | 6 +- packages/ai/CHANGELOG.md | 6 ++ packages/ai/package.json | 2 +- packages/ai/scripts/generate-models.ts | 20 +++++ packages/ai/src/models.generated.ts | 112 +++++-------------------- packages/coding-agent/CHANGELOG.md | 6 ++ packages/coding-agent/package.json | 8 +- packages/mom/package.json | 6 +- packages/mom/src/agent.ts | 9 ++ packages/pods/package.json | 4 +- packages/proxy/package.json | 2 +- packages/tui/package.json | 2 +- packages/web-ui/example/package.json | 2 +- packages/web-ui/package.json | 6 +- 15 files changed, 101 insertions(+), 128 deletions(-) diff --git a/package-lock.json b/package-lock.json index ea9e7057..39c1e71e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5975,11 +5975,11 @@ }, "packages/agent": { "name": "@mariozechner/pi-agent-core", - "version": "0.12.9", + "version": "0.12.10", "license": "MIT", "dependencies": { - "@mariozechner/pi-ai": "^0.12.8", - "@mariozechner/pi-tui": "^0.12.8" + "@mariozechner/pi-ai": "^0.12.9", + "@mariozechner/pi-tui": "^0.12.9" }, "devDependencies": { "@types/node": "^24.3.0", @@ -6009,7 +6009,7 @@ }, "packages/ai": { "name": "@mariozechner/pi-ai", - "version": "0.12.9", + "version": "0.12.10", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.61.0", @@ -6050,12 +6050,12 @@ }, "packages/coding-agent": { "name": "@mariozechner/pi-coding-agent", - "version": "0.12.9", + "version": "0.12.10", "license": "MIT", "dependencies": { - "@mariozechner/pi-agent-core": "^0.12.8", - "@mariozechner/pi-ai": "^0.12.8", - "@mariozechner/pi-tui": "^0.12.8", + "@mariozechner/pi-agent-core": "^0.12.9", + "@mariozechner/pi-ai": "^0.12.9", + "@mariozechner/pi-tui": "^0.12.9", "chalk": "^5.5.0", "diff": "^8.0.2", "glob": "^11.0.3" @@ -6092,12 +6092,12 @@ }, "packages/mom": { "name": "@mariozechner/pi-mom", - "version": "0.12.9", + "version": "0.12.10", "license": "MIT", "dependencies": { "@anthropic-ai/sandbox-runtime": "^0.0.16", - "@mariozechner/pi-agent-core": "^0.12.8", - "@mariozechner/pi-ai": "^0.12.8", + "@mariozechner/pi-agent-core": "^0.12.9", + "@mariozechner/pi-ai": "^0.12.9", "@sinclair/typebox": "^0.34.0", "@slack/socket-mode": "^2.0.0", "@slack/web-api": "^7.0.0", @@ -6135,10 +6135,10 @@ }, "packages/pods": { "name": "@mariozechner/pi", - "version": "0.12.9", + "version": "0.12.10", "license": "MIT", "dependencies": { - "@mariozechner/pi-agent-core": "^0.12.8", + "@mariozechner/pi-agent-core": "^0.12.9", "chalk": "^5.5.0" }, "bin": { @@ -6151,7 +6151,7 @@ }, "packages/proxy": { "name": "@mariozechner/pi-proxy", - "version": "0.12.9", + "version": "0.12.10", "dependencies": { "@hono/node-server": "^1.14.0", "hono": "^4.6.16" @@ -6167,7 +6167,7 @@ }, "packages/tui": { "name": "@mariozechner/pi-tui", - "version": "0.12.9", + "version": "0.12.10", "license": "MIT", "dependencies": { "@types/mime-types": "^2.1.4", @@ -6211,12 +6211,12 @@ }, "packages/web-ui": { "name": "@mariozechner/pi-web-ui", - "version": "0.12.9", + "version": "0.12.10", "license": "MIT", "dependencies": { "@lmstudio/sdk": "^1.5.0", - "@mariozechner/pi-ai": "^0.12.8", - "@mariozechner/pi-tui": "^0.12.8", + "@mariozechner/pi-ai": "^0.12.9", + "@mariozechner/pi-tui": "^0.12.9", "docx-preview": "^0.3.7", "jszip": "^3.10.1", "lucide": "^0.544.0", @@ -6237,7 +6237,7 @@ }, "packages/web-ui/example": { "name": "pi-web-ui-example", - "version": "1.0.5", + "version": "1.0.6", "dependencies": { "@mariozechner/mini-lit": "^0.2.0", "@mariozechner/pi-ai": "file:../../ai", diff --git a/packages/agent/package.json b/packages/agent/package.json index 711cc531..9b9506af 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-agent-core", - "version": "0.12.9", + "version": "0.12.10", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "type": "module", "main": "./dist/index.js", @@ -18,8 +18,8 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@mariozechner/pi-ai": "^0.12.9", - "@mariozechner/pi-tui": "^0.12.9" + "@mariozechner/pi-ai": "^0.12.10", + "@mariozechner/pi-tui": "^0.12.10" }, "keywords": [ "ai", diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 378d6ea7..61da782e 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +## [0.12.10] - 2025-12-04 + +### Added + +- Added `gpt-5.1-codex-max` model support + ### Fixed - **OpenAI Token Counting**: Fixed `usage.input` to exclude cached tokens for OpenAI providers. Previously, `input` included cached tokens, causing double-counting when calculating total context size via `input + cacheRead`. Now `input` represents non-cached input tokens across all providers, making `input + output + cacheRead + cacheWrite` the correct formula for total context size. diff --git a/packages/ai/package.json b/packages/ai/package.json index 915398a1..91d9a951 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-ai", - "version": "0.12.9", + "version": "0.12.10", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index c1e06ec9..d5908d9c 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -344,6 +344,26 @@ async function generateModels() { }); } + if (!allModels.some(m => m.provider === "openai" && m.id === "gpt-5.1-codex-max")) { + allModels.push({ + id: "gpt-5.1-codex-max", + name: "GPT-5.1 Codex Max", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + provider: "openai", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.125, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + }); + } + // Add missing Grok models if (!allModels.some(m => m.provider === "xai" && m.id === "grok-code-fast-1")) { allModels.push({ diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 106f38df..416afaac 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -1199,6 +1199,23 @@ export const MODELS = { contextWindow: 128000, maxTokens: 16384, } satisfies Model<"openai-responses">, + "gpt-5.1-codex-max": { + id: "gpt-5.1-codex-max", + name: "GPT-5.1 Codex Max", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.125, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, }, groq: { "llama-3.1-8b-instant": { @@ -3861,23 +3878,6 @@ export const MODELS = { contextWindow: 131072, maxTokens: 4096, } satisfies Model<"openai-completions">, - "mistralai/magistral-small-2506": { - id: "mistralai/magistral-small-2506", - name: "Mistral: Magistral Small 2506", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.5, - output: 1.5, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 40000, - maxTokens: 40000, - } satisfies Model<"openai-completions">, "mistralai/magistral-medium-2506:thinking": { id: "mistralai/magistral-medium-2506:thinking", name: "Mistral: Magistral Medium 2506 (thinking)", @@ -3895,23 +3895,6 @@ export const MODELS = { contextWindow: 40960, maxTokens: 40000, } satisfies Model<"openai-completions">, - "mistralai/magistral-medium-2506": { - id: "mistralai/magistral-medium-2506", - name: "Mistral: Magistral Medium 2506", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 2, - output: 5, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 40960, - maxTokens: 40000, - } satisfies Model<"openai-completions">, "google/gemini-2.5-pro-preview": { id: "google/gemini-2.5-pro-preview", name: "Google: Gemini 2.5 Pro Preview 06-05", @@ -3980,23 +3963,6 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 64000, } satisfies Model<"openai-completions">, - "mistralai/devstral-small-2505": { - id: "mistralai/devstral-small-2505", - name: "Mistral: Devstral Small 2505", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.06, - output: 0.12, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "openai/codex-mini": { id: "openai/codex-mini", name: "OpenAI: Codex Mini", @@ -4397,13 +4363,13 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.19999999999999998, - output: 0.88, - cacheRead: 0.106, + input: 0.15, + output: 0.7, + cacheRead: 0, cacheWrite: 0, }, - contextWindow: 163840, - maxTokens: 4096, + contextWindow: 8192, + maxTokens: 7168, } satisfies Model<"openai-completions">, "mistralai/mistral-small-3.1-24b-instruct:free": { id: "mistralai/mistral-small-3.1-24b-instruct:free", @@ -4711,23 +4677,6 @@ export const MODELS = { contextWindow: 163840, maxTokens: 4096, } satisfies Model<"openai-completions">, - "mistralai/codestral-2501": { - id: "mistralai/codestral-2501", - name: "Mistral: Codestral 2501", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.3, - output: 0.8999999999999999, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 256000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "deepseek/deepseek-chat": { id: "deepseek/deepseek-chat", name: "DeepSeek: DeepSeek V3", @@ -5595,23 +5544,6 @@ export const MODELS = { contextWindow: 128000, maxTokens: 4096, } satisfies Model<"openai-completions">, - "mistralai/mistral-small": { - id: "mistralai/mistral-small", - name: "Mistral Small", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.19999999999999998, - output: 0.6, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 32768, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "mistralai/mistral-tiny": { id: "mistralai/mistral-tiny", name: "Mistral Tiny", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e17b2349..cbb917f7 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +## [0.12.10] - 2025-12-04 + +### Added + +- Added `gpt-5.1-codex-max` model support + ## [0.12.9] - 2025-12-04 ### Added diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 4982d04e..9f2a4d20 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-coding-agent", - "version": "0.12.9", + "version": "0.12.10", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "type": "module", "piConfig": { @@ -28,9 +28,9 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@mariozechner/pi-agent-core": "^0.12.9", - "@mariozechner/pi-ai": "^0.12.9", - "@mariozechner/pi-tui": "^0.12.9", + "@mariozechner/pi-agent-core": "^0.12.10", + "@mariozechner/pi-ai": "^0.12.10", + "@mariozechner/pi-tui": "^0.12.10", "chalk": "^5.5.0", "diff": "^8.0.2", "glob": "^11.0.3" diff --git a/packages/mom/package.json b/packages/mom/package.json index e5467fb7..1f822de2 100644 --- a/packages/mom/package.json +++ b/packages/mom/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-mom", - "version": "0.12.9", + "version": "0.12.10", "description": "Slack bot that delegates messages to the pi coding agent", "type": "module", "bin": { @@ -21,8 +21,8 @@ }, "dependencies": { "@anthropic-ai/sandbox-runtime": "^0.0.16", - "@mariozechner/pi-agent-core": "^0.12.9", - "@mariozechner/pi-ai": "^0.12.9", + "@mariozechner/pi-agent-core": "^0.12.10", + "@mariozechner/pi-ai": "^0.12.10", "@sinclair/typebox": "^0.34.0", "@slack/socket-mode": "^2.0.0", "@slack/web-api": "^7.0.0", diff --git a/packages/mom/src/agent.ts b/packages/mom/src/agent.ts index fa04f383..e6bbaa54 100644 --- a/packages/mom/src/agent.ts +++ b/packages/mom/src/agent.ts @@ -249,6 +249,15 @@ Update when you learn something important or when asked to remember something. ### Current Memory ${memory} +## System Configuration Log +Maintain ${workspacePath}/SYSTEM.md to log all environment modifications: +- Installed packages (apk add, npm install, pip install) +- Environment variables set +- Config files modified (~/.gitconfig, cron jobs, etc.) +- Skill dependencies installed + +Update this file whenever you modify the environment. On fresh container, read it first to restore your setup. + ## Log Queries (CRITICAL: limit output to avoid context overflow) Format: \`{"date":"...","ts":"...","user":"...","userName":"...","text":"...","isBot":false}\` The log contains user messages AND your tool calls/results. Filter appropriately. diff --git a/packages/pods/package.json b/packages/pods/package.json index 65c7f476..29fc87ce 100644 --- a/packages/pods/package.json +++ b/packages/pods/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi", - "version": "0.12.9", + "version": "0.12.10", "description": "CLI tool for managing vLLM deployments on GPU pods", "type": "module", "bin": { @@ -34,7 +34,7 @@ "node": ">=20.0.0" }, "dependencies": { - "@mariozechner/pi-agent-core": "^0.12.9", + "@mariozechner/pi-agent-core": "^0.12.10", "chalk": "^5.5.0" }, "devDependencies": {} diff --git a/packages/proxy/package.json b/packages/proxy/package.json index 5bd7887d..55325e31 100644 --- a/packages/proxy/package.json +++ b/packages/proxy/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-proxy", - "version": "0.12.9", + "version": "0.12.10", "type": "module", "description": "CORS and authentication proxy for pi-ai", "main": "dist/index.js", diff --git a/packages/tui/package.json b/packages/tui/package.json index 1f2c6635..fa751aa9 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-tui", - "version": "0.12.9", + "version": "0.12.10", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "type": "module", "main": "dist/index.js", diff --git a/packages/web-ui/example/package.json b/packages/web-ui/example/package.json index 2da04b36..36a3d1ff 100644 --- a/packages/web-ui/example/package.json +++ b/packages/web-ui/example/package.json @@ -1,6 +1,6 @@ { "name": "pi-web-ui-example", - "version": "1.0.5", + "version": "1.0.6", "private": true, "type": "module", "scripts": { diff --git a/packages/web-ui/package.json b/packages/web-ui/package.json index f9b8a277..31e51b5c 100644 --- a/packages/web-ui/package.json +++ b/packages/web-ui/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-web-ui", - "version": "0.12.9", + "version": "0.12.10", "description": "Reusable web UI components for AI chat interfaces powered by @mariozechner/pi-ai", "type": "module", "main": "dist/index.js", @@ -18,8 +18,8 @@ }, "dependencies": { "@lmstudio/sdk": "^1.5.0", - "@mariozechner/pi-ai": "^0.12.9", - "@mariozechner/pi-tui": "^0.12.9", + "@mariozechner/pi-ai": "^0.12.10", + "@mariozechner/pi-tui": "^0.12.10", "docx-preview": "^0.3.7", "jszip": "^3.10.1", "lucide": "^0.544.0", From e1d3c2b76e68d9e07f01112152ad2785591f0056 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 4 Dec 2025 23:39:17 +0100 Subject: [PATCH 02/54] fix(mom): handle Slack msg_too_long errors gracefully - Add try/catch to all Slack API calls in promise chain - Truncate main channel messages at 35K with elaboration note - Truncate thread messages at 20K - Prevents process crash on long messages --- packages/mom/CHANGELOG.md | 6 ++ packages/mom/src/agent.ts | 45 +++++++++- packages/mom/src/slack.ts | 169 +++++++++++++++++++++++--------------- 3 files changed, 152 insertions(+), 68 deletions(-) diff --git a/packages/mom/CHANGELOG.md b/packages/mom/CHANGELOG.md index c2a79f0b..e71f99aa 100644 --- a/packages/mom/CHANGELOG.md +++ b/packages/mom/CHANGELOG.md @@ -4,6 +4,12 @@ ### Fixed +- Slack API errors (msg_too_long) no longer crash the process + - Added try/catch error handling to all Slack API calls in the message queue + - Main channel messages truncated at 35K with note to ask for elaboration + - Thread messages truncated at 20K + - replaceMessage also truncated at 35K + - Private channel messages not being logged - Added `message.groups` to required bot events in README - Added `groups:history` and `groups:read` to required scopes in README diff --git a/packages/mom/src/agent.ts b/packages/mom/src/agent.ts index e6bbaa54..fa68c991 100644 --- a/packages/mom/src/agent.ts +++ b/packages/mom/src/agent.ts @@ -127,7 +127,11 @@ function getRecentMessages(channelDir: string, turnCount: number): string { for (const msg of turn) { const date = (msg.date || "").substring(0, 19); const user = msg.userName || msg.user || ""; - const text = msg.text || ""; + let text = msg.text || ""; + // Truncate bot messages (tool results can be huge) + if (msg.isBot) { + text = truncateForContext(text, 50000, 2000, msg.ts); + } const attachments = (msg.attachments || []).map((a) => a.local).join(","); formatted.push(`${date}\t${user}\t${text}\t${attachments}`); } @@ -136,6 +140,43 @@ function getRecentMessages(channelDir: string, turnCount: number): string { return formatted.join("\n"); } +/** + * Truncate text to maxChars or maxLines, whichever comes first. + * Adds a note with stats and instructions if truncation occurred. + */ +function truncateForContext(text: string, maxChars: number, maxLines: number, ts?: string): string { + const lines = text.split("\n"); + const originalLines = lines.length; + const originalChars = text.length; + let truncated = false; + let result = text; + + // Check line limit first + if (lines.length > maxLines) { + result = lines.slice(0, maxLines).join("\n"); + truncated = true; + } + + // Check char limit + if (result.length > maxChars) { + result = result.substring(0, maxChars); + truncated = true; + } + + if (truncated) { + const remainingLines = originalLines - result.split("\n").length; + const remainingChars = originalChars - result.length; + result += `\n[... truncated ${remainingLines} more lines, ${remainingChars} more chars. `; + if (ts) { + result += `To get full content: jq -r 'select(.ts=="${ts}") | .text' log.jsonl > /tmp/msg.txt, then read /tmp/msg.txt in segments]`; + } else { + result += `Search log.jsonl for full content]`; + } + } + + return result; +} + function getMemory(channelDir: string): string { const parts: string[] = []; @@ -545,7 +586,7 @@ export function createAgentRunner(sandboxConfig: SandboxConfig): AgentRunner { date: new Date().toISOString(), ts: toSlackTs(), user: "bot", - text: `[Tool Result] ${event.toolName}: ${event.isError ? "ERROR: " : ""}${truncate(resultStr, 1000)}`, + text: `[Tool Result] ${event.toolName}: ${event.isError ? "ERROR: " : ""}${resultStr}`, attachments: [], isBot: true, }); diff --git a/packages/mom/src/slack.ts b/packages/mom/src/slack.ts index f0cf0007..aa24e667 100644 --- a/packages/mom/src/slack.ts +++ b/packages/mom/src/slack.ts @@ -24,7 +24,7 @@ export interface SlackContext { /** All known users in the workspace */ users: UserInfo[]; /** Send/update the main message (accumulates text). Set log=false to skip logging. */ - respond(text: string, log?: boolean): Promise; + respond(text: string, shouldLog?: boolean): Promise; /** Replace the entire message text (not append) */ replaceMessage(text: string): Promise; /** Post a message in the thread under the main message (for verbose details) */ @@ -352,40 +352,52 @@ export class MomBot { store: this.store, channels: this.getChannels(), users: this.getUsers(), - respond: async (responseText: string, log = true) => { + respond: async (responseText: string, shouldLog = true) => { // Queue updates to avoid race conditions updatePromise = updatePromise.then(async () => { - if (isThinking) { - // First real response replaces "Thinking..." - accumulatedText = responseText; - isThinking = false; - } else { - // Subsequent responses get appended - accumulatedText += "\n" + responseText; - } + try { + if (isThinking) { + // First real response replaces "Thinking..." + accumulatedText = responseText; + isThinking = false; + } else { + // Subsequent responses get appended + accumulatedText += "\n" + responseText; + } - // Add working indicator if still working - const displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText; + // Truncate accumulated text if too long (Slack limit is 40K, we use 35K for safety) + const MAX_MAIN_LENGTH = 35000; + const truncationNote = "\n\n_(message truncated, ask me to elaborate on specific parts)_"; + if (accumulatedText.length > MAX_MAIN_LENGTH) { + accumulatedText = + accumulatedText.substring(0, MAX_MAIN_LENGTH - truncationNote.length) + truncationNote; + } - if (messageTs) { - // Update existing message - await this.webClient.chat.update({ - channel: event.channel, - ts: messageTs, - text: displayText, - }); - } else { - // Post initial message - const result = await this.webClient.chat.postMessage({ - channel: event.channel, - text: displayText, - }); - messageTs = result.ts as string; - } + // Add working indicator if still working + const displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText; - // Log the response if requested - if (log) { - await this.store.logBotResponse(event.channel, responseText, messageTs!); + if (messageTs) { + // Update existing message + await this.webClient.chat.update({ + channel: event.channel, + ts: messageTs, + text: displayText, + }); + } else { + // Post initial message + const result = await this.webClient.chat.postMessage({ + channel: event.channel, + text: displayText, + }); + messageTs = result.ts as string; + } + + // Log the response if requested + if (shouldLog) { + await this.store.logBotResponse(event.channel, responseText, messageTs!); + } + } catch (err) { + log.logWarning("Slack respond error", err instanceof Error ? err.message : String(err)); } }); @@ -394,18 +406,29 @@ export class MomBot { respondInThread: async (threadText: string) => { // Queue thread posts to maintain order updatePromise = updatePromise.then(async () => { - if (!messageTs) { - // No main message yet, just skip - return; + try { + if (!messageTs) { + // No main message yet, just skip + return; + } + // Obfuscate usernames to avoid pinging people in thread details + let obfuscatedText = this.obfuscateUsernames(threadText); + + // Truncate thread messages if too long (20K limit for safety) + const MAX_THREAD_LENGTH = 20000; + if (obfuscatedText.length > MAX_THREAD_LENGTH) { + obfuscatedText = obfuscatedText.substring(0, MAX_THREAD_LENGTH - 50) + "\n\n_(truncated)_"; + } + + // Post in thread under the main message + await this.webClient.chat.postMessage({ + channel: event.channel, + thread_ts: messageTs, + text: obfuscatedText, + }); + } catch (err) { + log.logWarning("Slack respondInThread error", err instanceof Error ? err.message : String(err)); } - // Obfuscate usernames to avoid pinging people in thread details - const obfuscatedText = this.obfuscateUsernames(threadText); - // Post in thread under the main message - await this.webClient.chat.postMessage({ - channel: event.channel, - thread_ts: messageTs, - text: obfuscatedText, - }); }); await updatePromise; }, @@ -434,40 +457,54 @@ export class MomBot { }, replaceMessage: async (text: string) => { updatePromise = updatePromise.then(async () => { - // Replace the accumulated text entirely - accumulatedText = text; + try { + // Replace the accumulated text entirely, with truncation + const MAX_MAIN_LENGTH = 35000; + const truncationNote = "\n\n_(message truncated, ask me to elaborate on specific parts)_"; + if (text.length > MAX_MAIN_LENGTH) { + accumulatedText = text.substring(0, MAX_MAIN_LENGTH - truncationNote.length) + truncationNote; + } else { + accumulatedText = text; + } - const displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText; + const displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText; - if (messageTs) { - await this.webClient.chat.update({ - channel: event.channel, - ts: messageTs, - text: displayText, - }); - } else { - // Post initial message - const result = await this.webClient.chat.postMessage({ - channel: event.channel, - text: displayText, - }); - messageTs = result.ts as string; + if (messageTs) { + await this.webClient.chat.update({ + channel: event.channel, + ts: messageTs, + text: displayText, + }); + } else { + // Post initial message + const result = await this.webClient.chat.postMessage({ + channel: event.channel, + text: displayText, + }); + messageTs = result.ts as string; + } + } catch (err) { + log.logWarning("Slack replaceMessage error", err instanceof Error ? err.message : String(err)); } }); await updatePromise; }, setWorking: async (working: boolean) => { updatePromise = updatePromise.then(async () => { - isWorking = working; + try { + isWorking = working; - // If we have a message, update it to add/remove indicator - if (messageTs) { - const displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText; - await this.webClient.chat.update({ - channel: event.channel, - ts: messageTs, - text: displayText, - }); + // If we have a message, update it to add/remove indicator + if (messageTs) { + const displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText; + await this.webClient.chat.update({ + channel: event.channel, + ts: messageTs, + text: displayText, + }); + } + } catch (err) { + log.logWarning("Slack setWorking error", err instanceof Error ? err.message : String(err)); } }); await updatePromise; From 488f0808839fabc4234e5e73021ad01dc8460b3f Mon Sep 17 00:00:00 2001 From: Markus Ylisiurunen <8409947+markusylisiurunen@users.noreply.github.com> Date: Fri, 5 Dec 2025 02:38:31 +0200 Subject: [PATCH 03/54] improve readability of context usage display calculation (#111) --- packages/coding-agent/src/tui/footer.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/src/tui/footer.ts b/packages/coding-agent/src/tui/footer.ts index d9861d0d..b38f8936 100644 --- a/packages/coding-agent/src/tui/footer.ts +++ b/packages/coding-agent/src/tui/footer.ts @@ -186,12 +186,13 @@ export class FooterComponent implements Component { // Colorize context percentage based on usage let contextPercentStr: string; const autoIndicator = this.autoCompactEnabled ? " (auto)" : ""; + const contextPercentDisplay = `${contextPercent}% of ${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); From 029a04c43b8308c3c9c1fc9138db4afd34bfb5bb Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 5 Dec 2025 02:38:00 +0100 Subject: [PATCH 04/54] fix(mom): clarify attach tool only works with /workspace/ files --- packages/mom/src/tools/attach.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/mom/src/tools/attach.ts b/packages/mom/src/tools/attach.ts index 073fc9c5..174faf02 100644 --- a/packages/mom/src/tools/attach.ts +++ b/packages/mom/src/tools/attach.ts @@ -18,7 +18,8 @@ const attachSchema = Type.Object({ export const attachTool: AgentTool = { name: "attach", label: "attach", - description: "Attach a file to your response. Use this to share files, images, or documents with the user.", + description: + "Attach a file to your response. Use this to share files, images, or documents with the user. Only files from /workspace/ can be attached.", parameters: attachSchema, execute: async ( _toolCallId: string, From ef8bb6c062be55fcc9cb184b980a6a78057cf094 Mon Sep 17 00:00:00 2001 From: Markus Ylisiurunen Date: Fri, 5 Dec 2025 00:05:29 +0200 Subject: [PATCH 05/54] support appending content to the system prompt via cli argument --- .gitignore | 3 +- packages/coding-agent/src/main.ts | 109 ++++++++++++++++-------------- 2 files changed, 62 insertions(+), 50 deletions(-) diff --git a/.gitignore b/.gitignore index 98e604d3..56098f88 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ packages/*/dist-firefox/ # Editor files .vscode/ +.zed/ .idea/ *.swp *.swo @@ -23,4 +24,4 @@ packages/*/dist-firefox/ coverage/ .nyc_output/ .pi_config/ -tui-debug.log \ No newline at end of file +tui-debug.log diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index e6e7431f..12c80360 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -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) { @@ -231,22 +234,23 @@ ${chalk.bold("Usage:")} ${APP_NAME} [options] [@files...] [messages...] ${chalk.bold("Options:")} - --provider Provider name (default: google) - --model Model ID (default: gemini-2.5-flash) - --api-key API key (defaults to env vars) - --system-prompt System prompt (default: coding assistant prompt) - --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 Use specific session file - --no-session Don't save session (ephemeral) - --models Comma-separated model patterns for quick cycling with Ctrl+P - --tools Comma-separated list of tools to enable (default: read,bash,edit,write) - Available: read, bash, edit, write, grep, find, ls - --thinking Set thinking level: off, minimal, low, medium, high - --export Export session file to HTML and exit - --help, -h Show this help + --provider Provider name (default: google) + --model Model ID (default: gemini-2.5-flash) + --api-key API key (defaults to env vars) + --system-prompt System prompt (default: coding assistant prompt) + --append-system-prompt Append text or file contents to the system prompt + --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 Use specific session file + --no-session Don't save session (ephemeral) + --models Comma-separated model patterns for quick cycling with Ctrl+P + --tools Comma-separated list of tools to enable (default: read,bash,edit,write) + Available: read, bash, edit, write, grep, find, ls + --thinking Set thinking level: off, minimal, low, medium, high + --export Export session file to HTML and exit + --help, -h Show this help ${chalk.bold("Examples:")} # Interactive mode @@ -320,32 +324,47 @@ const toolDescriptions: Record = { 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 +383,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 +460,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) { @@ -1138,7 +1149,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 From 342af285c469e9a91bfc46c8c436f012cef81a60 Mon Sep 17 00:00:00 2001 From: Markus Ylisiurunen Date: Fri, 5 Dec 2025 09:28:41 +0200 Subject: [PATCH 06/54] document the new --append-system-prompt flag --- packages/coding-agent/CHANGELOG.md | 4 ++++ packages/coding-agent/README.md | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index cbb917f7..eed76e4e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- **`--append-system-prompt` Flag**: Append additional text or file contents to the system prompt. Supports both inline text and file paths. Complements `--system-prompt` for layering custom instructions without replacing the base system prompt. ([#114](https://github.com/badlogic/pi-mono/pull/114)) + ## [0.12.10] - 2025-12-04 ### Added diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index cd12e872..1d41ac70 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -926,6 +926,13 @@ Custom system prompt. Can be: If the argument is a valid file path, the file contents will be used as the system prompt. Otherwise, the text is used directly. Project context files and datetime are automatically appended. +**--append-system-prompt ** +Append additional text or file contents to the system prompt. Can be: +- Inline text: `--append-system-prompt "Also consider edge cases"` +- File path: `--append-system-prompt ./extra-instructions.txt` + +If the argument is a valid file path, the file contents will be appended. Otherwise, the text is appended directly. This complements `--system-prompt` for layering custom instructions without replacing the base system prompt. Works in both custom and default system prompts. + **--mode ** Output mode for non-interactive usage (implies `--print`). Options: - `text` (default): Output only the final assistant message text From 590db4b6cf64174e965bc7cb85feb0e8eba2e4ad Mon Sep 17 00:00:00 2001 From: Markus Ylisiurunen Date: Thu, 4 Dec 2025 20:48:56 +0200 Subject: [PATCH 07/54] allow toggling visibility of the assistant's thinking block --- packages/coding-agent/src/settings-manager.ts | 10 +++++ .../coding-agent/src/tui/assistant-message.ts | 11 ++++- .../coding-agent/src/tui/custom-editor.ts | 7 +++ packages/coding-agent/src/tui/tui-renderer.ts | 43 +++++++++++++++++-- 4 files changed, 65 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/src/settings-manager.ts b/packages/coding-agent/src/settings-manager.ts index a2306854..f9147f28 100644 --- a/packages/coding-agent/src/settings-manager.ts +++ b/packages/coding-agent/src/settings-manager.ts @@ -16,6 +16,7 @@ export interface Settings { queueMode?: "all" | "one-at-a-time"; theme?: string; compaction?: CompactionSettings; + hideThinkingBlock?: boolean; } export class SettingsManager { @@ -143,4 +144,13 @@ export class SettingsManager { keepRecentTokens: this.getCompactionKeepRecentTokens(), }; } + + getHideThinkingBlock(): boolean { + return this.settings.hideThinkingBlock ?? false; + } + + setHideThinkingBlock(hide: boolean): void { + this.settings.hideThinkingBlock = hide; + this.save(); + } } diff --git a/packages/coding-agent/src/tui/assistant-message.ts b/packages/coding-agent/src/tui/assistant-message.ts index e1a33fba..611bbcc0 100644 --- a/packages/coding-agent/src/tui/assistant-message.ts +++ b/packages/coding-agent/src/tui/assistant-message.ts @@ -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(); @@ -39,7 +46,7 @@ export class AssistantMessageComponent extends Container { // 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()) { + } else if (content.type === "thinking" && content.thinking.trim() && !this.hideThinkingBlock) { // Thinking traces in muted color, italic // Use Markdown component with default text style for consistent styling this.contentContainer.addChild( diff --git a/packages/coding-agent/src/tui/custom-editor.ts b/packages/coding-agent/src/tui/custom-editor.ts index 49e41703..a63932bd 100644 --- a/packages/coding-agent/src/tui/custom-editor.ts +++ b/packages/coding-agent/src/tui/custom-editor.ts @@ -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(); diff --git a/packages/coding-agent/src/tui/tui-renderer.ts b/packages/coding-agent/src/tui/tui-renderer.ts index 5cba73e7..888d5d94 100644 --- a/packages/coding-agent/src/tui/tui-renderer.ts +++ b/packages/coding-agent/src/tui/tui-renderer.ts @@ -107,6 +107,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; @@ -211,6 +214,9 @@ export class TuiRenderer { description: "Toggle automatic context compaction", }; + // Load hide thinking block setting + this.hideThinkingBlock = settingsManager.getHideThinkingBlock(); + // Load file-based slash commands this.fileCommands = loadSlashCommands(); @@ -272,6 +278,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" + @@ -362,6 +371,10 @@ export class TuiRenderer { this.toggleToolOutputExpansion(); }; + this.editor.onCtrlT = () => { + this.toggleThinkingBlockVisibility(); + }; + // Handle editor submission this.editor.onSubmit = async (text: string) => { text = text.trim(); @@ -648,7 +661,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(); @@ -788,7 +801,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 @@ -834,7 +847,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,7 +931,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) { @@ -1121,6 +1134,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(); From a29cd80acf5842917d005cc1f2c63262085c10bb Mon Sep 17 00:00:00 2001 From: Markus Ylisiurunen Date: Fri, 5 Dec 2025 09:48:35 +0200 Subject: [PATCH 08/54] add ctrl+t shortcut to toggle llm thinking block visibility --- packages/coding-agent/CHANGELOG.md | 4 +++ packages/coding-agent/README.md | 1 + .../coding-agent/src/tui/assistant-message.ts | 25 +++++++++++-------- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index cbb917f7..5b2d25c5 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- **Thinking Block Toggle**: Added `Ctrl+T` shortcut to toggle visibility of LLM thinking blocks. When toggled off, shows a static "Thinking..." label instead of full content. Useful for reducing visual clutter during long conversations. ([#113](https://github.com/badlogic/pi-mono/pull/113)) + ## [0.12.10] - 2025-12-04 ### Added diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index cd12e872..f686a322 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -684,6 +684,7 @@ Change queue mode with `/queue` command. Setting is saved in `~/.pi/agent/settin - **Shift+Tab**: Cycle thinking level (for reasoning-capable models) - **Ctrl+P**: Cycle models (use `--models` to scope) - **Ctrl+O**: Toggle tool output expansion (collapsed ↔ full output) +- **Ctrl+T**: Toggle thinking block visibility (shows full content ↔ static "Thinking..." label) ## Project Context Files diff --git a/packages/coding-agent/src/tui/assistant-message.ts b/packages/coding-agent/src/tui/assistant-message.ts index 611bbcc0..31e0f80e 100644 --- a/packages/coding-agent/src/tui/assistant-message.ts +++ b/packages/coding-agent/src/tui/assistant-message.ts @@ -46,16 +46,21 @@ export class AssistantMessageComponent extends Container { // 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() && !this.hideThinkingBlock) { - // 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)); + } else if (content.type === "thinking" && content.thinking.trim()) { + if (this.hideThinkingBlock) { + // Show static "Thinking..." label when hidden + this.contentContainer.addChild(new Text(theme.fg("muted", "Thinking..."), 1, 0)); + } 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)); + } } } From 266f30326893224f5510f57c05cf9eedfc266546 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 5 Dec 2025 10:37:14 +0100 Subject: [PATCH 09/54] Add contributor link to CHANGELOG entry --- packages/coding-agent/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index eed76e4e..3681038e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- **`--append-system-prompt` Flag**: Append additional text or file contents to the system prompt. Supports both inline text and file paths. Complements `--system-prompt` for layering custom instructions without replacing the base system prompt. ([#114](https://github.com/badlogic/pi-mono/pull/114)) +- **`--append-system-prompt` Flag**: Append additional text or file contents to the system prompt. Supports both inline text and file paths. Complements `--system-prompt` for layering custom instructions without replacing the base system prompt. ([#114](https://github.com/badlogic/pi-mono/pull/114) by [@markusylisiurunen](https://github.com/markusylisiurunen)) ## [0.12.10] - 2025-12-04 From 4a3e553260760d0f1a16f1d8d966a28c6292511f Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 5 Dec 2025 10:48:28 +0100 Subject: [PATCH 10/54] Fix spacer after hidden thinking block, add contributor link to CHANGELOG --- packages/coding-agent/CHANGELOG.md | 2 +- packages/coding-agent/src/tui/assistant-message.ts | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5b2d25c5..dfdefed7 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- **Thinking Block Toggle**: Added `Ctrl+T` shortcut to toggle visibility of LLM thinking blocks. When toggled off, shows a static "Thinking..." label instead of full content. Useful for reducing visual clutter during long conversations. ([#113](https://github.com/badlogic/pi-mono/pull/113)) +- **Thinking Block Toggle**: Added `Ctrl+T` shortcut to toggle visibility of LLM thinking blocks. When toggled off, shows a static "Thinking..." label instead of full content. Useful for reducing visual clutter during long conversations. ([#113](https://github.com/badlogic/pi-mono/pull/113) by [@markusylisiurunen](https://github.com/markusylisiurunen)) ## [0.12.10] - 2025-12-04 diff --git a/packages/coding-agent/src/tui/assistant-message.ts b/packages/coding-agent/src/tui/assistant-message.ts index 31e0f80e..8757e76c 100644 --- a/packages/coding-agent/src/tui/assistant-message.ts +++ b/packages/coding-agent/src/tui/assistant-message.ts @@ -41,15 +41,22 @@ 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()) { + // 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 From 398591fdb05dc8544830b977789ac0840cbad9c9 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 5 Dec 2025 11:05:04 +0100 Subject: [PATCH 11/54] Improve compaction UI styling - Simplified collapsed state: warning-colored text instead of styled banner - Shows token count inline: 'Earlier messages compacted from X tokens' - Removed redundant success message after compaction - Cleaner vertical spacing using paddingY instead of explicit Spacers Fixes #108 --- packages/coding-agent/CHANGELOG.md | 4 ++++ packages/coding-agent/src/tui/compaction.ts | 10 +++++----- packages/coding-agent/src/tui/tui-renderer.ts | 13 ------------- 3 files changed, 9 insertions(+), 18 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index fb37adb4..0e95e22e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Changed + +- **Compaction UI**: Simplified collapsed compaction indicator to show warning-colored text with token count instead of styled banner. Removed redundant success message after compaction. ([#108](https://github.com/badlogic/pi-mono/issues/108)) + ### Added - **`--append-system-prompt` Flag**: Append additional text or file contents to the system prompt. Supports both inline text and file paths. Complements `--system-prompt` for layering custom instructions without replacing the base system prompt. ([#114](https://github.com/badlogic/pi-mono/pull/114) by [@markusylisiurunen](https://github.com/markusylisiurunen)) diff --git a/packages/coding-agent/src/tui/compaction.ts b/packages/coding-agent/src/tui/compaction.ts index 11c08eea..f2835ee7 100644 --- a/packages/coding-agent/src/tui/compaction.ts +++ b/packages/coding-agent/src/tui/compaction.ts @@ -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)); } } diff --git a/packages/coding-agent/src/tui/tui-renderer.ts b/packages/coding-agent/src/tui/tui-renderer.ts index 888d5d94..13e66778 100644 --- a/packages/coding-agent/src/tui/tui-renderer.ts +++ b/packages/coding-agent/src/tui/tui-renderer.ts @@ -1175,15 +1175,6 @@ export class TuiRenderer { this.ui.requestRender(); } - private showSuccess(message: string, detail?: string): void { - 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.ui.requestRender(); - } - private showThinkingSelector(): void { // Create thinking selector with current level this.thinkingSelector = new ThinkingSelectorComponent( @@ -1914,10 +1905,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")) { From 51195bc9fcb9ec8d7e45c50011e7e4537f275e8e Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 5 Dec 2025 11:31:14 +0100 Subject: [PATCH 12/54] Add authHeader option and fix print mode error handling - Add 'authHeader' boolean option to models.json provider config When true, adds 'Authorization: Bearer ' to model headers Useful for providers requiring explicit auth headers (fixes #81) - Fix print mode (-p) silently failing on errors Now outputs error message to stderr and exits with code 1 when assistant message has stopReason of error/aborted --- packages/coding-agent/CHANGELOG.md | 5 +++++ packages/coding-agent/src/main.ts | 10 +++++++++- packages/coding-agent/src/model-config.ts | 11 ++++++++++- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 0e95e22e..ff0512ee 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -6,8 +6,13 @@ - **Compaction UI**: Simplified collapsed compaction indicator to show warning-colored text with token count instead of styled banner. Removed redundant success message after compaction. ([#108](https://github.com/badlogic/pi-mono/issues/108)) +### Fixed + +- **Print mode error handling**: `-p` flag now outputs error messages and exits with code 1 when requests fail, instead of silently producing no output. + ### Added +- **`authHeader` option in models.json**: Custom providers can set `"authHeader": true` to automatically add `Authorization: Bearer ` header. Useful for providers that require explicit auth headers. ([#81](https://github.com/badlogic/pi-mono/issues/81)) - **`--append-system-prompt` Flag**: Append additional text or file contents to the system prompt. Supports both inline text and file paths. Complements `--system-prompt` for layering custom instructions without replacing the base system prompt. ([#114](https://github.com/badlogic/pi-mono/pull/114) by [@markusylisiurunen](https://github.com/markusylisiurunen)) - **Thinking Block Toggle**: Added `Ctrl+T` shortcut to toggle visibility of LLM thinking blocks. When toggled off, shows a static "Thinking..." label instead of full content. Useful for reducing visual clutter during long conversations. ([#113](https://github.com/badlogic/pi-mono/pull/113) by [@markusylisiurunen](https://github.com/markusylisiurunen)) diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 12c80360..7f764ac1 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -817,7 +817,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); } diff --git a/packages/coding-agent/src/model-config.ts b/packages/coding-agent/src/model-config.ts index b5265028..abbebdb1 100644 --- a/packages/coding-agent/src/model-config.ts +++ b/packages/coding-agent/src/model-config.ts @@ -46,6 +46,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 +178,17 @@ function parseModels(config: ModelsConfig): Model[] { } // 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, From ed2c18250151a06d91f3e0af800242ed11c5a1bc Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 5 Dec 2025 11:32:59 +0100 Subject: [PATCH 13/54] Document authHeader option in README --- packages/coding-agent/README.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index f085404e..ec4809fc 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -291,6 +291,36 @@ You can add custom HTTP headers to bypass Cloudflare bot detection, add authenti - **Model-level `headers`**: Additional headers for specific models (merged with provider headers) - Model headers override provider headers when keys conflict +### Authorization Header + +Some providers require an explicit `Authorization: Bearer ` header. Set `authHeader: true` to automatically add this header using the resolved `apiKey`: + +```json +{ + "providers": { + "qwen": { + "baseUrl": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "apiKey": "QWEN_API_KEY", + "authHeader": true, + "api": "openai-completions", + "models": [ + { + "id": "qwen3-coder-plus", + "name": "Qwen3 Coder Plus", + "reasoning": true, + "input": ["text"], + "cost": {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0}, + "contextWindow": 1000000, + "maxTokens": 65536 + } + ] + } + } +} +``` + +When `authHeader: true`, the resolved API key is added as `Authorization: Bearer ` to the model headers. This is useful for providers that don't use the standard OpenAI authentication mechanism. + ### Model Selection Priority When starting `pi`, models are selected in this order: From d5e0cb463088716118220e2a66a4ac8fb12435e7 Mon Sep 17 00:00:00 2001 From: Hew Li Yang Date: Fri, 5 Dec 2025 18:47:57 +0800 Subject: [PATCH 14/54] wip: add /resume slash command --- packages/coding-agent/README.md | 10 +++ packages/coding-agent/src/tui/tui-renderer.ts | 64 +++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index ec4809fc..630fc48b 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -502,6 +502,16 @@ This allows you to explore alternative conversation paths without losing your cu /branch ``` +### /resume + +Switch to a different session. Opens an interactive selector showing all available sessions. Select a session to load it and continue where you left off. + +This is equivalent to the `--resume` CLI flag but can be used mid-session. + +``` +/resume +``` + ### /login Login with OAuth to use subscription-based models (Claude Pro/Max): diff --git a/packages/coding-agent/src/tui/tui-renderer.ts b/packages/coding-agent/src/tui/tui-renderer.ts index 13e66778..6254ec45 100644 --- a/packages/coding-agent/src/tui/tui-renderer.ts +++ b/packages/coding-agent/src/tui/tui-renderer.ts @@ -42,6 +42,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"; @@ -95,6 +96,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; @@ -214,6 +218,11 @@ 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(); @@ -243,6 +252,7 @@ export class TuiRenderer { clearCommand, compactCommand, autocompactCommand, + resumeCommand, ...fileSlashCommands, ], process.cwd(), @@ -488,6 +498,13 @@ export class TuiRenderer { return; } + // Check for /resume command + if (text === "/resume") { + this.showSessionSelector(); + this.editor.setText(""); + return; + } + // Check for file-based slash commands text = expandSlashCommand(text, this.fileCommands); @@ -1468,6 +1485,53 @@ export class TuiRenderer { this.ui.setFocus(this.editor); } + private showSessionSelector(): void { + // Create session selector + this.sessionSelector = new SessionSelectorComponent( + this.sessionManager, + (sessionPath) => { + // Set the selected session as active + this.sessionManager.setSessionFile(sessionPath); + + // Reload the session + const loaded = loadSessionFromEntries(this.sessionManager.loadEntries()); + this.agent.replaceMessages(loaded.messages); + + // 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)); + + // Hide selector and show editor again + this.hideSessionSelector(); + this.ui.requestRender(); + }, + () => { + // 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 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 { // For logout mode, filter to only show logged-in providers let providersToShow: string[] = []; From 30f63bcaf6d610791c782bec000ec158f8165365 Mon Sep 17 00:00:00 2001 From: Hew Li Yang Date: Fri, 5 Dec 2025 18:56:44 +0800 Subject: [PATCH 15/54] wip: changelog --- packages/coding-agent/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ff0512ee..e5ee9007 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -12,6 +12,7 @@ ### Added +- **`/resume` Command**: Switch to a different session mid-conversation. Opens an interactive selector showing all available sessions. Equivalent to the `--resume` CLI flag but can be used without restarting the agent. ([#117](https://github.com/badlogic/pi-mono/pull/117) by [@hewliyang](https://github.com/hewliyang)) - **`authHeader` option in models.json**: Custom providers can set `"authHeader": true` to automatically add `Authorization: Bearer ` header. Useful for providers that require explicit auth headers. ([#81](https://github.com/badlogic/pi-mono/issues/81)) - **`--append-system-prompt` Flag**: Append additional text or file contents to the system prompt. Supports both inline text and file paths. Complements `--system-prompt` for layering custom instructions without replacing the base system prompt. ([#114](https://github.com/badlogic/pi-mono/pull/114) by [@markusylisiurunen](https://github.com/markusylisiurunen)) - **Thinking Block Toggle**: Added `Ctrl+T` shortcut to toggle visibility of LLM thinking blocks. When toggled off, shows a static "Thinking..." label instead of full content. Useful for reducing visual clutter during long conversations. ([#113](https://github.com/badlogic/pi-mono/pull/113) by [@markusylisiurunen](https://github.com/markusylisiurunen)) From 240064eec3db43edf6cffd9caeabe4f261df2356 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 5 Dec 2025 11:59:39 +0100 Subject: [PATCH 16/54] fix: TUI crash with Unicode characters in branch selector - Use truncateToWidth instead of substring in user-message-selector.ts - Fix truncateToWidth to use Intl.Segmenter for proper grapheme handling - Add tests for Unicode truncation behavior --- packages/coding-agent/CHANGELOG.md | 1 + .../src/tui/user-message-selector.ts | 6 +- .../test/truncate-to-width.test.ts | 81 +++++++++++++++++++ packages/tui/src/utils.ts | 55 ++++++++----- 4 files changed, 121 insertions(+), 22 deletions(-) create mode 100644 packages/coding-agent/test/truncate-to-width.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ff0512ee..1a5a7f2b 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixed - **Print mode error handling**: `-p` flag now outputs error messages and exits with code 1 when requests fail, instead of silently producing no output. +- **Branch selector crash**: Fixed TUI crash when user messages contained Unicode characters (like `✔` or `›`) that caused line width to exceed terminal width. Now uses proper `truncateToWidth` instead of `substring`. ### Added diff --git a/packages/coding-agent/src/tui/user-message-selector.ts b/packages/coding-agent/src/tui/user-message-selector.ts index 1d9d7bfe..51f39418 100644 --- a/packages/coding-agent/src/tui/user-message-selector.ts +++ b/packages/coding-agent/src/tui/user-message-selector.ts @@ -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); diff --git a/packages/coding-agent/test/truncate-to-width.test.ts b/packages/coding-agent/test/truncate-to-width.test.ts new file mode 100644 index 00000000..4714c1d2 --- /dev/null +++ b/packages/coding-agent/test/truncate-to-width.test.ts @@ -0,0 +1,81 @@ +import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; +import { describe, expect, it } from "vitest"; + +/** + * Tests for truncateToWidth behavior with Unicode characters. + * + * These tests verify that truncateToWidth properly handles text with + * Unicode characters that have different byte vs display widths. + */ +describe("truncateToWidth", () => { + it("should truncate messages with Unicode characters correctly", () => { + // This message contains a checkmark (✔) which may have display width > 1 byte + const message = '✔ script to run › dev $ concurrently "vite" "node --import tsx ./'; + const width = 67; + const maxMsgWidth = width - 2; // Account for cursor + + const truncated = truncateToWidth(message, maxMsgWidth); + const truncatedWidth = visibleWidth(truncated); + + expect(truncatedWidth).toBeLessThanOrEqual(maxMsgWidth); + }); + + it("should handle emoji characters", () => { + const message = "🎉 Celebration! 🚀 Launch 📦 Package ready for deployment now"; + const width = 40; + const maxMsgWidth = width - 2; + + const truncated = truncateToWidth(message, maxMsgWidth); + const truncatedWidth = visibleWidth(truncated); + + expect(truncatedWidth).toBeLessThanOrEqual(maxMsgWidth); + }); + + it("should handle mixed ASCII and wide characters", () => { + const message = "Hello 世界 Test 你好 More text here that is long"; + const width = 30; + const maxMsgWidth = width - 2; + + const truncated = truncateToWidth(message, maxMsgWidth); + const truncatedWidth = visibleWidth(truncated); + + expect(truncatedWidth).toBeLessThanOrEqual(maxMsgWidth); + }); + + it("should not truncate messages that fit", () => { + const message = "Short message"; + const width = 50; + const maxMsgWidth = width - 2; + + const truncated = truncateToWidth(message, maxMsgWidth); + + expect(truncated).toBe(message); + expect(visibleWidth(truncated)).toBeLessThanOrEqual(maxMsgWidth); + }); + + it("should add ellipsis when truncating", () => { + const message = "This is a very long message that needs to be truncated"; + const width = 30; + const maxMsgWidth = width - 2; + + const truncated = truncateToWidth(message, maxMsgWidth); + + expect(truncated).toContain("..."); + expect(visibleWidth(truncated)).toBeLessThanOrEqual(maxMsgWidth); + }); + + it("should handle the exact crash case from issue report", () => { + // Terminal width was 67, line had visible width 68 + // The problematic text contained "✔" and "›" characters + const message = '✔ script to run › dev $ concurrently "vite" "node --import tsx ./server.ts"'; + const terminalWidth = 67; + const cursorWidth = 2; // "› " or " " + const maxMsgWidth = terminalWidth - cursorWidth; + + const truncated = truncateToWidth(message, maxMsgWidth); + const finalWidth = visibleWidth(truncated); + + // The final line (cursor + message) must not exceed terminal width + expect(finalWidth + cursorWidth).toBeLessThanOrEqual(terminalWidth); + }); +}); diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index 8dbbb540..718ac99a 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -309,36 +309,53 @@ export function truncateToWidth(text: string, maxWidth: number, ellipsis: string return ellipsis.substring(0, maxWidth); } - let currentWidth = 0; - let truncateAt = 0; + // Separate ANSI codes from visible content using grapheme segmentation let i = 0; + const segments: Array<{ type: "ansi" | "grapheme"; value: string }> = []; - while (i < text.length && currentWidth < targetWidth) { - // Skip ANSI escape sequences (include them in output but don't count width) - if (text[i] === "\x1b" && text[i + 1] === "[") { - let j = i + 2; - while (j < text.length && !/[a-zA-Z]/.test(text[j]!)) { - j++; + while (i < text.length) { + const ansiResult = extractAnsiCode(text, i); + if (ansiResult) { + segments.push({ type: "ansi", value: ansiResult.code }); + i += ansiResult.length; + } else { + // Find the next ANSI code or end of string + let end = i; + while (end < text.length) { + const nextAnsi = extractAnsiCode(text, end); + if (nextAnsi) break; + end++; } - // Include the final letter of the escape sequence - j++; - truncateAt = j; - i = j; + // Segment this non-ANSI portion into graphemes + const textPortion = text.slice(i, end); + for (const seg of segmenter.segment(textPortion)) { + segments.push({ type: "grapheme", value: seg.segment }); + } + i = end; + } + } + + // Build truncated string from segments + let result = ""; + let currentWidth = 0; + + for (const seg of segments) { + if (seg.type === "ansi") { + result += seg.value; continue; } - const char = text[i]!; - const charWidth = visibleWidth(char); + const grapheme = seg.value; + const graphemeWidth = visibleWidth(grapheme); - if (currentWidth + charWidth > targetWidth) { + if (currentWidth + graphemeWidth > targetWidth) { break; } - currentWidth += charWidth; - truncateAt = i + 1; - i++; + result += grapheme; + currentWidth += graphemeWidth; } // Add reset code before ellipsis to prevent styling leaking into it - return text.substring(0, truncateAt) + "\x1b[0m" + ellipsis; + return result + "\x1b[0m" + ellipsis; } From 93753843715a50c41ae36f2525b51479f14c8e25 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 5 Dec 2025 12:13:33 +0100 Subject: [PATCH 17/54] fix: strip remaining escape sequences from bash output stripAnsi misses some escape sequences like standalone ESC \ (String Terminator) which caused rendering issues when displaying captured TUI output. Now also removes any remaining ESC+char sequences and control characters after stripAnsi processing. --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/tui/tool-execution.ts | 12 +++++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1a5a7f2b..0c1efed1 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -10,6 +10,7 @@ - **Print mode error handling**: `-p` flag now outputs error messages and exits with code 1 when requests fail, instead of silently producing no output. - **Branch selector crash**: Fixed TUI crash when user messages contained Unicode characters (like `✔` or `›`) that caused line width to exceed terminal width. Now uses proper `truncateToWidth` instead of `substring`. +- **Bash output escape sequences**: Fixed incomplete stripping of terminal escape sequences in bash tool output. `stripAnsi` misses some sequences like standalone String Terminator (`ESC \`), which could cause rendering issues when displaying captured TUI output. ### Added diff --git a/packages/coding-agent/src/tui/tool-execution.ts b/packages/coding-agent/src/tui/tool-execution.ts index cca9ac07..8f7c66d6 100644 --- a/packages/coding-agent/src/tui/tool-execution.ts +++ b/packages/coding-agent/src/tui/tool-execution.ts @@ -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) { From ef333af3d1e1f51aab95a2c2bbe659d87706e987 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 5 Dec 2025 12:17:56 +0100 Subject: [PATCH 18/54] fix: footer overflow crash on narrow terminals Footer stats line now truncates gracefully when terminal width is too narrow to fit all stats, instead of overflowing and crashing the TUI. --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/tui/footer.ts | 12 ++++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 0c1efed1..139d62d4 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -11,6 +11,7 @@ - **Print mode error handling**: `-p` flag now outputs error messages and exits with code 1 when requests fail, instead of silently producing no output. - **Branch selector crash**: Fixed TUI crash when user messages contained Unicode characters (like `✔` or `›`) that caused line width to exceed terminal width. Now uses proper `truncateToWidth` instead of `substring`. - **Bash output escape sequences**: Fixed incomplete stripping of terminal escape sequences in bash tool output. `stripAnsi` misses some sequences like standalone String Terminator (`ESC \`), which could cause rendering issues when displaying captured TUI output. +- **Footer overflow crash**: Fixed TUI crash when terminal width is too narrow for the footer stats line. The footer now truncates gracefully instead of overflowing. ### Added diff --git a/packages/coding-agent/src/tui/footer.ts b/packages/coding-agent/src/tui/footer.ts index b38f8936..3f1e1986 100644 --- a/packages/coding-agent/src/tui/footer.ts +++ b/packages/coding-agent/src/tui/footer.ts @@ -196,7 +196,7 @@ export class FooterComponent implements Component { } 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"; @@ -210,9 +210,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; From b193560ab0b120ed49937cb2cfbd302cfe04524b Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 5 Dec 2025 12:19:37 +0100 Subject: [PATCH 19/54] Release v0.12.11 --- package-lock.json | 38 ++++++++++++++-------------- packages/agent/package.json | 6 ++--- packages/ai/package.json | 2 +- packages/coding-agent/CHANGELOG.md | 2 +- packages/coding-agent/package.json | 8 +++--- packages/mom/package.json | 6 ++--- packages/pods/package.json | 4 +-- packages/proxy/package.json | 2 +- packages/tui/package.json | 2 +- packages/web-ui/example/package.json | 2 +- packages/web-ui/package.json | 6 ++--- 11 files changed, 39 insertions(+), 39 deletions(-) diff --git a/package-lock.json b/package-lock.json index 39c1e71e..b530144e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5975,11 +5975,11 @@ }, "packages/agent": { "name": "@mariozechner/pi-agent-core", - "version": "0.12.10", + "version": "0.12.11", "license": "MIT", "dependencies": { - "@mariozechner/pi-ai": "^0.12.9", - "@mariozechner/pi-tui": "^0.12.9" + "@mariozechner/pi-ai": "^0.12.10", + "@mariozechner/pi-tui": "^0.12.10" }, "devDependencies": { "@types/node": "^24.3.0", @@ -6009,7 +6009,7 @@ }, "packages/ai": { "name": "@mariozechner/pi-ai", - "version": "0.12.10", + "version": "0.12.11", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.61.0", @@ -6050,12 +6050,12 @@ }, "packages/coding-agent": { "name": "@mariozechner/pi-coding-agent", - "version": "0.12.10", + "version": "0.12.11", "license": "MIT", "dependencies": { - "@mariozechner/pi-agent-core": "^0.12.9", - "@mariozechner/pi-ai": "^0.12.9", - "@mariozechner/pi-tui": "^0.12.9", + "@mariozechner/pi-agent-core": "^0.12.10", + "@mariozechner/pi-ai": "^0.12.10", + "@mariozechner/pi-tui": "^0.12.10", "chalk": "^5.5.0", "diff": "^8.0.2", "glob": "^11.0.3" @@ -6092,12 +6092,12 @@ }, "packages/mom": { "name": "@mariozechner/pi-mom", - "version": "0.12.10", + "version": "0.12.11", "license": "MIT", "dependencies": { "@anthropic-ai/sandbox-runtime": "^0.0.16", - "@mariozechner/pi-agent-core": "^0.12.9", - "@mariozechner/pi-ai": "^0.12.9", + "@mariozechner/pi-agent-core": "^0.12.10", + "@mariozechner/pi-ai": "^0.12.10", "@sinclair/typebox": "^0.34.0", "@slack/socket-mode": "^2.0.0", "@slack/web-api": "^7.0.0", @@ -6135,10 +6135,10 @@ }, "packages/pods": { "name": "@mariozechner/pi", - "version": "0.12.10", + "version": "0.12.11", "license": "MIT", "dependencies": { - "@mariozechner/pi-agent-core": "^0.12.9", + "@mariozechner/pi-agent-core": "^0.12.10", "chalk": "^5.5.0" }, "bin": { @@ -6151,7 +6151,7 @@ }, "packages/proxy": { "name": "@mariozechner/pi-proxy", - "version": "0.12.10", + "version": "0.12.11", "dependencies": { "@hono/node-server": "^1.14.0", "hono": "^4.6.16" @@ -6167,7 +6167,7 @@ }, "packages/tui": { "name": "@mariozechner/pi-tui", - "version": "0.12.10", + "version": "0.12.11", "license": "MIT", "dependencies": { "@types/mime-types": "^2.1.4", @@ -6211,12 +6211,12 @@ }, "packages/web-ui": { "name": "@mariozechner/pi-web-ui", - "version": "0.12.10", + "version": "0.12.11", "license": "MIT", "dependencies": { "@lmstudio/sdk": "^1.5.0", - "@mariozechner/pi-ai": "^0.12.9", - "@mariozechner/pi-tui": "^0.12.9", + "@mariozechner/pi-ai": "^0.12.10", + "@mariozechner/pi-tui": "^0.12.10", "docx-preview": "^0.3.7", "jszip": "^3.10.1", "lucide": "^0.544.0", @@ -6237,7 +6237,7 @@ }, "packages/web-ui/example": { "name": "pi-web-ui-example", - "version": "1.0.6", + "version": "1.0.7", "dependencies": { "@mariozechner/mini-lit": "^0.2.0", "@mariozechner/pi-ai": "file:../../ai", diff --git a/packages/agent/package.json b/packages/agent/package.json index 9b9506af..79cc0880 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-agent-core", - "version": "0.12.10", + "version": "0.12.11", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "type": "module", "main": "./dist/index.js", @@ -18,8 +18,8 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@mariozechner/pi-ai": "^0.12.10", - "@mariozechner/pi-tui": "^0.12.10" + "@mariozechner/pi-ai": "^0.12.11", + "@mariozechner/pi-tui": "^0.12.11" }, "keywords": [ "ai", diff --git a/packages/ai/package.json b/packages/ai/package.json index 91d9a951..1fb6db05 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-ai", - "version": "0.12.10", + "version": "0.12.11", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 139d62d4..41a64f7e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.12.11] - 2025-12-05 ### Changed diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 9f2a4d20..c0a45d01 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-coding-agent", - "version": "0.12.10", + "version": "0.12.11", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "type": "module", "piConfig": { @@ -28,9 +28,9 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@mariozechner/pi-agent-core": "^0.12.10", - "@mariozechner/pi-ai": "^0.12.10", - "@mariozechner/pi-tui": "^0.12.10", + "@mariozechner/pi-agent-core": "^0.12.11", + "@mariozechner/pi-ai": "^0.12.11", + "@mariozechner/pi-tui": "^0.12.11", "chalk": "^5.5.0", "diff": "^8.0.2", "glob": "^11.0.3" diff --git a/packages/mom/package.json b/packages/mom/package.json index 1f822de2..052b9a0e 100644 --- a/packages/mom/package.json +++ b/packages/mom/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-mom", - "version": "0.12.10", + "version": "0.12.11", "description": "Slack bot that delegates messages to the pi coding agent", "type": "module", "bin": { @@ -21,8 +21,8 @@ }, "dependencies": { "@anthropic-ai/sandbox-runtime": "^0.0.16", - "@mariozechner/pi-agent-core": "^0.12.10", - "@mariozechner/pi-ai": "^0.12.10", + "@mariozechner/pi-agent-core": "^0.12.11", + "@mariozechner/pi-ai": "^0.12.11", "@sinclair/typebox": "^0.34.0", "@slack/socket-mode": "^2.0.0", "@slack/web-api": "^7.0.0", diff --git a/packages/pods/package.json b/packages/pods/package.json index 29fc87ce..3e32dab4 100644 --- a/packages/pods/package.json +++ b/packages/pods/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi", - "version": "0.12.10", + "version": "0.12.11", "description": "CLI tool for managing vLLM deployments on GPU pods", "type": "module", "bin": { @@ -34,7 +34,7 @@ "node": ">=20.0.0" }, "dependencies": { - "@mariozechner/pi-agent-core": "^0.12.10", + "@mariozechner/pi-agent-core": "^0.12.11", "chalk": "^5.5.0" }, "devDependencies": {} diff --git a/packages/proxy/package.json b/packages/proxy/package.json index 55325e31..0e77727e 100644 --- a/packages/proxy/package.json +++ b/packages/proxy/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-proxy", - "version": "0.12.10", + "version": "0.12.11", "type": "module", "description": "CORS and authentication proxy for pi-ai", "main": "dist/index.js", diff --git a/packages/tui/package.json b/packages/tui/package.json index fa751aa9..e7f4ddad 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-tui", - "version": "0.12.10", + "version": "0.12.11", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "type": "module", "main": "dist/index.js", diff --git a/packages/web-ui/example/package.json b/packages/web-ui/example/package.json index 36a3d1ff..99587303 100644 --- a/packages/web-ui/example/package.json +++ b/packages/web-ui/example/package.json @@ -1,6 +1,6 @@ { "name": "pi-web-ui-example", - "version": "1.0.6", + "version": "1.0.7", "private": true, "type": "module", "scripts": { diff --git a/packages/web-ui/package.json b/packages/web-ui/package.json index 31e51b5c..63f896a7 100644 --- a/packages/web-ui/package.json +++ b/packages/web-ui/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-web-ui", - "version": "0.12.10", + "version": "0.12.11", "description": "Reusable web UI components for AI chat interfaces powered by @mariozechner/pi-ai", "type": "module", "main": "dist/index.js", @@ -18,8 +18,8 @@ }, "dependencies": { "@lmstudio/sdk": "^1.5.0", - "@mariozechner/pi-ai": "^0.12.10", - "@mariozechner/pi-tui": "^0.12.10", + "@mariozechner/pi-ai": "^0.12.11", + "@mariozechner/pi-tui": "^0.12.11", "docx-preview": "^0.3.7", "jszip": "^3.10.1", "lucide": "^0.544.0", From 6a29b2df3f8d72ed9c3cf98491983c5e11d5dfb0 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 5 Dec 2025 12:20:35 +0100 Subject: [PATCH 20/54] Add [Unreleased] section --- packages/ai/src/models.generated.ts | 125 ++++++++++++++++++++-------- packages/coding-agent/CHANGELOG.md | 2 + 2 files changed, 90 insertions(+), 37 deletions(-) diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 416afaac..75de7b00 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -1080,6 +1080,23 @@ export const MODELS = { contextWindow: 128000, maxTokens: 16384, } satisfies Model<"openai-responses">, + "gpt-5.1-codex-max": { + id: "gpt-5.1-codex-max", + name: "GPT-5.1 Codex Max", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.125, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, o3: { id: "o3", name: "o3", @@ -1199,23 +1216,6 @@ export const MODELS = { contextWindow: 128000, maxTokens: 16384, } satisfies Model<"openai-responses">, - "gpt-5.1-codex-max": { - id: "gpt-5.1-codex-max", - name: "GPT-5.1 Codex Max", - api: "openai-responses", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 1.25, - output: 10, - cacheRead: 0.125, - cacheWrite: 0, - }, - contextWindow: 400000, - maxTokens: 128000, - } satisfies Model<"openai-responses">, }, groq: { "llama-3.1-8b-instant": { @@ -2025,6 +2025,57 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 65535, } satisfies Model<"openai-completions">, + "mistralai/ministral-14b-2512": { + id: "mistralai/ministral-14b-2512", + name: "Mistral: Ministral 3 14B 2512", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.19999999999999998, + output: 0.19999999999999998, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "mistralai/ministral-8b-2512": { + id: "mistralai/ministral-8b-2512", + name: "Mistral: Ministral 3 8B 2512", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.15, + output: 0.15, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "mistralai/ministral-3b-2512": { + id: "mistralai/ministral-3b-2512", + name: "Mistral: Ministral 3 3B 2512", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.09999999999999999, + output: 0.09999999999999999, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "mistralai/mistral-large-2512": { id: "mistralai/mistral-large-2512", name: "Mistral: Mistral Large 3 2512", @@ -3351,23 +3402,6 @@ export const MODELS = { contextWindow: 400000, maxTokens: 128000, } satisfies Model<"openai-completions">, - "openai/gpt-oss-120b:exacto": { - id: "openai/gpt-oss-120b:exacto", - name: "OpenAI: gpt-oss-120b (exacto)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0.04, - output: 0.19999999999999998, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 32768, - } satisfies Model<"openai-completions">, "openai/gpt-oss-120b": { id: "openai/gpt-oss-120b", name: "OpenAI: gpt-oss-120b", @@ -3377,13 +3411,30 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.04, - output: 0.19999999999999998, + input: 0.039, + output: 0.19, cacheRead: 0, cacheWrite: 0, }, contextWindow: 131072, - maxTokens: 32768, + maxTokens: 4096, + } satisfies Model<"openai-completions">, + "openai/gpt-oss-120b:exacto": { + id: "openai/gpt-oss-120b:exacto", + name: "OpenAI: gpt-oss-120b (exacto)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.039, + output: 0.19, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, } satisfies Model<"openai-completions">, "openai/gpt-oss-20b:free": { id: "openai/gpt-oss-20b:free", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 41a64f7e..89e70513 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.12.11] - 2025-12-05 ### Changed From 5c388c0a7767acc6e21fdf31bce677e585497480 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 5 Dec 2025 12:21:44 +0100 Subject: [PATCH 21/54] fix: make footer token counts more compact - Use M suffix for millions (e.g., 10.2M instead of 10184k) - Change '% of' to '%/' for context display --- packages/coding-agent/src/tui/footer.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/tui/footer.ts b/packages/coding-agent/src/tui/footer.ts index 3f1e1986..cbd08656 100644 --- a/packages/coding-agent/src/tui/footer.ts +++ b/packages/coding-agent/src/tui/footer.ts @@ -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,7 +188,7 @@ export class FooterComponent implements Component { // Colorize context percentage based on usage let contextPercentStr: string; const autoIndicator = this.autoCompactEnabled ? " (auto)" : ""; - const contextPercentDisplay = `${contextPercent}% of ${formatTokens(contextWindow)}${autoIndicator}`; + const contextPercentDisplay = `${contextPercent}%/${formatTokens(contextWindow)}${autoIndicator}`; if (contextPercentValue > 90) { contextPercentStr = theme.fg("error", contextPercentDisplay); } else if (contextPercentValue > 70) { From ca39e899f4c60bc37957395a2223e56db48b5245 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 5 Dec 2025 12:22:30 +0100 Subject: [PATCH 22/54] docs: add changelog entry for footer display changes --- packages/coding-agent/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 89e70513..ee6be520 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Changed + +- **Footer display**: Token counts now use M suffix for millions (e.g., `10.2M` instead of `10184k`). Context display shortened from `61.3% of 200k` to `61.3%/200k`. + ## [0.12.11] - 2025-12-05 ### Changed From c550ed2bcab8db29fd70e2096a390cf80d69cd91 Mon Sep 17 00:00:00 2001 From: Nico Bailon Date: Fri, 5 Dec 2025 07:57:42 -0800 Subject: [PATCH 23/54] feat(tui): add prompt history navigation with Up/Down arrows Browse previously submitted prompts using Up/Down arrow keys, similar to shell history and Claude Code's prompt history feature. - Up arrow when editor is empty: browse to older prompts - Down arrow when browsing: return to newer prompts or clear editor - Cursor movement within multi-line history entries supported - History is session-scoped, stores up to 100 entries - Consecutive duplicates are not added to history Includes 15 new tests for history navigation behavior. --- packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/README.md | 2 + packages/coding-agent/src/tui/tui-renderer.ts | 6 + packages/tui/src/components/editor.ts | 115 +++++++-- packages/tui/test/editor.test.ts | 241 ++++++++++++++++++ 5 files changed, 350 insertions(+), 18 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index cbb917f7..83997d2a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- **Prompt History Navigation**: Browse previously submitted prompts using Up/Down arrow keys when the editor is empty. Press Up to cycle through older prompts, Down to return to newer ones or clear the editor. Similar to shell history and Claude Code's prompt history feature. History is session-scoped and stores up to 100 entries. + ## [0.12.10] - 2025-12-04 ### Added diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index cd12e872..e7971510 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -661,6 +661,8 @@ Change queue mode with `/queue` command. Setting is saved in `~/.pi/agent/settin **Navigation:** - **Arrow keys**: Move cursor (Up/Down navigate visual lines, Left/Right move by character) +- **Up Arrow** (empty editor): Browse previous prompts (history) +- **Down Arrow** (browsing history): Browse newer prompts or return to empty editor - **Option+Left** / **Ctrl+Left**: Move word backwards - **Option+Right** / **Ctrl+Right**: Move word forwards - **Ctrl+A** / **Home**: Jump to start of line diff --git a/packages/coding-agent/src/tui/tui-renderer.ts b/packages/coding-agent/src/tui/tui-renderer.ts index 5cba73e7..4546816d 100644 --- a/packages/coding-agent/src/tui/tui-renderer.ts +++ b/packages/coding-agent/src/tui/tui-renderer.ts @@ -516,6 +516,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(); @@ -526,6 +529,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 diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index ba0bc1d9..0bf06d41 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -48,6 +48,10 @@ export class Editor implements Component { private pasteBuffer: string = ""; private isInPaste: boolean = false; + // Prompt history for up/down navigation + private history: string[] = []; + private historyIndex: number = -1; // -1 = not browsing, 0 = most recent, 1 = older, etc. + public onSubmit?: (text: string) => void; public onChange?: (text: string) => void; public disableSubmit: boolean = false; @@ -61,6 +65,66 @@ export class Editor implements Component { this.autocompleteProvider = provider; } + /** + * Add a prompt to history for up/down arrow navigation. + * Called after successful submission. + */ + addToHistory(text: string): void { + const trimmed = text.trim(); + if (!trimmed) return; + // Don't add consecutive duplicates + if (this.history.length > 0 && this.history[0] === trimmed) return; + this.history.unshift(trimmed); + // Limit history size + if (this.history.length > 100) { + this.history.pop(); + } + } + + private isEditorEmpty(): boolean { + return this.state.lines.length === 1 && this.state.lines[0] === ""; + } + + private isOnFirstVisualLine(): boolean { + const visualLines = this.buildVisualLineMap(this.lastWidth); + const currentVisualLine = this.findCurrentVisualLine(visualLines); + return currentVisualLine === 0; + } + + private isOnLastVisualLine(): boolean { + const visualLines = this.buildVisualLineMap(this.lastWidth); + const currentVisualLine = this.findCurrentVisualLine(visualLines); + return currentVisualLine === visualLines.length - 1; + } + + private navigateHistory(direction: 1 | -1): void { + if (this.history.length === 0) return; + + const newIndex = this.historyIndex - direction; // Up(-1) increases index, Down(1) decreases + if (newIndex < -1 || newIndex >= this.history.length) return; + + this.historyIndex = newIndex; + + if (this.historyIndex === -1) { + // Returned to "current" state - clear editor + this.setTextInternal(""); + } else { + this.setTextInternal(this.history[this.historyIndex] || ""); + } + } + + /** Internal setText that doesn't reset history state - used by navigateHistory */ + private setTextInternal(text: string): void { + const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); + this.state.lines = lines.length === 0 ? [""] : lines; + this.state.cursorLine = this.state.lines.length - 1; + this.state.cursorCol = this.state.lines[this.state.cursorLine]?.length || 0; + + if (this.onChange) { + this.onChange(this.getText()); + } + } + invalidate(): void { // No cached state to invalidate currently } @@ -342,6 +406,7 @@ export class Editor implements Component { }; this.pastes.clear(); this.pasteCounter = 0; + this.historyIndex = -1; // Exit history browsing mode // Notify that editor is now empty if (this.onChange) { @@ -383,11 +448,21 @@ export class Editor implements Component { } // Arrow keys else if (data === "\x1b[A") { - // Up - this.moveCursor(-1, 0); + // Up - history navigation or cursor movement + if (this.isEditorEmpty()) { + this.navigateHistory(-1); // Start browsing history + } else if (this.historyIndex > -1 && this.isOnFirstVisualLine()) { + this.navigateHistory(-1); // Navigate to older history entry + } else { + this.moveCursor(-1, 0); // Cursor movement (within text or history entry) + } } else if (data === "\x1b[B") { - // Down - this.moveCursor(1, 0); + // Down - history navigation or cursor movement + if (this.historyIndex > -1 && this.isOnLastVisualLine()) { + this.navigateHistory(1); // Navigate to newer history entry or clear + } else { + this.moveCursor(1, 0); // Cursor movement (within text or history entry) + } } else if (data === "\x1b[C") { // Right this.moveCursor(0, 1); @@ -479,24 +554,14 @@ export class Editor implements Component { } setText(text: string): void { - // Split text into lines, handling different line endings - const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); - - // Ensure at least one empty line - this.state.lines = lines.length === 0 ? [""] : lines; - - // Reset cursor to end of text - this.state.cursorLine = this.state.lines.length - 1; - this.state.cursorCol = this.state.lines[this.state.cursorLine]?.length || 0; - - // Notify of change - if (this.onChange) { - this.onChange(this.getText()); - } + this.historyIndex = -1; // Exit history browsing mode + this.setTextInternal(text); } // All the editor methods from before... private insertCharacter(char: string): void { + this.historyIndex = -1; // Exit history browsing mode + const line = this.state.lines[this.state.cursorLine] || ""; const before = line.slice(0, this.state.cursorCol); @@ -544,6 +609,8 @@ export class Editor implements Component { } private handlePaste(pastedText: string): void { + this.historyIndex = -1; // Exit history browsing mode + // Clean the pasted text const cleanText = pastedText.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); @@ -632,6 +699,8 @@ export class Editor implements Component { } private addNewLine(): void { + this.historyIndex = -1; // Exit history browsing mode + const currentLine = this.state.lines[this.state.cursorLine] || ""; const before = currentLine.slice(0, this.state.cursorCol); @@ -651,6 +720,8 @@ export class Editor implements Component { } private handleBackspace(): void { + this.historyIndex = -1; // Exit history browsing mode + if (this.state.cursorCol > 0) { // Delete character in current line const line = this.state.lines[this.state.cursorLine] || ""; @@ -704,6 +775,8 @@ export class Editor implements Component { } private deleteToStartOfLine(): void { + this.historyIndex = -1; // Exit history browsing mode + const currentLine = this.state.lines[this.state.cursorLine] || ""; if (this.state.cursorCol > 0) { @@ -725,6 +798,8 @@ export class Editor implements Component { } private deleteToEndOfLine(): void { + this.historyIndex = -1; // Exit history browsing mode + const currentLine = this.state.lines[this.state.cursorLine] || ""; if (this.state.cursorCol < currentLine.length) { @@ -743,6 +818,8 @@ export class Editor implements Component { } private deleteWordBackwards(): void { + this.historyIndex = -1; // Exit history browsing mode + const currentLine = this.state.lines[this.state.cursorLine] || ""; // If at start of line, behave like backspace at column 0 (merge with previous line) @@ -791,6 +868,8 @@ export class Editor implements Component { } private handleForwardDelete(): void { + this.historyIndex = -1; // Exit history browsing mode + const currentLine = this.state.lines[this.state.cursorLine] || ""; if (this.state.cursorCol < currentLine.length) { diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index 9928568f..2df17051 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -4,6 +4,247 @@ import { Editor } from "../src/components/editor.js"; import { defaultEditorTheme } from "./test-themes.js"; describe("Editor component", () => { + describe("Prompt history navigation", () => { + it("does nothing on Up arrow when history is empty", () => { + const editor = new Editor(defaultEditorTheme); + + editor.handleInput("\x1b[A"); // Up arrow + + assert.strictEqual(editor.getText(), ""); + }); + + it("shows most recent history entry on Up arrow when editor is empty", () => { + const editor = new Editor(defaultEditorTheme); + + editor.addToHistory("first prompt"); + editor.addToHistory("second prompt"); + + editor.handleInput("\x1b[A"); // Up arrow + + assert.strictEqual(editor.getText(), "second prompt"); + }); + + it("cycles through history entries on repeated Up arrow", () => { + const editor = new Editor(defaultEditorTheme); + + editor.addToHistory("first"); + editor.addToHistory("second"); + editor.addToHistory("third"); + + editor.handleInput("\x1b[A"); // Up - shows "third" + assert.strictEqual(editor.getText(), "third"); + + editor.handleInput("\x1b[A"); // Up - shows "second" + assert.strictEqual(editor.getText(), "second"); + + editor.handleInput("\x1b[A"); // Up - shows "first" + assert.strictEqual(editor.getText(), "first"); + + editor.handleInput("\x1b[A"); // Up - stays at "first" (oldest) + assert.strictEqual(editor.getText(), "first"); + }); + + it("returns to empty editor on Down arrow after browsing history", () => { + const editor = new Editor(defaultEditorTheme); + + editor.addToHistory("prompt"); + + editor.handleInput("\x1b[A"); // Up - shows "prompt" + assert.strictEqual(editor.getText(), "prompt"); + + editor.handleInput("\x1b[B"); // Down - clears editor + assert.strictEqual(editor.getText(), ""); + }); + + it("navigates forward through history with Down arrow", () => { + const editor = new Editor(defaultEditorTheme); + + editor.addToHistory("first"); + editor.addToHistory("second"); + editor.addToHistory("third"); + + // Go to oldest + editor.handleInput("\x1b[A"); // third + editor.handleInput("\x1b[A"); // second + editor.handleInput("\x1b[A"); // first + + // Navigate back + editor.handleInput("\x1b[B"); // second + assert.strictEqual(editor.getText(), "second"); + + editor.handleInput("\x1b[B"); // third + assert.strictEqual(editor.getText(), "third"); + + editor.handleInput("\x1b[B"); // empty + assert.strictEqual(editor.getText(), ""); + }); + + it("exits history mode when typing a character", () => { + const editor = new Editor(defaultEditorTheme); + + editor.addToHistory("old prompt"); + + editor.handleInput("\x1b[A"); // Up - shows "old prompt" + editor.handleInput("x"); // Type a character - exits history mode + + assert.strictEqual(editor.getText(), "old promptx"); + }); + + it("exits history mode on setText", () => { + const editor = new Editor(defaultEditorTheme); + + editor.addToHistory("first"); + editor.addToHistory("second"); + + editor.handleInput("\x1b[A"); // Up - shows "second" + editor.setText(""); // External clear + + // Up should start fresh from most recent + editor.handleInput("\x1b[A"); + assert.strictEqual(editor.getText(), "second"); + }); + + it("does not add empty strings to history", () => { + const editor = new Editor(defaultEditorTheme); + + editor.addToHistory(""); + editor.addToHistory(" "); + editor.addToHistory("valid"); + + editor.handleInput("\x1b[A"); + assert.strictEqual(editor.getText(), "valid"); + + // Should not have more entries + editor.handleInput("\x1b[A"); + assert.strictEqual(editor.getText(), "valid"); + }); + + it("does not add consecutive duplicates to history", () => { + const editor = new Editor(defaultEditorTheme); + + editor.addToHistory("same"); + editor.addToHistory("same"); + editor.addToHistory("same"); + + editor.handleInput("\x1b[A"); // "same" + assert.strictEqual(editor.getText(), "same"); + + editor.handleInput("\x1b[A"); // stays at "same" (only one entry) + assert.strictEqual(editor.getText(), "same"); + }); + + it("allows non-consecutive duplicates in history", () => { + const editor = new Editor(defaultEditorTheme); + + editor.addToHistory("first"); + editor.addToHistory("second"); + editor.addToHistory("first"); // Not consecutive, should be added + + editor.handleInput("\x1b[A"); // "first" + assert.strictEqual(editor.getText(), "first"); + + editor.handleInput("\x1b[A"); // "second" + assert.strictEqual(editor.getText(), "second"); + + editor.handleInput("\x1b[A"); // "first" (older one) + assert.strictEqual(editor.getText(), "first"); + }); + + it("uses cursor movement instead of history when editor has content", () => { + const editor = new Editor(defaultEditorTheme); + + editor.addToHistory("history item"); + editor.setText("line1\nline2"); + + // Cursor is at end of line2, Up should move to line1 + editor.handleInput("\x1b[A"); // Up - cursor movement + + // Insert character to verify cursor position + editor.handleInput("X"); + + // X should be inserted in line1, not replace with history + assert.strictEqual(editor.getText(), "line1X\nline2"); + }); + + it("limits history to 100 entries", () => { + const editor = new Editor(defaultEditorTheme); + + // Add 105 entries + for (let i = 0; i < 105; i++) { + editor.addToHistory(`prompt ${i}`); + } + + // Navigate to oldest + for (let i = 0; i < 100; i++) { + editor.handleInput("\x1b[A"); + } + + // Should be at entry 5 (oldest kept), not entry 0 + assert.strictEqual(editor.getText(), "prompt 5"); + + // One more Up should not change anything + editor.handleInput("\x1b[A"); + assert.strictEqual(editor.getText(), "prompt 5"); + }); + + it("allows cursor movement within multi-line history entry with Down", () => { + const editor = new Editor(defaultEditorTheme); + + editor.addToHistory("line1\nline2\nline3"); + + // Browse to the multi-line entry + editor.handleInput("\x1b[A"); // Up - shows entry, cursor at end of line3 + assert.strictEqual(editor.getText(), "line1\nline2\nline3"); + + // Down should exit history since cursor is on last line + editor.handleInput("\x1b[B"); // Down + assert.strictEqual(editor.getText(), ""); // Exited to empty + }); + + it("allows cursor movement within multi-line history entry with Up", () => { + const editor = new Editor(defaultEditorTheme); + + editor.addToHistory("older entry"); + editor.addToHistory("line1\nline2\nline3"); + + // Browse to the multi-line entry + editor.handleInput("\x1b[A"); // Up - shows multi-line, cursor at end of line3 + + // Up should move cursor within the entry (not on first line yet) + editor.handleInput("\x1b[A"); // Up - cursor moves to line2 + assert.strictEqual(editor.getText(), "line1\nline2\nline3"); // Still same entry + + editor.handleInput("\x1b[A"); // Up - cursor moves to line1 (now on first visual line) + assert.strictEqual(editor.getText(), "line1\nline2\nline3"); // Still same entry + + // Now Up should navigate to older history entry + editor.handleInput("\x1b[A"); // Up - navigate to older + assert.strictEqual(editor.getText(), "older entry"); + }); + + it("navigates from multi-line entry back to newer via Down after cursor movement", () => { + const editor = new Editor(defaultEditorTheme); + + editor.addToHistory("line1\nline2\nline3"); + + // Browse to entry and move cursor up + editor.handleInput("\x1b[A"); // Up - shows entry, cursor at end + editor.handleInput("\x1b[A"); // Up - cursor to line2 + editor.handleInput("\x1b[A"); // Up - cursor to line1 + + // Now Down should move cursor down within the entry + editor.handleInput("\x1b[B"); // Down - cursor to line2 + assert.strictEqual(editor.getText(), "line1\nline2\nline3"); + + editor.handleInput("\x1b[B"); // Down - cursor to line3 + assert.strictEqual(editor.getText(), "line1\nline2\nline3"); + + // Now on last line, Down should exit history + editor.handleInput("\x1b[B"); // Down - exit to empty + assert.strictEqual(editor.getText(), ""); + }); + }); + describe("Unicode text editing behavior", () => { it("inserts mixed ASCII, umlauts, and emojis as literal text", () => { const editor = new Editor(defaultEditorTheme); From ff047e5ee11db1a2303559256d007b7210dc58a1 Mon Sep 17 00:00:00 2001 From: Markus Ylisiurunen <8409947+markusylisiurunen@users.noreply.github.com> Date: Fri, 5 Dec 2025 21:33:04 +0200 Subject: [PATCH 24/54] Implement fuzzy search for model/session selector and improve `Input` multi-key sequence handling (#122) * implement fuzzy search and filtering for tui selectors * update changelog and readme * add correct pr to changelog --- packages/coding-agent/CHANGELOG.md | 8 ++ packages/coding-agent/src/fuzzy.test.ts | 92 +++++++++++++++++++ packages/coding-agent/src/fuzzy.ts | 83 +++++++++++++++++ .../coding-agent/src/tui/model-selector.ts | 15 +-- .../coding-agent/src/tui/session-selector.ts | 16 +--- packages/tui/README.md | 8 ++ packages/tui/src/components/input.ts | 56 +++++++++++ 7 files changed, 251 insertions(+), 27 deletions(-) create mode 100644 packages/coding-agent/src/fuzzy.test.ts create mode 100644 packages/coding-agent/src/fuzzy.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ee6be520..c7af84bd 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -6,6 +6,14 @@ - **Footer display**: Token counts now use M suffix for millions (e.g., `10.2M` instead of `10184k`). Context display shortened from `61.3% of 200k` to `61.3%/200k`. +### Fixed + +- **Multi-key sequences in inputs**: Inputs like model search now handle multi-key sequences identically to the main prompt editor. ([#122](https://github.com/badlogic/pi-mono/pull/122) by [@markusylisiurunen](https://github.com/markusylisiurunen)) + +### Added + +- **Fuzzy search models and sessions**: Implemented a simple fuzzy search for models and sessions (e.g., `codexmax` now finds `gpt-5.1-codex-max`). ([#122](https://github.com/badlogic/pi-mono/pull/122) by [@markusylisiurunen](https://github.com/markusylisiurunen)) + ## [0.12.11] - 2025-12-05 ### Changed diff --git a/packages/coding-agent/src/fuzzy.test.ts b/packages/coding-agent/src/fuzzy.test.ts new file mode 100644 index 00000000..7975bf1c --- /dev/null +++ b/packages/coding-agent/src/fuzzy.test.ts @@ -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"); + }); +}); diff --git a/packages/coding-agent/src/fuzzy.ts b/packages/coding-agent/src/fuzzy.ts new file mode 100644 index 00000000..837c8bb2 --- /dev/null +++ b/packages/coding-agent/src/fuzzy.ts @@ -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(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); +} diff --git a/packages/coding-agent/src/tui/model-selector.ts b/packages/coding-agent/src/tui/model-selector.ts index d84aeb79..c343dfbb 100644 --- a/packages/coding-agent/src/tui/model-selector.ts +++ b/packages/coding-agent/src/tui/model-selector.ts @@ -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(); } diff --git a/packages/coding-agent/src/tui/session-selector.ts b/packages/coding-agent/src/tui/session-selector.ts index b96c114d..fbb47568 100644 --- a/packages/coding-agent/src/tui/session-selector.ts +++ b/packages/coding-agent/src/tui/session-selector.ts @@ -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)); } diff --git a/packages/tui/README.md b/packages/tui/README.md index ef02d244..0ed8f6d3 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -93,6 +93,14 @@ input.onSubmit = (value) => console.log(value); input.setValue("initial"); ``` +**Key Bindings:** +- `Enter` - Submit +- `Ctrl+A` / `Ctrl+E` - Line start/end +- `Ctrl+W` or `Option+Backspace` - Delete word backwards +- `Ctrl+U` - Delete to start of line +- `Ctrl+K` - Delete to end of line +- Arrow keys, Backspace, Delete work as expected + ### Editor Multi-line text editor with autocomplete, file completion, and paste handling. diff --git a/packages/tui/src/components/input.ts b/packages/tui/src/components/input.ts index f4f11f6a..dbf8c801 100644 --- a/packages/tui/src/components/input.ts +++ b/packages/tui/src/components/input.ts @@ -113,6 +113,31 @@ export class Input implements Component { return; } + if (data.charCodeAt(0) === 23) { + // Ctrl+W - delete word backwards + this.deleteWordBackwards(); + return; + } + + if (data === "\x1b\x7f") { + // Option/Alt+Backspace - delete word backwards + this.deleteWordBackwards(); + return; + } + + if (data.charCodeAt(0) === 21) { + // Ctrl+U - delete from cursor to start of line + this.value = this.value.slice(this.cursor); + this.cursor = 0; + return; + } + + if (data.charCodeAt(0) === 11) { + // Ctrl+K - delete from cursor to end of line + this.value = this.value.slice(0, this.cursor); + return; + } + // Regular character input if (data.length === 1 && data >= " " && data <= "~") { this.value = this.value.slice(0, this.cursor) + data + this.value.slice(this.cursor); @@ -120,6 +145,37 @@ export class Input implements Component { } } + private deleteWordBackwards(): void { + if (this.cursor === 0) { + return; + } + + const text = this.value.slice(0, this.cursor); + let deleteFrom = this.cursor; + + const isWhitespace = (char: string): boolean => /\s/.test(char); + const isPunctuation = (char: string): boolean => /[(){}[\]<>.,;:'"!?+\-=*/\\|&%^$#@~`]/.test(char); + + const charBeforeCursor = text[deleteFrom - 1] ?? ""; + + // If immediately on whitespace or punctuation, delete that single boundary char + if (isWhitespace(charBeforeCursor) || isPunctuation(charBeforeCursor)) { + deleteFrom -= 1; + } else { + // Otherwise, delete a run of non-boundary characters (the "word") + while (deleteFrom > 0) { + const ch = text[deleteFrom - 1] ?? ""; + if (isWhitespace(ch) || isPunctuation(ch)) { + break; + } + deleteFrom -= 1; + } + } + + this.value = text.slice(0, deleteFrom) + this.value.slice(this.cursor); + this.cursor = deleteFrom; + } + private handlePaste(pastedText: string): void { // Clean the pasted text - remove newlines and carriage returns const cleanText = pastedText.replace(/\r\n/g, "").replace(/\r/g, "").replace(/\n/g, ""); From 6ec1391ebba100adfadde2246648e6e175058523 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 5 Dec 2025 21:49:44 +0100 Subject: [PATCH 25/54] fix: escape codes not properly wrapped during line wrapping - ANSI codes now attach to next visible content, not trailing whitespace - Rewrote AnsiCodeTracker to track individual attributes - Line-end resets only turn off underline, preserving background colors - Added vitest config to exclude node:test files fixes #109 --- packages/coding-agent/CHANGELOG.md | 1 + packages/tui/src/utils.ts | 226 ++++++++++++++++++++++++++-- packages/tui/test/wrap-ansi.test.ts | 206 +++++++++++++------------ packages/tui/vitest.config.ts | 7 + 4 files changed, 332 insertions(+), 108 deletions(-) create mode 100644 packages/tui/vitest.config.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index c7af84bd..29c1e21f 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixed - **Multi-key sequences in inputs**: Inputs like model search now handle multi-key sequences identically to the main prompt editor. ([#122](https://github.com/badlogic/pi-mono/pull/122) by [@markusylisiurunen](https://github.com/markusylisiurunen)) +- **Line wrapping escape codes**: Fixed underline style bleeding into padding when wrapping long URLs. ANSI codes now attach to the correct content, and line-end resets only turn off underline (preserving background colors). ([#109](https://github.com/badlogic/pi-mono/issues/109)) ### Added diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index 718ac99a..a724cf7f 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -35,27 +35,199 @@ function extractAnsiCode(str: string, pos: number): { code: string; length: numb * Track active ANSI SGR codes to preserve styling across line breaks. */ class AnsiCodeTracker { - private activeAnsiCodes: string[] = []; + // Track individual attributes separately so we can reset them specifically + private bold = false; + private dim = false; + private italic = false; + private underline = false; + private blink = false; + private inverse = false; + private hidden = false; + private strikethrough = false; + private fgColor: string | null = null; // Stores the full code like "31" or "38;5;240" + private bgColor: string | null = null; // Stores the full code like "41" or "48;5;240" process(ansiCode: string): void { if (!ansiCode.endsWith("m")) { return; } - // Full reset clears everything - if (ansiCode === "\x1b[0m" || ansiCode === "\x1b[m") { - this.activeAnsiCodes.length = 0; - } else { - this.activeAnsiCodes.push(ansiCode); + // Extract the parameters between \x1b[ and m + const match = ansiCode.match(/\x1b\[([\d;]*)m/); + if (!match) return; + + const params = match[1]; + if (params === "" || params === "0") { + // Full reset + this.reset(); + return; + } + + // Parse parameters (can be semicolon-separated) + const parts = params.split(";"); + let i = 0; + while (i < parts.length) { + const code = Number.parseInt(parts[i], 10); + + // Handle 256-color and RGB codes which consume multiple parameters + if (code === 38 || code === 48) { + // 38;5;N (256 color fg) or 38;2;R;G;B (RGB fg) + // 48;5;N (256 color bg) or 48;2;R;G;B (RGB bg) + if (parts[i + 1] === "5" && parts[i + 2] !== undefined) { + // 256 color: 38;5;N or 48;5;N + const colorCode = `${parts[i]};${parts[i + 1]};${parts[i + 2]}`; + if (code === 38) { + this.fgColor = colorCode; + } else { + this.bgColor = colorCode; + } + i += 3; + continue; + } else if (parts[i + 1] === "2" && parts[i + 4] !== undefined) { + // RGB color: 38;2;R;G;B or 48;2;R;G;B + const colorCode = `${parts[i]};${parts[i + 1]};${parts[i + 2]};${parts[i + 3]};${parts[i + 4]}`; + if (code === 38) { + this.fgColor = colorCode; + } else { + this.bgColor = colorCode; + } + i += 5; + continue; + } + } + + // Standard SGR codes + switch (code) { + case 0: + this.reset(); + break; + case 1: + this.bold = true; + break; + case 2: + this.dim = true; + break; + case 3: + this.italic = true; + break; + case 4: + this.underline = true; + break; + case 5: + this.blink = true; + break; + case 7: + this.inverse = true; + break; + case 8: + this.hidden = true; + break; + case 9: + this.strikethrough = true; + break; + case 21: + this.bold = false; + break; // Some terminals + case 22: + this.bold = false; + this.dim = false; + break; + case 23: + this.italic = false; + break; + case 24: + this.underline = false; + break; + case 25: + this.blink = false; + break; + case 27: + this.inverse = false; + break; + case 28: + this.hidden = false; + break; + case 29: + this.strikethrough = false; + break; + case 39: + this.fgColor = null; + break; // Default fg + case 49: + this.bgColor = null; + break; // Default bg + default: + // Standard foreground colors 30-37, 90-97 + if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) { + this.fgColor = String(code); + } + // Standard background colors 40-47, 100-107 + else if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107)) { + this.bgColor = String(code); + } + break; + } + i++; } } + private reset(): void { + this.bold = false; + this.dim = false; + this.italic = false; + this.underline = false; + this.blink = false; + this.inverse = false; + this.hidden = false; + this.strikethrough = false; + this.fgColor = null; + this.bgColor = null; + } + getActiveCodes(): string { - return this.activeAnsiCodes.join(""); + const codes: string[] = []; + if (this.bold) codes.push("1"); + if (this.dim) codes.push("2"); + if (this.italic) codes.push("3"); + if (this.underline) codes.push("4"); + if (this.blink) codes.push("5"); + if (this.inverse) codes.push("7"); + if (this.hidden) codes.push("8"); + if (this.strikethrough) codes.push("9"); + if (this.fgColor) codes.push(this.fgColor); + if (this.bgColor) codes.push(this.bgColor); + + if (codes.length === 0) return ""; + return `\x1b[${codes.join(";")}m`; } hasActiveCodes(): boolean { - return this.activeAnsiCodes.length > 0; + return ( + this.bold || + this.dim || + this.italic || + this.underline || + this.blink || + this.inverse || + this.hidden || + this.strikethrough || + this.fgColor !== null || + this.bgColor !== null + ); + } + + /** + * Get reset codes for attributes that need to be turned off at line end, + * specifically underline which bleeds into padding. + * Returns empty string if no problematic attributes are active. + */ + getLineEndReset(): string { + // Only underline causes visual bleeding into padding + // Other attributes like colors don't visually bleed to padding + if (this.underline) { + return "\x1b[24m"; // Underline off only + } + return ""; } } @@ -78,13 +250,15 @@ function updateTrackerFromText(text: string, tracker: AnsiCodeTracker): void { function splitIntoTokensWithAnsi(text: string): string[] { const tokens: string[] = []; let current = ""; + let pendingAnsi = ""; // ANSI codes waiting to be attached to next visible content let inWhitespace = false; let i = 0; while (i < text.length) { const ansiResult = extractAnsiCode(text, i); if (ansiResult) { - current += ansiResult.code; + // Hold ANSI codes separately - they'll be attached to the next visible char + pendingAnsi += ansiResult.code; i += ansiResult.length; continue; } @@ -98,11 +272,22 @@ function splitIntoTokensWithAnsi(text: string): string[] { current = ""; } + // Attach any pending ANSI codes to this visible character + if (pendingAnsi) { + current += pendingAnsi; + pendingAnsi = ""; + } + inWhitespace = charIsSpace; current += char; i++; } + // Handle any remaining pending ANSI codes (attach to last token) + if (pendingAnsi) { + current += pendingAnsi; + } + if (current) { tokens.push(current); } @@ -161,12 +346,17 @@ function wrapSingleLine(line: string, width: number): string[] { // Token itself is too long - break it character by character if (tokenVisibleLength > width && !isWhitespace) { if (currentLine) { + // Add specific reset for underline only (preserves background) + const lineEndReset = tracker.getLineEndReset(); + if (lineEndReset) { + currentLine += lineEndReset; + } wrapped.push(currentLine); currentLine = ""; currentVisibleLength = 0; } - // Break long token + // Break long token - breakLongWord handles its own resets const broken = breakLongWord(token, width, tracker); wrapped.push(...broken.slice(0, -1)); currentLine = broken[broken.length - 1]; @@ -178,8 +368,13 @@ function wrapSingleLine(line: string, width: number): string[] { const totalNeeded = currentVisibleLength + tokenVisibleLength; if (totalNeeded > width && currentVisibleLength > 0) { - // Wrap to next line - don't carry trailing whitespace - wrapped.push(currentLine.trimEnd()); + // Add specific reset for underline only (preserves background) + let lineToWrap = currentLine.trimEnd(); + const lineEndReset = tracker.getLineEndReset(); + if (lineEndReset) { + lineToWrap += lineEndReset; + } + wrapped.push(lineToWrap); if (isWhitespace) { // Don't start new line with whitespace currentLine = tracker.getActiveCodes(); @@ -198,6 +393,7 @@ function wrapSingleLine(line: string, width: number): string[] { } if (currentLine) { + // No reset at end of final line - let caller handle it wrapped.push(currentLine); } @@ -251,6 +447,11 @@ function breakLongWord(word: string, width: number, tracker: AnsiCodeTracker): s const graphemeWidth = visibleWidth(grapheme); if (currentWidth + graphemeWidth > width) { + // Add specific reset for underline only (preserves background) + const lineEndReset = tracker.getLineEndReset(); + if (lineEndReset) { + currentLine += lineEndReset; + } lines.push(currentLine); currentLine = tracker.getActiveCodes(); currentWidth = 0; @@ -261,6 +462,7 @@ function breakLongWord(word: string, width: number, tracker: AnsiCodeTracker): s } if (currentLine) { + // No reset at end of final segment - caller handles continuation lines.push(currentLine); } diff --git a/packages/tui/test/wrap-ansi.test.ts b/packages/tui/test/wrap-ansi.test.ts index 7e5af229..03b933ae 100644 --- a/packages/tui/test/wrap-ansi.test.ts +++ b/packages/tui/test/wrap-ansi.test.ts @@ -1,112 +1,126 @@ -import assert from "node:assert"; -import { describe, it } from "node:test"; -import { Chalk } from "chalk"; - -// We'll implement these -import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "../src/utils.js"; - -const chalk = new Chalk({ level: 3 }); +import { describe, expect, it } from "vitest"; +import { visibleWidth, wrapTextWithAnsi } from "../src/utils.js"; describe("wrapTextWithAnsi", () => { - it("wraps plain text at word boundaries", () => { - const text = "hello world this is a test"; - const lines = wrapTextWithAnsi(text, 15); + describe("underline styling", () => { + it("should not apply underline style before the styled text", () => { + const underlineOn = "\x1b[4m"; + const underlineOff = "\x1b[24m"; + const url = "https://example.com/very/long/path/that/will/wrap"; + const text = `read this thread ${underlineOn}${url}${underlineOff}`; - assert.strictEqual(lines.length, 2); - assert.strictEqual(lines[0], "hello world"); - assert.strictEqual(lines[1], "this is a test"); + const wrapped = wrapTextWithAnsi(text, 40); + + // First line should NOT contain underline code - it's just "read this thread " + expect(wrapped[0]).toBe("read this thread "); + + // Second line should start with underline, have URL content + expect(wrapped[1].startsWith(underlineOn)).toBe(true); + expect(wrapped[1]).toContain("https://"); + }); + + it("should not bleed underline to padding - each line should end with reset for underline only", () => { + const underlineOn = "\x1b[4m"; + const underlineOff = "\x1b[24m"; + const url = "https://example.com/very/long/path/that/will/definitely/wrap"; + const text = `prefix ${underlineOn}${url}${underlineOff} suffix`; + + const wrapped = wrapTextWithAnsi(text, 30); + + // Middle lines (with underlined content) should end with underline-off, not full reset + // Line 1 and 2 contain underlined URL parts + for (let i = 1; i < wrapped.length - 1; i++) { + const line = wrapped[i]; + if (line.includes(underlineOn)) { + // Should end with underline off, NOT full reset + expect(line.endsWith(underlineOff)).toBe(true); + expect(line.endsWith("\x1b[0m")).toBe(false); + } + } + }); }); - it("preserves ANSI codes across wrapped lines", () => { - const text = chalk.bold("hello world this is bold text"); - const lines = wrapTextWithAnsi(text, 20); + describe("background color preservation", () => { + it("should preserve background color across wrapped lines without full reset", () => { + const bgBlue = "\x1b[44m"; + const reset = "\x1b[0m"; + const text = `${bgBlue}hello world this is blue background text${reset}`; - // Should have bold code at start of each line - assert.ok(lines[0].includes("\x1b[1m")); - assert.ok(lines[1].includes("\x1b[1m")); + const wrapped = wrapTextWithAnsi(text, 15); - // Each line should be <= 20 visible chars - assert.ok(visibleWidth(lines[0]) <= 20); - assert.ok(visibleWidth(lines[1]) <= 20); + // Each line should have background color + for (const line of wrapped) { + expect(line.includes(bgBlue)).toBe(true); + } + + // Middle lines should NOT end with full reset (kills background for padding) + for (let i = 0; i < wrapped.length - 1; i++) { + expect(wrapped[i].endsWith("\x1b[0m")).toBe(false); + } + }); + + it("should reset underline but preserve background when wrapping underlined text inside background", () => { + const underlineOn = "\x1b[4m"; + const underlineOff = "\x1b[24m"; + const reset = "\x1b[0m"; + + const text = `\x1b[41mprefix ${underlineOn}UNDERLINED_CONTENT_THAT_WRAPS${underlineOff} suffix${reset}`; + + const wrapped = wrapTextWithAnsi(text, 20); + + console.log("Wrapped lines:"); + for (let i = 0; i < wrapped.length; i++) { + console.log(` [${i}]: ${JSON.stringify(wrapped[i])}`); + } + + // All lines should have background color 41 (either as \x1b[41m or combined like \x1b[4;41m) + for (const line of wrapped) { + const hasBgColor = line.includes("[41m") || line.includes(";41m") || line.includes("[41;"); + expect(hasBgColor).toBe(true); + } + + // Lines with underlined content should use underline-off at end, not full reset + for (let i = 0; i < wrapped.length - 1; i++) { + const line = wrapped[i]; + // If this line has underline on, it should end with underline off (not full reset) + if ( + (line.includes("[4m") || line.includes("[4;") || line.includes(";4m")) && + !line.includes(underlineOff) + ) { + expect(line.endsWith(underlineOff)).toBe(true); + expect(line.endsWith("\x1b[0m")).toBe(false); + } + } + }); }); - it("handles text with resets", () => { - const text = chalk.bold("bold ") + "normal " + chalk.cyan("cyan"); - const lines = wrapTextWithAnsi(text, 30); + describe("basic wrapping", () => { + it("should wrap plain text correctly", () => { + const text = "hello world this is a test"; + const wrapped = wrapTextWithAnsi(text, 10); - assert.strictEqual(lines.length, 1); - // Should contain the reset code from chalk - assert.ok(lines[0].includes("\x1b[")); - }); + expect(wrapped.length).toBeGreaterThan(1); + wrapped.forEach((line) => { + expect(visibleWidth(line)).toBeLessThanOrEqual(10); + }); + }); - it("does NOT pad lines", () => { - const text = "hello"; - const lines = wrapTextWithAnsi(text, 20); + it("should preserve color codes across wraps", () => { + const red = "\x1b[31m"; + const reset = "\x1b[0m"; + const text = `${red}hello world this is red${reset}`; - assert.strictEqual(lines.length, 1); - assert.strictEqual(visibleWidth(lines[0]), 5); // NOT 20 - }); + const wrapped = wrapTextWithAnsi(text, 10); - it("handles empty text", () => { - const lines = wrapTextWithAnsi("", 20); - assert.strictEqual(lines.length, 1); - assert.strictEqual(lines[0], ""); - }); + // Each continuation line should start with red code + for (let i = 1; i < wrapped.length; i++) { + expect(wrapped[i].startsWith(red)).toBe(true); + } - it("handles newlines", () => { - const text = "line1\nline2\nline3"; - const lines = wrapTextWithAnsi(text, 20); - - assert.strictEqual(lines.length, 3); - assert.strictEqual(lines[0], "line1"); - assert.strictEqual(lines[1], "line2"); - assert.strictEqual(lines[2], "line3"); - }); -}); - -describe("applyBackgroundToLine", () => { - const greenBg = (text: string) => chalk.bgGreen(text); - - it("applies background to plain text and pads to width", () => { - const line = "hello"; - const result = applyBackgroundToLine(line, 20, greenBg); - - // Should be exactly 20 visible chars - const stripped = result.replace(/\x1b\[[0-9;]*m/g, ""); - assert.strictEqual(stripped.length, 20); - - // Should have background codes - assert.ok(result.includes("\x1b[48") || result.includes("\x1b[42m")); - assert.ok(result.includes("\x1b[49m")); - }); - - it("handles text with ANSI codes and resets", () => { - const line = chalk.bold("hello") + " world"; - const result = applyBackgroundToLine(line, 20, greenBg); - - // Should be exactly 20 visible chars - const stripped = result.replace(/\x1b\[[0-9;]*m/g, ""); - assert.strictEqual(stripped.length, 20); - - // Should still have bold - assert.ok(result.includes("\x1b[1m")); - - // Should have background throughout (even after resets) - assert.ok(result.includes("\x1b[48") || result.includes("\x1b[42m")); - }); - - it("handles text with 0m resets by reapplying background", () => { - // Simulate: bold text + reset + normal text - const line = "\x1b[1mhello\x1b[0m world"; - const result = applyBackgroundToLine(line, 20, greenBg); - - // Should NOT have black cells (spaces without background) - // Pattern we DON'T want: 49m or 0m followed by spaces before bg reapplied - const blackCellPattern = /(\x1b\[49m|\x1b\[0m)\s+\x1b\[48;2/; - assert.ok(!blackCellPattern.test(result), `Found black cells in: ${JSON.stringify(result)}`); - - // Should be exactly 20 chars - const stripped = result.replace(/\x1b\[[0-9;]*m/g, ""); - assert.strictEqual(stripped.length, 20); + // Middle lines should not end with full reset + for (let i = 0; i < wrapped.length - 1; i++) { + expect(wrapped[i].endsWith("\x1b[0m")).toBe(false); + } + }); }); }); diff --git a/packages/tui/vitest.config.ts b/packages/tui/vitest.config.ts new file mode 100644 index 00000000..a90c176d --- /dev/null +++ b/packages/tui/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["test/wrap-ansi.test.ts"], + }, +}); From 3a5185c5fd23cd92a732ea3e74ac926e2ab1ac24 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 5 Dec 2025 22:00:23 +0100 Subject: [PATCH 26/54] feat(tui): add prompt history navigation with Up/Down arrows - Browse previously submitted prompts using Up/Down arrow keys - History is session-scoped and stores up to 100 entries - Load history from session on continue/resume - Includes 15 tests for history navigation fixes #121 --- packages/coding-agent/CHANGELOG.md | 2 +- packages/coding-agent/src/tui/tui-renderer.ts | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 83997d2a..581ea365 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- **Prompt History Navigation**: Browse previously submitted prompts using Up/Down arrow keys when the editor is empty. Press Up to cycle through older prompts, Down to return to newer ones or clear the editor. Similar to shell history and Claude Code's prompt history feature. History is session-scoped and stores up to 100 entries. +- **Prompt History Navigation**: Browse previously submitted prompts using Up/Down arrow keys when the editor is empty. Press Up to cycle through older prompts, Down to return to newer ones or clear the editor. Similar to shell history and Claude Code's prompt history feature. History is session-scoped and stores up to 100 entries. ([#121](https://github.com/badlogic/pi-mono/pull/121) by [@nicobailon](https://github.com/nicobailon)) ## [0.12.10] - 2025-12-04 diff --git a/packages/coding-agent/src/tui/tui-renderer.ts b/packages/coding-agent/src/tui/tui-renderer.ts index 4546816d..15fcf992 100644 --- a/packages/coding-agent/src/tui/tui-renderer.ts +++ b/packages/coding-agent/src/tui/tui-renderer.ts @@ -881,6 +881,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(); } From 4bd52cf3ed19acbb9c041f344df7b5f9632e5c48 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 5 Dec 2025 22:53:51 +0100 Subject: [PATCH 27/54] feat(coding-agent): add /resume command to switch sessions mid-conversation - Opens interactive session selector - Properly aborts in-flight agent turns before switching - Restores model and thinking level from resumed session - Clears UI state (queued messages, pending tools, etc.) closes #117 --- packages/coding-agent/src/tui/tui-renderer.ts | 80 ++++++++++++++----- 1 file changed, 61 insertions(+), 19 deletions(-) diff --git a/packages/coding-agent/src/tui/tui-renderer.ts b/packages/coding-agent/src/tui/tui-renderer.ts index 6254ec45..bd21490d 100644 --- a/packages/coding-agent/src/tui/tui-renderer.ts +++ b/packages/coding-agent/src/tui/tui-renderer.ts @@ -1489,26 +1489,9 @@ export class TuiRenderer { // Create session selector this.sessionSelector = new SessionSelectorComponent( this.sessionManager, - (sessionPath) => { - // Set the selected session as active - this.sessionManager.setSessionFile(sessionPath); - - // Reload the session - const loaded = loadSessionFromEntries(this.sessionManager.loadEntries()); - this.agent.replaceMessages(loaded.messages); - - // 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)); - - // Hide selector and show editor again + async (sessionPath) => { this.hideSessionSelector(); - this.ui.requestRender(); + await this.handleResumeSession(sessionPath); }, () => { // Just hide the selector @@ -1524,6 +1507,65 @@ export class TuiRenderer { this.ui.requestRender(); } + private async handleResumeSession(sessionPath: string): Promise { + // 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(); From ddf09720ccefc18020619f90c423128d6dc70ed1 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 5 Dec 2025 23:00:45 +0100 Subject: [PATCH 28/54] Release v0.12.12 --- AGENTS.md | 27 ++++++++++++++++++++ package-lock.json | 38 ++++++++++++++-------------- packages/agent/package.json | 6 ++--- packages/ai/package.json | 2 +- packages/coding-agent/CHANGELOG.md | 4 +-- packages/coding-agent/package.json | 8 +++--- packages/mom/package.json | 6 ++--- packages/pods/package.json | 4 +-- packages/proxy/package.json | 2 +- packages/tui/package.json | 2 +- packages/web-ui/example/package.json | 2 +- packages/web-ui/package.json | 6 ++--- 12 files changed, 67 insertions(+), 40 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c01bec64..e3d81904 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,3 +49,30 @@ When closing issues via commit: - NEVER modify already-released version sections (e.g., `## [0.12.2]`) - Each version section is immutable once released - When releasing: rename `[Unreleased]` to the new version, then add a fresh empty `[Unreleased]` section + +## Releasing + +1. **Bump version** (all packages use lockstep versioning): + ```bash + npm run version:patch # For bug fixes + npm run version:minor # For new features + npm run version:major # For breaking changes + ``` + +2. **Finalize CHANGELOG.md**: Change `[Unreleased]` to the new version with today's date (e.g., `## [0.12.12] - 2025-12-05`) + +3. **Commit and tag**: + ```bash + git add . + git commit -m "Release v0.12.12" + git tag v0.12.12 + git push origin main + git push origin v0.12.12 + ``` + +4. **Publish to npm**: + ```bash + npm run publish + ``` + +5. **Add new [Unreleased] section** at top of CHANGELOG.md for next cycle, commit it diff --git a/package-lock.json b/package-lock.json index b530144e..b9dea854 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5975,11 +5975,11 @@ }, "packages/agent": { "name": "@mariozechner/pi-agent-core", - "version": "0.12.11", + "version": "0.12.12", "license": "MIT", "dependencies": { - "@mariozechner/pi-ai": "^0.12.10", - "@mariozechner/pi-tui": "^0.12.10" + "@mariozechner/pi-ai": "^0.12.11", + "@mariozechner/pi-tui": "^0.12.11" }, "devDependencies": { "@types/node": "^24.3.0", @@ -6009,7 +6009,7 @@ }, "packages/ai": { "name": "@mariozechner/pi-ai", - "version": "0.12.11", + "version": "0.12.12", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.61.0", @@ -6050,12 +6050,12 @@ }, "packages/coding-agent": { "name": "@mariozechner/pi-coding-agent", - "version": "0.12.11", + "version": "0.12.12", "license": "MIT", "dependencies": { - "@mariozechner/pi-agent-core": "^0.12.10", - "@mariozechner/pi-ai": "^0.12.10", - "@mariozechner/pi-tui": "^0.12.10", + "@mariozechner/pi-agent-core": "^0.12.11", + "@mariozechner/pi-ai": "^0.12.11", + "@mariozechner/pi-tui": "^0.12.11", "chalk": "^5.5.0", "diff": "^8.0.2", "glob": "^11.0.3" @@ -6092,12 +6092,12 @@ }, "packages/mom": { "name": "@mariozechner/pi-mom", - "version": "0.12.11", + "version": "0.12.12", "license": "MIT", "dependencies": { "@anthropic-ai/sandbox-runtime": "^0.0.16", - "@mariozechner/pi-agent-core": "^0.12.10", - "@mariozechner/pi-ai": "^0.12.10", + "@mariozechner/pi-agent-core": "^0.12.11", + "@mariozechner/pi-ai": "^0.12.11", "@sinclair/typebox": "^0.34.0", "@slack/socket-mode": "^2.0.0", "@slack/web-api": "^7.0.0", @@ -6135,10 +6135,10 @@ }, "packages/pods": { "name": "@mariozechner/pi", - "version": "0.12.11", + "version": "0.12.12", "license": "MIT", "dependencies": { - "@mariozechner/pi-agent-core": "^0.12.10", + "@mariozechner/pi-agent-core": "^0.12.11", "chalk": "^5.5.0" }, "bin": { @@ -6151,7 +6151,7 @@ }, "packages/proxy": { "name": "@mariozechner/pi-proxy", - "version": "0.12.11", + "version": "0.12.12", "dependencies": { "@hono/node-server": "^1.14.0", "hono": "^4.6.16" @@ -6167,7 +6167,7 @@ }, "packages/tui": { "name": "@mariozechner/pi-tui", - "version": "0.12.11", + "version": "0.12.12", "license": "MIT", "dependencies": { "@types/mime-types": "^2.1.4", @@ -6211,12 +6211,12 @@ }, "packages/web-ui": { "name": "@mariozechner/pi-web-ui", - "version": "0.12.11", + "version": "0.12.12", "license": "MIT", "dependencies": { "@lmstudio/sdk": "^1.5.0", - "@mariozechner/pi-ai": "^0.12.10", - "@mariozechner/pi-tui": "^0.12.10", + "@mariozechner/pi-ai": "^0.12.11", + "@mariozechner/pi-tui": "^0.12.11", "docx-preview": "^0.3.7", "jszip": "^3.10.1", "lucide": "^0.544.0", @@ -6237,7 +6237,7 @@ }, "packages/web-ui/example": { "name": "pi-web-ui-example", - "version": "1.0.7", + "version": "1.0.8", "dependencies": { "@mariozechner/mini-lit": "^0.2.0", "@mariozechner/pi-ai": "file:../../ai", diff --git a/packages/agent/package.json b/packages/agent/package.json index 79cc0880..aa4d0ec6 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-agent-core", - "version": "0.12.11", + "version": "0.12.12", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "type": "module", "main": "./dist/index.js", @@ -18,8 +18,8 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@mariozechner/pi-ai": "^0.12.11", - "@mariozechner/pi-tui": "^0.12.11" + "@mariozechner/pi-ai": "^0.12.12", + "@mariozechner/pi-tui": "^0.12.12" }, "keywords": [ "ai", diff --git a/packages/ai/package.json b/packages/ai/package.json index 1fb6db05..2dd9964a 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-ai", - "version": "0.12.11", + "version": "0.12.12", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e5478b13..e681f0d2 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.12.12] - 2025-12-05 ### Changed @@ -15,6 +15,7 @@ - **Fuzzy search models and sessions**: Implemented a simple fuzzy search for models and sessions (e.g., `codexmax` now finds `gpt-5.1-codex-max`). ([#122](https://github.com/badlogic/pi-mono/pull/122) by [@markusylisiurunen](https://github.com/markusylisiurunen)) - **Prompt History Navigation**: Browse previously submitted prompts using Up/Down arrow keys when the editor is empty. Press Up to cycle through older prompts, Down to return to newer ones or clear the editor. Similar to shell history and Claude Code's prompt history feature. History is session-scoped and stores up to 100 entries. ([#121](https://github.com/badlogic/pi-mono/pull/121) by [@nicobailon](https://github.com/nicobailon)) +- **`/resume` Command**: Switch to a different session mid-conversation. Opens an interactive selector showing all available sessions. Equivalent to the `--resume` CLI flag but can be used without restarting the agent. ([#117](https://github.com/badlogic/pi-mono/pull/117) by [@hewliyang](https://github.com/hewliyang)) ## [0.12.11] - 2025-12-05 @@ -31,7 +32,6 @@ ### Added -- **`/resume` Command**: Switch to a different session mid-conversation. Opens an interactive selector showing all available sessions. Equivalent to the `--resume` CLI flag but can be used without restarting the agent. ([#117](https://github.com/badlogic/pi-mono/pull/117) by [@hewliyang](https://github.com/hewliyang)) - **`authHeader` option in models.json**: Custom providers can set `"authHeader": true` to automatically add `Authorization: Bearer ` header. Useful for providers that require explicit auth headers. ([#81](https://github.com/badlogic/pi-mono/issues/81)) - **`--append-system-prompt` Flag**: Append additional text or file contents to the system prompt. Supports both inline text and file paths. Complements `--system-prompt` for layering custom instructions without replacing the base system prompt. ([#114](https://github.com/badlogic/pi-mono/pull/114) by [@markusylisiurunen](https://github.com/markusylisiurunen)) - **Thinking Block Toggle**: Added `Ctrl+T` shortcut to toggle visibility of LLM thinking blocks. When toggled off, shows a static "Thinking..." label instead of full content. Useful for reducing visual clutter during long conversations. ([#113](https://github.com/badlogic/pi-mono/pull/113) by [@markusylisiurunen](https://github.com/markusylisiurunen)) diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index c0a45d01..1ffebc23 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-coding-agent", - "version": "0.12.11", + "version": "0.12.12", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "type": "module", "piConfig": { @@ -28,9 +28,9 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@mariozechner/pi-agent-core": "^0.12.11", - "@mariozechner/pi-ai": "^0.12.11", - "@mariozechner/pi-tui": "^0.12.11", + "@mariozechner/pi-agent-core": "^0.12.12", + "@mariozechner/pi-ai": "^0.12.12", + "@mariozechner/pi-tui": "^0.12.12", "chalk": "^5.5.0", "diff": "^8.0.2", "glob": "^11.0.3" diff --git a/packages/mom/package.json b/packages/mom/package.json index 052b9a0e..c98db1aa 100644 --- a/packages/mom/package.json +++ b/packages/mom/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-mom", - "version": "0.12.11", + "version": "0.12.12", "description": "Slack bot that delegates messages to the pi coding agent", "type": "module", "bin": { @@ -21,8 +21,8 @@ }, "dependencies": { "@anthropic-ai/sandbox-runtime": "^0.0.16", - "@mariozechner/pi-agent-core": "^0.12.11", - "@mariozechner/pi-ai": "^0.12.11", + "@mariozechner/pi-agent-core": "^0.12.12", + "@mariozechner/pi-ai": "^0.12.12", "@sinclair/typebox": "^0.34.0", "@slack/socket-mode": "^2.0.0", "@slack/web-api": "^7.0.0", diff --git a/packages/pods/package.json b/packages/pods/package.json index 3e32dab4..016a1f96 100644 --- a/packages/pods/package.json +++ b/packages/pods/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi", - "version": "0.12.11", + "version": "0.12.12", "description": "CLI tool for managing vLLM deployments on GPU pods", "type": "module", "bin": { @@ -34,7 +34,7 @@ "node": ">=20.0.0" }, "dependencies": { - "@mariozechner/pi-agent-core": "^0.12.11", + "@mariozechner/pi-agent-core": "^0.12.12", "chalk": "^5.5.0" }, "devDependencies": {} diff --git a/packages/proxy/package.json b/packages/proxy/package.json index 0e77727e..abe6c9c0 100644 --- a/packages/proxy/package.json +++ b/packages/proxy/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-proxy", - "version": "0.12.11", + "version": "0.12.12", "type": "module", "description": "CORS and authentication proxy for pi-ai", "main": "dist/index.js", diff --git a/packages/tui/package.json b/packages/tui/package.json index e7f4ddad..da6517f5 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-tui", - "version": "0.12.11", + "version": "0.12.12", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "type": "module", "main": "dist/index.js", diff --git a/packages/web-ui/example/package.json b/packages/web-ui/example/package.json index 99587303..a2c3adf6 100644 --- a/packages/web-ui/example/package.json +++ b/packages/web-ui/example/package.json @@ -1,6 +1,6 @@ { "name": "pi-web-ui-example", - "version": "1.0.7", + "version": "1.0.8", "private": true, "type": "module", "scripts": { diff --git a/packages/web-ui/package.json b/packages/web-ui/package.json index 63f896a7..500b24e8 100644 --- a/packages/web-ui/package.json +++ b/packages/web-ui/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-web-ui", - "version": "0.12.11", + "version": "0.12.12", "description": "Reusable web UI components for AI chat interfaces powered by @mariozechner/pi-ai", "type": "module", "main": "dist/index.js", @@ -18,8 +18,8 @@ }, "dependencies": { "@lmstudio/sdk": "^1.5.0", - "@mariozechner/pi-ai": "^0.12.11", - "@mariozechner/pi-tui": "^0.12.11", + "@mariozechner/pi-ai": "^0.12.12", + "@mariozechner/pi-tui": "^0.12.12", "docx-preview": "^0.3.7", "jszip": "^3.10.1", "lucide": "^0.544.0", From c423734e36926d5bb7d882770da7424d84636bca Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 5 Dec 2025 23:01:46 +0100 Subject: [PATCH 29/54] Add [Unreleased] section --- packages/ai/src/models.generated.ts | 40 ++++++++++++++++++++++++++--- packages/coding-agent/CHANGELOG.md | 2 ++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 75de7b00..283f8ca7 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -1991,6 +1991,23 @@ export const MODELS = { } satisfies Model<"anthropic-messages">, }, openrouter: { + "openai/gpt-5.1-codex-max": { + id: "openai/gpt-5.1-codex-max", + name: "OpenAI: GPT-5.1-Codex-Max", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.125, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, "amazon/nova-2-lite-v1:free": { id: "amazon/nova-2-lite-v1:free", name: "Amazon: Nova 2 Lite (free)", @@ -3402,6 +3419,23 @@ export const MODELS = { contextWindow: 400000, maxTokens: 128000, } satisfies Model<"openai-completions">, + "openai/gpt-oss-120b:free": { + id: "openai/gpt-oss-120b:free", + name: "OpenAI: gpt-oss-120b (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "openai/gpt-oss-120b": { id: "openai/gpt-oss-120b", name: "OpenAI: gpt-oss-120b", @@ -4686,13 +4720,13 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.049999999999999996, - output: 0.08, + input: 0.03, + output: 0.11, cacheRead: 0, cacheWrite: 0, }, contextWindow: 32768, - maxTokens: 16384, + maxTokens: 32768, } satisfies Model<"openai-completions">, "deepseek/deepseek-r1-distill-llama-70b": { id: "deepseek/deepseek-r1-distill-llama-70b", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e681f0d2..33ea895a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.12.12] - 2025-12-05 ### Changed From 7352072bc2128bbd26d8ebed0cc959dac72990f9 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 5 Dec 2025 23:36:06 +0100 Subject: [PATCH 30/54] Run version check in parallel with TUI startup Instead of blocking startup for up to 1 second waiting for the version check, run it in the background and insert the notification into chat when it completes. --- packages/coding-agent/src/main.ts | 24 ++++++------ packages/coding-agent/src/tui/tui-renderer.ts | 37 +++++++++---------- 2 files changed, 29 insertions(+), 32 deletions(-) diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 7f764ac1..067370b2 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -717,7 +717,7 @@ async function runInteractiveMode( version: string, changelogMarkdown: string | null = null, modelFallbackMessage: string | null = null, - newVersion: string | null = null, + versionCheckPromise: Promise, scopedModels: Array<{ model: Model; thinkingLevel: ThinkingLevel }> = [], initialMessages: string[] = [], initialMessage?: string, @@ -730,7 +730,6 @@ async function runInteractiveMode( settingsManager, version, changelogMarkdown, - newVersion, scopedModels, fdPath, ); @@ -738,6 +737,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); @@ -1334,16 +1340,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((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; @@ -1394,7 +1392,7 @@ export async function main(args: string[]) { VERSION, changelogMarkdown, modelFallbackMessage, - newVersion, + versionCheckPromise, scopedModels, parsed.messages, initialMessage, diff --git a/packages/coding-agent/src/tui/tui-renderer.ts b/packages/coding-agent/src/tui/tui-renderer.ts index 5447654c..53d9c781 100644 --- a/packages/coding-agent/src/tui/tui-renderer.ts +++ b/packages/coding-agent/src/tui/tui-renderer.ts @@ -70,7 +70,6 @@ export class TuiRenderer { private lastSigintTime = 0; private changelogMarkdown: string | null = null; - private newVersion: string | null = null; // Message queueing private queuedMessages: string[] = []; @@ -126,7 +125,6 @@ export class TuiRenderer { settingsManager: SettingsManager, version: string, changelogMarkdown: string | null = null, - newVersion: string | null = null, scopedModels: Array<{ model: Model; thinkingLevel: ThinkingLevel }> = [], fdPath: string | null = null, ) { @@ -134,7 +132,6 @@ export class TuiRenderer { this.sessionManager = sessionManager; this.settingsManager = settingsManager; this.version = version; - this.newVersion = newVersion; this.changelogMarkdown = changelogMarkdown; this.scopedModels = scopedModels; this.ui = new TUI(new ProcessTerminal()); @@ -303,22 +300,6 @@ 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()); @@ -1214,6 +1195,24 @@ export class TuiRenderer { this.ui.requestRender(); } + showNewVersionNotification(newVersion: string): void { + // Show new version notification in the chat + this.chatContainer.addChild(new Spacer(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(); + } + private showThinkingSelector(): void { // Create thinking selector with current level this.thinkingSelector = new ThinkingSelectorComponent( From bd3f5779d32318ab7d41ac845f1e0ea1aecb9f96 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 5 Dec 2025 23:36:30 +0100 Subject: [PATCH 31/54] Add changelog entry for parallel version check --- packages/coding-agent/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 33ea895a..5e8f98dc 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Changed + +- **Faster startup**: Version check now runs in parallel with TUI initialization instead of blocking startup for up to 1 second. Update notifications appear in chat when the check completes. + ## [0.12.12] - 2025-12-05 ### Changed From 05849258b5e34a4677d6b22b3b4a6f138c735121 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 5 Dec 2025 23:38:32 +0100 Subject: [PATCH 32/54] Release v0.12.13 --- package-lock.json | 38 ++++++++++++++-------------- packages/agent/package.json | 6 ++--- packages/ai/package.json | 2 +- packages/coding-agent/CHANGELOG.md | 2 ++ packages/coding-agent/package.json | 8 +++--- packages/mom/package.json | 6 ++--- packages/pods/package.json | 4 +-- packages/proxy/package.json | 2 +- packages/tui/package.json | 2 +- packages/web-ui/example/package.json | 2 +- packages/web-ui/package.json | 6 ++--- 11 files changed, 40 insertions(+), 38 deletions(-) diff --git a/package-lock.json b/package-lock.json index b9dea854..2141fc80 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5975,11 +5975,11 @@ }, "packages/agent": { "name": "@mariozechner/pi-agent-core", - "version": "0.12.12", + "version": "0.12.13", "license": "MIT", "dependencies": { - "@mariozechner/pi-ai": "^0.12.11", - "@mariozechner/pi-tui": "^0.12.11" + "@mariozechner/pi-ai": "^0.12.12", + "@mariozechner/pi-tui": "^0.12.12" }, "devDependencies": { "@types/node": "^24.3.0", @@ -6009,7 +6009,7 @@ }, "packages/ai": { "name": "@mariozechner/pi-ai", - "version": "0.12.12", + "version": "0.12.13", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.61.0", @@ -6050,12 +6050,12 @@ }, "packages/coding-agent": { "name": "@mariozechner/pi-coding-agent", - "version": "0.12.12", + "version": "0.12.13", "license": "MIT", "dependencies": { - "@mariozechner/pi-agent-core": "^0.12.11", - "@mariozechner/pi-ai": "^0.12.11", - "@mariozechner/pi-tui": "^0.12.11", + "@mariozechner/pi-agent-core": "^0.12.12", + "@mariozechner/pi-ai": "^0.12.12", + "@mariozechner/pi-tui": "^0.12.12", "chalk": "^5.5.0", "diff": "^8.0.2", "glob": "^11.0.3" @@ -6092,12 +6092,12 @@ }, "packages/mom": { "name": "@mariozechner/pi-mom", - "version": "0.12.12", + "version": "0.12.13", "license": "MIT", "dependencies": { "@anthropic-ai/sandbox-runtime": "^0.0.16", - "@mariozechner/pi-agent-core": "^0.12.11", - "@mariozechner/pi-ai": "^0.12.11", + "@mariozechner/pi-agent-core": "^0.12.12", + "@mariozechner/pi-ai": "^0.12.12", "@sinclair/typebox": "^0.34.0", "@slack/socket-mode": "^2.0.0", "@slack/web-api": "^7.0.0", @@ -6135,10 +6135,10 @@ }, "packages/pods": { "name": "@mariozechner/pi", - "version": "0.12.12", + "version": "0.12.13", "license": "MIT", "dependencies": { - "@mariozechner/pi-agent-core": "^0.12.11", + "@mariozechner/pi-agent-core": "^0.12.12", "chalk": "^5.5.0" }, "bin": { @@ -6151,7 +6151,7 @@ }, "packages/proxy": { "name": "@mariozechner/pi-proxy", - "version": "0.12.12", + "version": "0.12.13", "dependencies": { "@hono/node-server": "^1.14.0", "hono": "^4.6.16" @@ -6167,7 +6167,7 @@ }, "packages/tui": { "name": "@mariozechner/pi-tui", - "version": "0.12.12", + "version": "0.12.13", "license": "MIT", "dependencies": { "@types/mime-types": "^2.1.4", @@ -6211,12 +6211,12 @@ }, "packages/web-ui": { "name": "@mariozechner/pi-web-ui", - "version": "0.12.12", + "version": "0.12.13", "license": "MIT", "dependencies": { "@lmstudio/sdk": "^1.5.0", - "@mariozechner/pi-ai": "^0.12.11", - "@mariozechner/pi-tui": "^0.12.11", + "@mariozechner/pi-ai": "^0.12.12", + "@mariozechner/pi-tui": "^0.12.12", "docx-preview": "^0.3.7", "jszip": "^3.10.1", "lucide": "^0.544.0", @@ -6237,7 +6237,7 @@ }, "packages/web-ui/example": { "name": "pi-web-ui-example", - "version": "1.0.8", + "version": "1.0.9", "dependencies": { "@mariozechner/mini-lit": "^0.2.0", "@mariozechner/pi-ai": "file:../../ai", diff --git a/packages/agent/package.json b/packages/agent/package.json index aa4d0ec6..a928c6a3 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-agent-core", - "version": "0.12.12", + "version": "0.12.13", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "type": "module", "main": "./dist/index.js", @@ -18,8 +18,8 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@mariozechner/pi-ai": "^0.12.12", - "@mariozechner/pi-tui": "^0.12.12" + "@mariozechner/pi-ai": "^0.12.13", + "@mariozechner/pi-tui": "^0.12.13" }, "keywords": [ "ai", diff --git a/packages/ai/package.json b/packages/ai/package.json index 2dd9964a..a8187e67 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-ai", - "version": "0.12.12", + "version": "0.12.13", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5e8f98dc..6aba4824 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +## [0.12.13] - 2025-12-05 + ### Changed - **Faster startup**: Version check now runs in parallel with TUI initialization instead of blocking startup for up to 1 second. Update notifications appear in chat when the check completes. diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 1ffebc23..a32fa19a 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-coding-agent", - "version": "0.12.12", + "version": "0.12.13", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "type": "module", "piConfig": { @@ -28,9 +28,9 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@mariozechner/pi-agent-core": "^0.12.12", - "@mariozechner/pi-ai": "^0.12.12", - "@mariozechner/pi-tui": "^0.12.12", + "@mariozechner/pi-agent-core": "^0.12.13", + "@mariozechner/pi-ai": "^0.12.13", + "@mariozechner/pi-tui": "^0.12.13", "chalk": "^5.5.0", "diff": "^8.0.2", "glob": "^11.0.3" diff --git a/packages/mom/package.json b/packages/mom/package.json index c98db1aa..ea84eb0f 100644 --- a/packages/mom/package.json +++ b/packages/mom/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-mom", - "version": "0.12.12", + "version": "0.12.13", "description": "Slack bot that delegates messages to the pi coding agent", "type": "module", "bin": { @@ -21,8 +21,8 @@ }, "dependencies": { "@anthropic-ai/sandbox-runtime": "^0.0.16", - "@mariozechner/pi-agent-core": "^0.12.12", - "@mariozechner/pi-ai": "^0.12.12", + "@mariozechner/pi-agent-core": "^0.12.13", + "@mariozechner/pi-ai": "^0.12.13", "@sinclair/typebox": "^0.34.0", "@slack/socket-mode": "^2.0.0", "@slack/web-api": "^7.0.0", diff --git a/packages/pods/package.json b/packages/pods/package.json index 016a1f96..d40a449d 100644 --- a/packages/pods/package.json +++ b/packages/pods/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi", - "version": "0.12.12", + "version": "0.12.13", "description": "CLI tool for managing vLLM deployments on GPU pods", "type": "module", "bin": { @@ -34,7 +34,7 @@ "node": ">=20.0.0" }, "dependencies": { - "@mariozechner/pi-agent-core": "^0.12.12", + "@mariozechner/pi-agent-core": "^0.12.13", "chalk": "^5.5.0" }, "devDependencies": {} diff --git a/packages/proxy/package.json b/packages/proxy/package.json index abe6c9c0..15ca6f69 100644 --- a/packages/proxy/package.json +++ b/packages/proxy/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-proxy", - "version": "0.12.12", + "version": "0.12.13", "type": "module", "description": "CORS and authentication proxy for pi-ai", "main": "dist/index.js", diff --git a/packages/tui/package.json b/packages/tui/package.json index da6517f5..26406ed5 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-tui", - "version": "0.12.12", + "version": "0.12.13", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "type": "module", "main": "dist/index.js", diff --git a/packages/web-ui/example/package.json b/packages/web-ui/example/package.json index a2c3adf6..ec6391cc 100644 --- a/packages/web-ui/example/package.json +++ b/packages/web-ui/example/package.json @@ -1,6 +1,6 @@ { "name": "pi-web-ui-example", - "version": "1.0.8", + "version": "1.0.9", "private": true, "type": "module", "scripts": { diff --git a/packages/web-ui/package.json b/packages/web-ui/package.json index 500b24e8..4e3dbe10 100644 --- a/packages/web-ui/package.json +++ b/packages/web-ui/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-web-ui", - "version": "0.12.12", + "version": "0.12.13", "description": "Reusable web UI components for AI chat interfaces powered by @mariozechner/pi-ai", "type": "module", "main": "dist/index.js", @@ -18,8 +18,8 @@ }, "dependencies": { "@lmstudio/sdk": "^1.5.0", - "@mariozechner/pi-ai": "^0.12.12", - "@mariozechner/pi-tui": "^0.12.12", + "@mariozechner/pi-ai": "^0.12.13", + "@mariozechner/pi-tui": "^0.12.13", "docx-preview": "^0.3.7", "jszip": "^3.10.1", "lucide": "^0.544.0", From 4a972fbe6cde8b2d4ca6e07ba5250bfceed2cb5d Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 6 Dec 2025 00:46:57 +0100 Subject: [PATCH 33/54] Release v0.12.14 --- package-lock.json | 38 +++++------ packages/agent/package.json | 6 +- packages/ai/package.json | 2 +- packages/coding-agent/CHANGELOG.md | 6 ++ packages/coding-agent/package.json | 8 +-- packages/coding-agent/src/tui/tui-renderer.ts | 10 +++ packages/mom/out.html | 66 +++++++++++++++++++ packages/mom/package.json | 6 +- packages/pods/package.json | 4 +- packages/proxy/package.json | 2 +- packages/tui/package.json | 2 +- packages/web-ui/example/package.json | 2 +- packages/web-ui/package.json | 6 +- 13 files changed, 120 insertions(+), 38 deletions(-) create mode 100644 packages/mom/out.html diff --git a/package-lock.json b/package-lock.json index 2141fc80..8247c37b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5975,11 +5975,11 @@ }, "packages/agent": { "name": "@mariozechner/pi-agent-core", - "version": "0.12.13", + "version": "0.12.14", "license": "MIT", "dependencies": { - "@mariozechner/pi-ai": "^0.12.12", - "@mariozechner/pi-tui": "^0.12.12" + "@mariozechner/pi-ai": "^0.12.13", + "@mariozechner/pi-tui": "^0.12.13" }, "devDependencies": { "@types/node": "^24.3.0", @@ -6009,7 +6009,7 @@ }, "packages/ai": { "name": "@mariozechner/pi-ai", - "version": "0.12.13", + "version": "0.12.14", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.61.0", @@ -6050,12 +6050,12 @@ }, "packages/coding-agent": { "name": "@mariozechner/pi-coding-agent", - "version": "0.12.13", + "version": "0.12.14", "license": "MIT", "dependencies": { - "@mariozechner/pi-agent-core": "^0.12.12", - "@mariozechner/pi-ai": "^0.12.12", - "@mariozechner/pi-tui": "^0.12.12", + "@mariozechner/pi-agent-core": "^0.12.13", + "@mariozechner/pi-ai": "^0.12.13", + "@mariozechner/pi-tui": "^0.12.13", "chalk": "^5.5.0", "diff": "^8.0.2", "glob": "^11.0.3" @@ -6092,12 +6092,12 @@ }, "packages/mom": { "name": "@mariozechner/pi-mom", - "version": "0.12.13", + "version": "0.12.14", "license": "MIT", "dependencies": { "@anthropic-ai/sandbox-runtime": "^0.0.16", - "@mariozechner/pi-agent-core": "^0.12.12", - "@mariozechner/pi-ai": "^0.12.12", + "@mariozechner/pi-agent-core": "^0.12.13", + "@mariozechner/pi-ai": "^0.12.13", "@sinclair/typebox": "^0.34.0", "@slack/socket-mode": "^2.0.0", "@slack/web-api": "^7.0.0", @@ -6135,10 +6135,10 @@ }, "packages/pods": { "name": "@mariozechner/pi", - "version": "0.12.13", + "version": "0.12.14", "license": "MIT", "dependencies": { - "@mariozechner/pi-agent-core": "^0.12.12", + "@mariozechner/pi-agent-core": "^0.12.13", "chalk": "^5.5.0" }, "bin": { @@ -6151,7 +6151,7 @@ }, "packages/proxy": { "name": "@mariozechner/pi-proxy", - "version": "0.12.13", + "version": "0.12.14", "dependencies": { "@hono/node-server": "^1.14.0", "hono": "^4.6.16" @@ -6167,7 +6167,7 @@ }, "packages/tui": { "name": "@mariozechner/pi-tui", - "version": "0.12.13", + "version": "0.12.14", "license": "MIT", "dependencies": { "@types/mime-types": "^2.1.4", @@ -6211,12 +6211,12 @@ }, "packages/web-ui": { "name": "@mariozechner/pi-web-ui", - "version": "0.12.13", + "version": "0.12.14", "license": "MIT", "dependencies": { "@lmstudio/sdk": "^1.5.0", - "@mariozechner/pi-ai": "^0.12.12", - "@mariozechner/pi-tui": "^0.12.12", + "@mariozechner/pi-ai": "^0.12.13", + "@mariozechner/pi-tui": "^0.12.13", "docx-preview": "^0.3.7", "jszip": "^3.10.1", "lucide": "^0.544.0", @@ -6237,7 +6237,7 @@ }, "packages/web-ui/example": { "name": "pi-web-ui-example", - "version": "1.0.9", + "version": "1.0.10", "dependencies": { "@mariozechner/mini-lit": "^0.2.0", "@mariozechner/pi-ai": "file:../../ai", diff --git a/packages/agent/package.json b/packages/agent/package.json index a928c6a3..e00b84ae 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-agent-core", - "version": "0.12.13", + "version": "0.12.14", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "type": "module", "main": "./dist/index.js", @@ -18,8 +18,8 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@mariozechner/pi-ai": "^0.12.13", - "@mariozechner/pi-tui": "^0.12.13" + "@mariozechner/pi-ai": "^0.12.14", + "@mariozechner/pi-tui": "^0.12.14" }, "keywords": [ "ai", diff --git a/packages/ai/package.json b/packages/ai/package.json index a8187e67..4a662cde 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-ai", - "version": "0.12.13", + "version": "0.12.14", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6aba4824..ccb7eb39 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +## [0.12.14] - 2025-12-06 + +### Added + +- **Double-Escape Branch Shortcut**: Press Escape twice with an empty editor to quickly open the `/branch` selector for conversation branching. + ## [0.12.13] - 2025-12-05 ### Changed diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index a32fa19a..afe2589e 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-coding-agent", - "version": "0.12.13", + "version": "0.12.14", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "type": "module", "piConfig": { @@ -28,9 +28,9 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@mariozechner/pi-agent-core": "^0.12.13", - "@mariozechner/pi-ai": "^0.12.13", - "@mariozechner/pi-tui": "^0.12.13", + "@mariozechner/pi-agent-core": "^0.12.14", + "@mariozechner/pi-ai": "^0.12.14", + "@mariozechner/pi-tui": "^0.12.14", "chalk": "^5.5.0", "diff": "^8.0.2", "glob": "^11.0.3" diff --git a/packages/coding-agent/src/tui/tui-renderer.ts b/packages/coding-agent/src/tui/tui-renderer.ts index 53d9c781..34b4c63d 100644 --- a/packages/coding-agent/src/tui/tui-renderer.ts +++ b/packages/coding-agent/src/tui/tui-renderer.ts @@ -69,6 +69,7 @@ export class TuiRenderer { private loadingAnimation: Loader | null = null; private lastSigintTime = 0; + private lastEscapeTime = 0; private changelogMarkdown: string | null = null; // Message queueing @@ -343,6 +344,15 @@ export class TuiRenderer { // Abort this.agent.abort(); + } 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; + } } }; diff --git a/packages/mom/out.html b/packages/mom/out.html new file mode 100644 index 00000000..eca59864 --- /dev/null +++ b/packages/mom/out.html @@ -0,0 +1,66 @@ + + + + + + + + + rust programming - Brave Search + + + +
🌐
Rust
rust-lang.org
Rust Programming Language
Rust has great documentation, a friendly compiler with useful error messages, and top-notch tooling — an integrated package manager and build tool, smart multi-editor support with auto-completion and type inspections, an auto-formatter, and more.
Learn
A language empowering everyone to build reliable and efficient software.
Tools
A language empowering everyone to build reliable and efficient software.
Community
A language empowering everyone to build reliable and efficient software.
Install
A language empowering everyone to build reliable and efficient software.

memory-safe programming language without garbage collection

Rust is a general-purpose programming language. It is noted for its emphasis on performance, type safety, concurrency, and memory safety. Rust supports multiple programming paradigms. It was influenced by ideas from functional … Wikipedia
Factsheet
Developer The Rust Team
First appeared January 19, 2012; 13 years ago (2012-01-19)
Factsheet
Developer The Rust Team
First appeared January 19, 2012; 13 years ago (2012-01-19)
🌐
Wikipedia
en.wikipedia.org › wiki › Rust_(programming_language)
Rust (programming language) - Wikipedia
1 day ago - Rust is a general-purpose programming language. It is noted for its emphasis on performance, type safety, concurrency, and memory safety. Rust supports multiple programming paradigms. It was influenced by ideas from functional programming, including immutability, higher-order functions, algebraic ...
🌐
Rust Programming Language
rust-lang.org › learn
Learn Rust - Rust Programming Language
Affectionately nicknamed “the book,” The Rust Programming Language will give you an overview of the language from first principles.
🌐
W3Schools
w3schools.com › rust
Rust Tutorial
Rust is a popular programming language used to build everything from web servers to game engines.
🌐
The Rust Programming Language
doc.rust-lang.org › book › ch00-00-introduction.html
Introduction - The Rust Programming Language
Welcome to The Rust Programming Language, an introductory book about Rust. The Rust programming language helps you write faster, more reliable software. High-level ergonomics and low-level control are often at odds in programming language design; Rust challenges that conflict.
🌐
Medium
medium.com › codex › rust-101-everything-you-need-to-know-about-rust-f3dd0ae99f4c
Rust 101 — Everything you need to know about Rust | by Nishant Aanjaney Jalan | CodeX | Medium
February 25, 2023 - Code should be able to run without crashing first time — Yes, you heard me. Rust is a statically typed language, and based on the way it is constructed, it leaves almost (I say, almost!) no room for your program to crash.
🌐
Wikipedia
de.wikipedia.org › wiki › Rust_(Programmiersprache)
Rust (Programmiersprache) – Wikipedia
May 16, 2015 - Rust vereint Ansätze aus verschiedenen Programmierparadigmen, unter anderem aus der funktionalen, der objektorientierten und der nebenläufigen Programmierung und erlaubt so ein hohes Abstraktionsniveau. Beispielsweise gibt es in Rust algebraische Datentypen, Pattern Matching, Traits (ähnlich den Typklassen in Haskell), Closures sowie Unterstützung für RAII.
🌐
Reddit
reddit.com › r › rust
The Rust Programming Language
December 2, 2010 - A place for all things related to the Rust programming language—an open-source systems language that emphasizes performance, reliability, and productivity.
Find elsewhere
🌐
GeeksforGeeks
geeksforgeeks.org › rust › introduction-to-rust-programming-language
Introduction to Rust Programming Language - GeeksforGeeks
July 23, 2025 - Rust is using Rust which means that all the standard compiler libraries are written in rust; there is a bit of use of the C programming language but most of it is Rust.
🌐
Stack Overflow
stackoverflow.blog › 2020 › 01 › 20 › what-is-rust-and-why-is-it-so-popular
What is Rust and why is it so popular? - Stack Overflow
This means any value may be what it says or nothing, effectively creating a second possible type for every type. Like Haskell and some other modern programming languages, Rust encodes this possibility using an optional type, and the compiler requires you to handle the None case.
🌐
YouTube
youtube.com › playlist
Rust Programming Tutorial 🦀 - YouTube
Learn Rust Programming in these videos! 🦀 We will cover the fundamental concepts behind the Rust language, such as control flow statements, variables, iterat...
🌐
CodiLime
codilime.com › blog › software development › backend › rust programming language - what is rust used for and why is so popular? - codilime
Rust programming language - what is rust used for and why is so popular? - CodiLime
Rust is a statically-typed programming language designed for performance and safety, especially safe concurrency and memory management. Its syntax is similar to that of C++. It is an open-source project developed originally at Mozilla Research.
🌐
GitHub
github.com › rust-lang › rust
GitHub - rust-lang/rust: Empowering everyone to build reliable and efficient software.
This is the main source code repository for Rust.
Starred by 108K users
Forked by 14.1K users
Languages   Rust 89.9% | HTML 7.2% | Shell 0.7% | JavaScript 0.5% | C 0.4% | Python 0.3%
🌐
Zero To Mastery
zerotomastery.io › home › courses › rust programming: the complete developer's guide
Learn Rust Programming: The Complete Developer's Guide | Zero To Mastery
Foundational computer science topics such as computer memory, program logic, and simple data structures · Working with data: enums, structs, tuples, expressions, optional data and more · Solid understanding of all core concepts of the Rust programming language such as: memory, mutability, traits, slices, and generics
🌐
MIT
web.mit.edu › rust-lang_v1.25 › arch › amd64_ubuntu1404 › share › doc › rust › html › book › first-edition › getting-started.html
Getting Started - The Rust Programming Language
Now, let’s go over what just happened in your "Hello, world!" program in detail. Here's the first piece of the puzzle: ... These lines define a function in Rust. The main function is special: it's the beginning of every Rust program.
🌐
Codecademy
codecademy.com › learn › rust-for-programmers
Rust for Programmers | Codecademy
A quick primer on the fundamentals of the Rust programming language for experienced programmers.
Rating: 3.9 ​ - ​ + 155 + votes
🌐
Programiz
programiz.com › rust
Learn Rust
It has been voted one of the most loved programming languages for more than five years in StackOverflow's survey. Rust is specifically designed to address the safety issues found in older languages like C and C++, by preventing memory leaks and common errors, making it a secure choice for system programming.
🌐
O'Reilly
oreilly.com › library › view › programming-rust-2nd › 9781492052586
Programming Rust, 2nd Edition [Book]
The Rust systems programming language combines that control with a modern type system that catches broad classes of common mistakes, from memory management errors to data races between threads.
🌐
GitHub
github.com › rust-lang
The Rust Programming Language · GitHub
The Rust Programming Language has 232 repositories available. Follow their code on GitHub.
+ + +
+ + + diff --git a/packages/mom/package.json b/packages/mom/package.json index ea84eb0f..347781ec 100644 --- a/packages/mom/package.json +++ b/packages/mom/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-mom", - "version": "0.12.13", + "version": "0.12.14", "description": "Slack bot that delegates messages to the pi coding agent", "type": "module", "bin": { @@ -21,8 +21,8 @@ }, "dependencies": { "@anthropic-ai/sandbox-runtime": "^0.0.16", - "@mariozechner/pi-agent-core": "^0.12.13", - "@mariozechner/pi-ai": "^0.12.13", + "@mariozechner/pi-agent-core": "^0.12.14", + "@mariozechner/pi-ai": "^0.12.14", "@sinclair/typebox": "^0.34.0", "@slack/socket-mode": "^2.0.0", "@slack/web-api": "^7.0.0", diff --git a/packages/pods/package.json b/packages/pods/package.json index d40a449d..55e7fd6e 100644 --- a/packages/pods/package.json +++ b/packages/pods/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi", - "version": "0.12.13", + "version": "0.12.14", "description": "CLI tool for managing vLLM deployments on GPU pods", "type": "module", "bin": { @@ -34,7 +34,7 @@ "node": ">=20.0.0" }, "dependencies": { - "@mariozechner/pi-agent-core": "^0.12.13", + "@mariozechner/pi-agent-core": "^0.12.14", "chalk": "^5.5.0" }, "devDependencies": {} diff --git a/packages/proxy/package.json b/packages/proxy/package.json index 15ca6f69..78df4b8f 100644 --- a/packages/proxy/package.json +++ b/packages/proxy/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-proxy", - "version": "0.12.13", + "version": "0.12.14", "type": "module", "description": "CORS and authentication proxy for pi-ai", "main": "dist/index.js", diff --git a/packages/tui/package.json b/packages/tui/package.json index 26406ed5..b2ace90c 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-tui", - "version": "0.12.13", + "version": "0.12.14", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "type": "module", "main": "dist/index.js", diff --git a/packages/web-ui/example/package.json b/packages/web-ui/example/package.json index ec6391cc..51e5359d 100644 --- a/packages/web-ui/example/package.json +++ b/packages/web-ui/example/package.json @@ -1,6 +1,6 @@ { "name": "pi-web-ui-example", - "version": "1.0.9", + "version": "1.0.10", "private": true, "type": "module", "scripts": { diff --git a/packages/web-ui/package.json b/packages/web-ui/package.json index 4e3dbe10..d19d8c83 100644 --- a/packages/web-ui/package.json +++ b/packages/web-ui/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-web-ui", - "version": "0.12.13", + "version": "0.12.14", "description": "Reusable web UI components for AI chat interfaces powered by @mariozechner/pi-ai", "type": "module", "main": "dist/index.js", @@ -18,8 +18,8 @@ }, "dependencies": { "@lmstudio/sdk": "^1.5.0", - "@mariozechner/pi-ai": "^0.12.13", - "@mariozechner/pi-tui": "^0.12.13", + "@mariozechner/pi-ai": "^0.12.14", + "@mariozechner/pi-tui": "^0.12.14", "docx-preview": "^0.3.7", "jszip": "^3.10.1", "lucide": "^0.544.0", From ee83284dcf28eae31b52baf4761cbddcaf167841 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 6 Dec 2025 00:48:46 +0100 Subject: [PATCH 34/54] Fix wrap-ansi test to use node:test instead of vitest --- packages/ai/src/models.generated.ts | 80 ++++++++++++++--------------- packages/tui/test/wrap-ansi.test.ts | 40 +++++++-------- 2 files changed, 58 insertions(+), 62 deletions(-) diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 283f8ca7..9a570e4a 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -4983,9 +4983,9 @@ export const MODELS = { contextWindow: 32768, maxTokens: 4096, } satisfies Model<"openai-completions">, - "anthropic/claude-3.5-haiku-20241022": { - id: "anthropic/claude-3.5-haiku-20241022", - name: "Anthropic: Claude 3.5 Haiku (2024-10-22)", + "anthropic/claude-3.5-haiku": { + id: "anthropic/claude-3.5-haiku", + name: "Anthropic: Claude 3.5 Haiku", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -5000,9 +5000,9 @@ export const MODELS = { contextWindow: 200000, maxTokens: 8192, } satisfies Model<"openai-completions">, - "anthropic/claude-3.5-haiku": { - id: "anthropic/claude-3.5-haiku", - name: "Anthropic: Claude 3.5 Haiku", + "anthropic/claude-3.5-haiku-20241022": { + id: "anthropic/claude-3.5-haiku-20241022", + name: "Anthropic: Claude 3.5 Haiku (2024-10-22)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -5153,23 +5153,6 @@ export const MODELS = { contextWindow: 32768, maxTokens: 4096, } satisfies Model<"openai-completions">, - "cohere/command-r-08-2024": { - id: "cohere/command-r-08-2024", - name: "Cohere: Command R (08-2024)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.15, - output: 0.6, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 4000, - } satisfies Model<"openai-completions">, "cohere/command-r-plus-08-2024": { id: "cohere/command-r-plus-08-2024", name: "Cohere: Command R+ (08-2024)", @@ -5187,6 +5170,23 @@ export const MODELS = { contextWindow: 128000, maxTokens: 4000, } satisfies Model<"openai-completions">, + "cohere/command-r-08-2024": { + id: "cohere/command-r-08-2024", + name: "Cohere: Command R (08-2024)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.15, + output: 0.6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4000, + } satisfies Model<"openai-completions">, "sao10k/l3.1-euryale-70b": { id: "sao10k/l3.1-euryale-70b", name: "Sao10K: Llama 3.1 Euryale 70B v2.2", @@ -5238,23 +5238,6 @@ export const MODELS = { contextWindow: 128000, maxTokens: 16384, } satisfies Model<"openai-completions">, - "meta-llama/llama-3.1-8b-instruct": { - id: "meta-llama/llama-3.1-8b-instruct", - name: "Meta: Llama 3.1 8B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.02, - output: 0.03, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 16384, - } satisfies Model<"openai-completions">, "meta-llama/llama-3.1-405b-instruct": { id: "meta-llama/llama-3.1-405b-instruct", name: "Meta: Llama 3.1 405B Instruct", @@ -5272,6 +5255,23 @@ export const MODELS = { contextWindow: 130815, maxTokens: 4096, } satisfies Model<"openai-completions">, + "meta-llama/llama-3.1-8b-instruct": { + id: "meta-llama/llama-3.1-8b-instruct", + name: "Meta: Llama 3.1 8B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.02, + output: 0.03, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 16384, + } satisfies Model<"openai-completions">, "meta-llama/llama-3.1-70b-instruct": { id: "meta-llama/llama-3.1-70b-instruct", name: "Meta: Llama 3.1 70B Instruct", diff --git a/packages/tui/test/wrap-ansi.test.ts b/packages/tui/test/wrap-ansi.test.ts index 03b933ae..d7acb47c 100644 --- a/packages/tui/test/wrap-ansi.test.ts +++ b/packages/tui/test/wrap-ansi.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from "vitest"; +import assert from "node:assert"; +import { describe, it } from "node:test"; import { visibleWidth, wrapTextWithAnsi } from "../src/utils.js"; describe("wrapTextWithAnsi", () => { @@ -12,11 +13,11 @@ describe("wrapTextWithAnsi", () => { const wrapped = wrapTextWithAnsi(text, 40); // First line should NOT contain underline code - it's just "read this thread " - expect(wrapped[0]).toBe("read this thread "); + assert.strictEqual(wrapped[0], "read this thread "); // Second line should start with underline, have URL content - expect(wrapped[1].startsWith(underlineOn)).toBe(true); - expect(wrapped[1]).toContain("https://"); + assert.strictEqual(wrapped[1].startsWith(underlineOn), true); + assert.ok(wrapped[1].includes("https://")); }); it("should not bleed underline to padding - each line should end with reset for underline only", () => { @@ -33,8 +34,8 @@ describe("wrapTextWithAnsi", () => { const line = wrapped[i]; if (line.includes(underlineOn)) { // Should end with underline off, NOT full reset - expect(line.endsWith(underlineOff)).toBe(true); - expect(line.endsWith("\x1b[0m")).toBe(false); + assert.strictEqual(line.endsWith(underlineOff), true); + assert.strictEqual(line.endsWith("\x1b[0m"), false); } } }); @@ -50,12 +51,12 @@ describe("wrapTextWithAnsi", () => { // Each line should have background color for (const line of wrapped) { - expect(line.includes(bgBlue)).toBe(true); + assert.ok(line.includes(bgBlue)); } // Middle lines should NOT end with full reset (kills background for padding) for (let i = 0; i < wrapped.length - 1; i++) { - expect(wrapped[i].endsWith("\x1b[0m")).toBe(false); + assert.strictEqual(wrapped[i].endsWith("\x1b[0m"), false); } }); @@ -68,15 +69,10 @@ describe("wrapTextWithAnsi", () => { const wrapped = wrapTextWithAnsi(text, 20); - console.log("Wrapped lines:"); - for (let i = 0; i < wrapped.length; i++) { - console.log(` [${i}]: ${JSON.stringify(wrapped[i])}`); - } - // All lines should have background color 41 (either as \x1b[41m or combined like \x1b[4;41m) for (const line of wrapped) { const hasBgColor = line.includes("[41m") || line.includes(";41m") || line.includes("[41;"); - expect(hasBgColor).toBe(true); + assert.ok(hasBgColor); } // Lines with underlined content should use underline-off at end, not full reset @@ -87,8 +83,8 @@ describe("wrapTextWithAnsi", () => { (line.includes("[4m") || line.includes("[4;") || line.includes(";4m")) && !line.includes(underlineOff) ) { - expect(line.endsWith(underlineOff)).toBe(true); - expect(line.endsWith("\x1b[0m")).toBe(false); + assert.strictEqual(line.endsWith(underlineOff), true); + assert.strictEqual(line.endsWith("\x1b[0m"), false); } } }); @@ -99,10 +95,10 @@ describe("wrapTextWithAnsi", () => { const text = "hello world this is a test"; const wrapped = wrapTextWithAnsi(text, 10); - expect(wrapped.length).toBeGreaterThan(1); - wrapped.forEach((line) => { - expect(visibleWidth(line)).toBeLessThanOrEqual(10); - }); + assert.ok(wrapped.length > 1); + for (const line of wrapped) { + assert.ok(visibleWidth(line) <= 10); + } }); it("should preserve color codes across wraps", () => { @@ -114,12 +110,12 @@ describe("wrapTextWithAnsi", () => { // Each continuation line should start with red code for (let i = 1; i < wrapped.length; i++) { - expect(wrapped[i].startsWith(red)).toBe(true); + assert.strictEqual(wrapped[i].startsWith(red), true); } // Middle lines should not end with full reset for (let i = 0; i < wrapped.length - 1; i++) { - expect(wrapped[i].endsWith("\x1b[0m")).toBe(false); + assert.strictEqual(wrapped[i].endsWith("\x1b[0m"), false); } }); }); From fa77ef8b6ae199551ddd8b8b96dd9cc438424660 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 6 Dec 2025 08:56:31 +0000 Subject: [PATCH 35/54] docs: fix mini-lit links (#123) --- packages/web-ui/README.md | 2 +- packages/web-ui/example/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/web-ui/README.md b/packages/web-ui/README.md index 6b70877d..fc459bd0 100644 --- a/packages/web-ui/README.md +++ b/packages/web-ui/README.md @@ -2,7 +2,7 @@ Reusable web UI components for building AI chat interfaces powered by [@mariozechner/pi-ai](../ai). -Built with [mini-lit](https://github.com/mariozechner/mini-lit) web components and Tailwind CSS v4. + Built with [mini-lit](https://github.com/badlogic/mini-lit) web components and Tailwind CSS v4. ## Features diff --git a/packages/web-ui/example/README.md b/packages/web-ui/example/README.md index 5fad0572..475c3085 100644 --- a/packages/web-ui/example/README.md +++ b/packages/web-ui/example/README.md @@ -58,4 +58,4 @@ example/ - [Pi Web UI Documentation](../README.md) - [Pi AI Documentation](../../ai/README.md) -- [Mini Lit Documentation](https://github.com/mariozechner/mini-lit) +- [Mini Lit Documentation](https://github.com/badlogic/mini-lit) From 10a1e1ef909acb00acf2c46934441dc2b4839a97 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 6 Dec 2025 14:52:51 +0100 Subject: [PATCH 36/54] docs: add under-compaction analysis Documents context window overflow scenarios, how OpenCode and Codex handle them, and what fixes are needed. Related to #128 --- packages/coding-agent/docs/undercompaction.md | 313 ++++++++++++++++++ 1 file changed, 313 insertions(+) create mode 100644 packages/coding-agent/docs/undercompaction.md diff --git a/packages/coding-agent/docs/undercompaction.md b/packages/coding-agent/docs/undercompaction.md new file mode 100644 index 00000000..3de29719 --- /dev/null +++ b/packages/coding-agent/docs/undercompaction.md @@ -0,0 +1,313 @@ +# Under-Compaction Analysis + +## Problem Statement + +Auto-compaction triggers too late, causing context window overflows that result in failed LLM calls with `stopReason == "length"`. + +## Architecture Overview + +### Event Flow + +``` +User prompt + │ + ▼ +agent.prompt() + │ + ▼ +agentLoop() in packages/ai/src/agent/agent-loop.ts + │ + ├─► streamAssistantResponse() + │ │ + │ ▼ + │ LLM provider (Anthropic, OpenAI, etc.) + │ │ + │ ▼ + │ Events: message_start → message_update* → message_end + │ │ + │ ▼ + │ AssistantMessage with usage stats (input, output, cacheRead, cacheWrite) + │ + ├─► If assistant has tool calls: + │ │ + │ ▼ + │ executeToolCalls() + │ │ + │ ├─► tool_execution_start (toolCallId, toolName, args) + │ │ + │ ├─► tool.execute() runs (read, bash, write, edit, etc.) + │ │ + │ ├─► tool_execution_end (toolCallId, toolName, result, isError) + │ │ + │ └─► message_start + message_end for ToolResultMessage + │ + └─► Loop continues until no more tool calls + │ + ▼ + agent_end +``` + +### Token Usage Reporting + +Token usage is ONLY available in `AssistantMessage.usage` after the LLM responds: + +```typescript +// From packages/ai/src/types.ts +export interface Usage { + input: number; // Tokens in the request + output: number; // Tokens generated + cacheRead: number; // Cached tokens read + cacheWrite: number; // Cached tokens written + cost: Cost; +} +``` + +The `input` field represents the total context size sent to the LLM, which includes: +- System prompt +- All conversation messages +- All tool results from previous calls + +### Current Compaction Check + +Both TUI (`tui-renderer.ts`) and RPC (`main.ts`) modes check compaction identically: + +```typescript +// In agent.subscribe() callback: +if (event.type === "message_end") { + // ... + if (event.message.role === "assistant") { + await checkAutoCompaction(); + } +} + +async function checkAutoCompaction() { + // Get last non-aborted assistant message + const messages = agent.state.messages; + let lastAssistant = findLastNonAbortedAssistant(messages); + if (!lastAssistant) return; + + const contextTokens = calculateContextTokens(lastAssistant.usage); + const contextWindow = agent.state.model.contextWindow; + + if (!shouldCompact(contextTokens, contextWindow, settings)) return; + + // Trigger compaction... +} +``` + +**The check happens on `message_end` for assistant messages only.** + +## The Under-Compaction Problem + +### Failure Scenario + +``` +Context window: 200,000 tokens +Reserve tokens: 16,384 (default) +Threshold: 200,000 - 16,384 = 183,616 + +Turn N: + 1. Assistant message received, usage shows 180,000 tokens + 2. shouldCompact(180000, 200000, settings) → 180000 > 183616 → FALSE + 3. Tool executes: `cat large-file.txt` → outputs 100KB (~25,000 tokens) + 4. Context now effectively 205,000 tokens, but we don't know this + 5. Next LLM call fails: context exceeds 200,000 window +``` + +The problem occurs when: +1. Context is below threshold (so compaction doesn't trigger) +2. A tool adds enough content to push it over the window limit +3. We only discover this when the next LLM call fails + +### Root Cause + +1. **Token counts are retrospective**: We only learn the context size AFTER the LLM processes it +2. **Tool results are blind spots**: When a tool executes and returns a large result, we don't know how many tokens it adds until the next LLM call +3. **No estimation before submission**: We submit the context and hope it fits + +## Current Tool Output Limits + +| Tool | Our Limit | Worst Case | +|------|-----------|------------| +| bash | 10MB per stream | 20MB (~5M tokens) | +| read | 2000 lines × 2000 chars | 4MB (~1M tokens) | +| write | Byte count only | Minimal | +| edit | Diff output | Variable | + +## How Other Tools Handle This + +### SST/OpenCode + +**Tool Output Limits (during execution):** + +| Tool | Limit | Details | +|------|-------|---------| +| bash | 30KB chars | `MAX_OUTPUT_LENGTH = 30_000`, truncates with notice | +| read | 2000 lines × 2000 chars/line | No total cap, theoretically 4MB | +| grep | 100 matches, 2000 chars/line | Truncates with notice | +| ls | 100 files | Truncates with notice | +| glob | 100 results | Truncates with notice | +| webfetch | 5MB | `MAX_RESPONSE_SIZE` | + +**Overflow Detection:** +- `isOverflow()` runs BEFORE each turn (not during) +- Uses last LLM-reported token count: `tokens.input + tokens.cache.read + tokens.output` +- Triggers if `count > context - maxOutput` +- Does NOT detect overflow from tool results in current turn + +**Recovery - Pruning:** +- `prune()` runs AFTER each turn completes +- Walks backwards through completed tool results +- Keeps last 40k tokens of tool outputs (`PRUNE_PROTECT`) +- Removes content from older tool results (marks `time.compacted`) +- Only prunes if savings > 20k tokens (`PRUNE_MINIMUM`) +- Token estimation: `chars / 4` + +**Recovery - Compaction:** +- Triggered when `isOverflow()` returns true before a turn +- LLM generates summary of conversation +- Replaces old messages with summary + +**Gap:** No mid-turn protection. A single read returning 4MB would overflow. The 30KB bash limit is their primary practical protection. + +### OpenAI/Codex + +**Tool Output Limits (during execution):** + +| Tool | Limit | Details | +|------|-------|---------| +| shell/exec | 10k tokens or 10k bytes | Per-model `TruncationPolicy`, user-configurable | +| read_file | 2000 lines, 500 chars/line | `MAX_LINE_LENGTH = 500`, ~1MB max | +| grep_files | 100 matches | Default limit | +| list_dir | Configurable | BFS with depth limits | + +**Truncation Policy:** +- Per-model family setting: `TruncationPolicy::Bytes(10_000)` or `TruncationPolicy::Tokens(10_000)` +- User can override via `tool_output_token_limit` config +- Applied to ALL tool outputs uniformly via `truncate_function_output_items_with_policy()` +- Preserves beginning and end, removes middle with `"…N tokens truncated…"` marker + +**Overflow Detection:** +- After each successful turn: `if total_usage_tokens >= auto_compact_token_limit { compact() }` +- Per-model thresholds (e.g., 180k for 200k context window) +- `ContextWindowExceeded` error caught and handled + +**Recovery - Compaction:** +- If tokens exceed threshold after turn, triggers `run_inline_auto_compact_task()` +- During compaction, if `ContextWindowExceeded`: removes oldest history item and retries +- Loop: `history.remove_first_item()` until it fits +- Notifies user: "Trimmed N older conversation item(s)" + +**Recovery - Turn Error:** +- On `ContextWindowExceeded` during normal turn: marks tokens as full, returns error to user +- Does NOT auto-retry the failed turn +- User must manually continue + +**Gap:** Still no mid-turn protection, but aggressive 10k token truncation on all tool outputs prevents most issues in practice. + +### Comparison + +| Feature | pi-coding-agent | OpenCode | Codex | +|---------|-----------------|----------|-------| +| Bash limit | 10MB | 30KB | ~40KB (10k tokens) | +| Read limit | 2000×2000 (4MB) | 2000×2000 (4MB) | 2000×500 (1MB) | +| Truncation policy | None | Per-tool | Per-model, uniform | +| Token estimation | None | chars/4 | chars/4 | +| Pre-turn check | No | Yes (last tokens) | Yes (threshold) | +| Mid-turn check | No | No | No | +| Post-turn pruning | No | Yes (removes old tool output) | No | +| Overflow recovery | No | Compaction | Trim oldest + compact | + +**Key insight:** None of these tools protect against mid-turn overflow. Their practical protection is aggressive static limits on tool output, especially bash. OpenCode's 30KB bash limit vs our 10MB is the critical difference. + +## Recommended Solution + +### Phase 1: Static Limits (immediate) + +Add hard limits to tool outputs matching industry practice: + +```typescript +// packages/coding-agent/src/tools/limits.ts +export const MAX_TOOL_OUTPUT_CHARS = 30_000; // ~7.5k tokens, matches OpenCode bash +export const MAX_TOOL_OUTPUT_NOTICE = "\n\n...(truncated, output exceeded limit)..."; +``` + +Apply to all tools: +- bash: 10MB → 30KB +- read: Add 100KB total output cap +- edit: Cap diff output + +### Phase 2: Post-Tool Estimation + +After `tool_execution_end`, estimate and flag: + +```typescript +let needsCompactionAfterTurn = false; + +agent.subscribe(async (event) => { + if (event.type === "tool_execution_end") { + const resultChars = extractTextLength(event.result); + const estimatedTokens = Math.ceil(resultChars / 4); + + const lastUsage = getLastAssistantUsage(agent.state.messages); + if (lastUsage) { + const current = calculateContextTokens(lastUsage); + const projected = current + estimatedTokens; + const threshold = agent.state.model.contextWindow - settings.reserveTokens; + if (projected > threshold) { + needsCompactionAfterTurn = true; + } + } + } + + if (event.type === "turn_end" && needsCompactionAfterTurn) { + needsCompactionAfterTurn = false; + await triggerCompaction(); + } +}); +``` + +### Phase 3: Overflow Recovery (like Codex) + +Handle `stopReason === "length"` gracefully: + +```typescript +if (event.type === "message_end" && event.message.role === "assistant") { + if (event.message.stopReason === "length") { + // Context overflow occurred + await triggerCompaction(); + // Optionally: retry the turn + } +} +``` + +During compaction, if it also overflows, trim oldest messages: + +```typescript +async function compactWithRetry() { + while (true) { + try { + await compact(); + break; + } catch (e) { + if (isContextOverflow(e) && messages.length > 1) { + messages.shift(); // Remove oldest + continue; + } + throw e; + } + } +} +``` + +## Summary + +The under-compaction problem occurs because: +1. We only check context size after assistant messages +2. Tool results can add arbitrary amounts of content +3. We discover overflows only when the next LLM call fails + +The fix requires: +1. Aggressive static limits on tool output (immediate safety net) +2. Token estimation after tool execution (proactive detection) +3. Graceful handling of overflow errors (fallback recovery) From a325c1c7d1dd89a4cd8f786146744f2914f87ecf Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 6 Dec 2025 21:24:15 +0100 Subject: [PATCH 37/54] Add context overflow detection utilities Extract overflow detection logic into reusable utilities: - isContextOverflowError() to detect overflow from error messages - isContextOverflowFromUsage() to detect overflow from token usage - Patterns for Anthropic, OpenAI, Google, xAI, Groq, OpenRouter, llama.cpp, LM Studio Fixes #129 --- packages/ai/src/index.ts | 1 + packages/ai/src/utils/overflow.ts | 110 +++++ packages/ai/test/context-overflow.test.ts | 464 ++++++++++++++++++++++ 3 files changed, 575 insertions(+) create mode 100644 packages/ai/src/utils/overflow.ts create mode 100644 packages/ai/test/context-overflow.test.ts diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 1689d0ef..874686b7 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -6,4 +6,5 @@ export * from "./providers/openai-completions.js"; export * from "./providers/openai-responses.js"; export * from "./stream.js"; export * from "./types.js"; +export * from "./utils/overflow.js"; export * from "./utils/typebox-helpers.js"; diff --git a/packages/ai/src/utils/overflow.ts b/packages/ai/src/utils/overflow.ts new file mode 100644 index 00000000..c7a858fc --- /dev/null +++ b/packages/ai/src/utils/overflow.ts @@ -0,0 +1,110 @@ +import type { AssistantMessage } from "../types.js"; + +/** + * Regex patterns to detect context overflow errors from different providers. + * + * These patterns match error messages returned when the input exceeds + * the model's context window. + * + * Provider-specific patterns (with example error messages): + * + * - Anthropic: "prompt is too long: 213462 tokens > 200000 maximum" + * - OpenAI: "Your input exceeds the context window of this model" + * - Google: "The input token count (1196265) exceeds the maximum number of tokens allowed (1048575)" + * - xAI: "This model's maximum prompt length is 131072 but the request contains 537812 tokens" + * - Groq: "Please reduce the length of the messages or completion" + * - OpenRouter: "This endpoint's maximum context length is X tokens. However, you requested about Y tokens" + * - llama.cpp: "the request exceeds the available context size, try increasing it" + * - LM Studio: "tokens to keep from the initial prompt is greater than the context length" + * - Cerebras: Returns "400 status code (no body)" - handled separately below + * - z.ai: Does NOT error, accepts overflow silently - handled via usage.input > contextWindow + * - Ollama: Silently truncates input - not detectable via error message + */ +const OVERFLOW_PATTERNS = [ + /prompt is too long/i, // Anthropic + /exceeds the context window/i, // OpenAI (Completions & Responses API) + /input token count.*exceeds the maximum/i, // Google (Gemini) + /maximum prompt length is \d+/i, // xAI (Grok) + /reduce the length of the messages/i, // Groq + /maximum context length is \d+ tokens/i, // OpenRouter (all backends) + /exceeds the available context size/i, // llama.cpp server + /greater than the context length/i, // LM Studio + /context length exceeded/i, // Generic fallback + /too many tokens/i, // Generic fallback + /token limit exceeded/i, // Generic fallback +]; + +/** + * Check if an assistant message represents a context overflow error. + * + * This handles two cases: + * 1. Error-based overflow: Most providers return stopReason "error" with a + * specific error message pattern. + * 2. Silent overflow: Some providers accept overflow requests and return + * successfully. For these, we check if usage.input exceeds the context window. + * + * ## Reliability by Provider + * + * **Reliable detection (returns error with detectable message):** + * - Anthropic: "prompt is too long: X tokens > Y maximum" + * - OpenAI (Completions & Responses): "exceeds the context window" + * - Google Gemini: "input token count exceeds the maximum" + * - xAI (Grok): "maximum prompt length is X but request contains Y" + * - Groq: "reduce the length of the messages" + * - Cerebras: 400/413 status code (no body) + * - OpenRouter (all backends): "maximum context length is X tokens" + * - llama.cpp: "exceeds the available context size" + * - LM Studio: "greater than the context length" + * + * **Unreliable detection:** + * - z.ai: Sometimes accepts overflow silently (detectable via usage.input > contextWindow), + * sometimes returns rate limit errors. Pass contextWindow param to detect silent overflow. + * - Ollama: Silently truncates input without error. Cannot be detected via this function. + * The response will have usage.input < expected, but we don't know the expected value. + * + * ## Custom Providers + * + * If you've added custom models via settings.json, this function may not detect + * overflow errors from those providers. To add support: + * + * 1. Send a request that exceeds the model's context window + * 2. Check the errorMessage in the response + * 3. Create a regex pattern that matches the error + * 4. The pattern should be added to OVERFLOW_PATTERNS in this file, or + * check the errorMessage yourself before calling this function + * + * @param message - The assistant message to check + * @param contextWindow - Optional context window size for detecting silent overflow (z.ai) + * @returns true if the message indicates a context overflow + */ +export function isContextOverflow(message: AssistantMessage, contextWindow?: number): boolean { + // Case 1: Check error message patterns + if (message.stopReason === "error" && message.errorMessage) { + // Check known patterns + if (OVERFLOW_PATTERNS.some((p) => p.test(message.errorMessage!))) { + return true; + } + + // Cerebras returns 400/413 with no body - check for status code pattern + if (/^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message.errorMessage)) { + return true; + } + } + + // Case 2: Silent overflow (z.ai style) - successful but usage exceeds context + if (contextWindow && message.stopReason === "stop") { + const inputTokens = message.usage.input + message.usage.cacheRead; + if (inputTokens > contextWindow) { + return true; + } + } + + return false; +} + +/** + * Get the overflow patterns for testing purposes. + */ +export function getOverflowPatterns(): RegExp[] { + return [...OVERFLOW_PATTERNS]; +} diff --git a/packages/ai/test/context-overflow.test.ts b/packages/ai/test/context-overflow.test.ts new file mode 100644 index 00000000..94f3e4e0 --- /dev/null +++ b/packages/ai/test/context-overflow.test.ts @@ -0,0 +1,464 @@ +/** + * Test context overflow error handling across providers. + * + * Context overflow occurs when the input (prompt + history) exceeds + * the model's context window. This is different from output token limits. + * + * Expected behavior: All providers should return stopReason: "error" + * with an errorMessage that indicates the context was too large, + * OR (for z.ai) return successfully with usage.input > contextWindow. + * + * The isContextOverflow() function must return true for all providers. + */ + +import type { ChildProcess } from "child_process"; +import { execSync, spawn } from "child_process"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { getModel } from "../src/models.js"; +import { complete } from "../src/stream.js"; +import type { AssistantMessage, Context, Model, Usage } from "../src/types.js"; +import { isContextOverflow } from "../src/utils/overflow.js"; + +// Lorem ipsum paragraph for realistic token estimation +const LOREM_IPSUM = `Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. `; + +// Generate a string that will exceed the context window +// Using chars/4 as token estimate (works better with varied text than repeated chars) +function generateOverflowContent(contextWindow: number): string { + const targetTokens = contextWindow + 10000; // Exceed by 10k tokens + const targetChars = targetTokens * 4 * 1.5; + const repetitions = Math.ceil(targetChars / LOREM_IPSUM.length); + return LOREM_IPSUM.repeat(repetitions); +} + +interface OverflowResult { + provider: string; + model: string; + contextWindow: number; + stopReason: string; + errorMessage: string | undefined; + usage: Usage; + hasUsageData: boolean; + response: AssistantMessage; +} + +async function testContextOverflow(model: Model, apiKey: string): Promise { + const overflowContent = generateOverflowContent(model.contextWindow); + + const context: Context = { + systemPrompt: "You are a helpful assistant.", + messages: [ + { + role: "user", + content: overflowContent, + timestamp: Date.now(), + }, + ], + }; + + const response = await complete(model, context, { apiKey }); + + const hasUsageData = response.usage.input > 0 || response.usage.cacheRead > 0; + + return { + provider: model.provider, + model: model.id, + contextWindow: model.contextWindow, + stopReason: response.stopReason, + errorMessage: response.errorMessage, + usage: response.usage, + hasUsageData, + response, + }; +} + +function logResult(result: OverflowResult) { + console.log(`\n${result.provider} / ${result.model}:`); + console.log(` contextWindow: ${result.contextWindow}`); + console.log(` stopReason: ${result.stopReason}`); + console.log(` errorMessage: ${result.errorMessage}`); + console.log(` usage: ${JSON.stringify(result.usage)}`); + console.log(` hasUsageData: ${result.hasUsageData}`); +} + +// ============================================================================= +// Anthropic +// Expected pattern: "prompt is too long: X tokens > Y maximum" +// ============================================================================= + +describe("Context overflow error handling", () => { + describe.skipIf(!process.env.ANTHROPIC_API_KEY)("Anthropic (API Key)", () => { + it("claude-3-5-haiku - should detect overflow via isContextOverflow", async () => { + const model = getModel("anthropic", "claude-3-5-haiku-20241022"); + const result = await testContextOverflow(model, process.env.ANTHROPIC_API_KEY!); + logResult(result); + + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toMatch(/prompt is too long/i); + expect(isContextOverflow(result.response, model.contextWindow)).toBe(true); + }, 120000); + }); + + describe.skipIf(!process.env.ANTHROPIC_OAUTH_TOKEN)("Anthropic (OAuth)", () => { + it("claude-sonnet-4 - should detect overflow via isContextOverflow", async () => { + const model = getModel("anthropic", "claude-sonnet-4-20250514"); + const result = await testContextOverflow(model, process.env.ANTHROPIC_OAUTH_TOKEN!); + logResult(result); + + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toMatch(/prompt is too long/i); + expect(isContextOverflow(result.response, model.contextWindow)).toBe(true); + }, 120000); + }); + + // ============================================================================= + // OpenAI + // Expected pattern: "exceeds the context window" + // ============================================================================= + + describe.skipIf(!process.env.OPENAI_API_KEY)("OpenAI Completions", () => { + it("gpt-4o-mini - should detect overflow via isContextOverflow", async () => { + const model = getModel("openai", "gpt-4o-mini"); + const result = await testContextOverflow(model, process.env.OPENAI_API_KEY!); + logResult(result); + + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toMatch(/exceeds the context window/i); + expect(isContextOverflow(result.response, model.contextWindow)).toBe(true); + }, 120000); + }); + + describe.skipIf(!process.env.OPENAI_API_KEY)("OpenAI Responses", () => { + it("gpt-4o - should detect overflow via isContextOverflow", async () => { + const model = getModel("openai", "gpt-4o"); + const result = await testContextOverflow(model, process.env.OPENAI_API_KEY!); + logResult(result); + + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toMatch(/exceeds the context window/i); + expect(isContextOverflow(result.response, model.contextWindow)).toBe(true); + }, 120000); + }); + + // ============================================================================= + // Google + // Expected pattern: "input token count (X) exceeds the maximum" + // ============================================================================= + + describe.skipIf(!process.env.GEMINI_API_KEY)("Google", () => { + it("gemini-2.0-flash - should detect overflow via isContextOverflow", async () => { + const model = getModel("google", "gemini-2.0-flash"); + const result = await testContextOverflow(model, process.env.GEMINI_API_KEY!); + logResult(result); + + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toMatch(/input token count.*exceeds the maximum/i); + expect(isContextOverflow(result.response, model.contextWindow)).toBe(true); + }, 120000); + }); + + // ============================================================================= + // xAI + // Expected pattern: "maximum prompt length is X but the request contains Y" + // ============================================================================= + + describe.skipIf(!process.env.XAI_API_KEY)("xAI", () => { + it("grok-3-fast - should detect overflow via isContextOverflow", async () => { + const model = getModel("xai", "grok-3-fast"); + const result = await testContextOverflow(model, process.env.XAI_API_KEY!); + logResult(result); + + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toMatch(/maximum prompt length is \d+/i); + expect(isContextOverflow(result.response, model.contextWindow)).toBe(true); + }, 120000); + }); + + // ============================================================================= + // Groq + // Expected pattern: "reduce the length of the messages" + // ============================================================================= + + describe.skipIf(!process.env.GROQ_API_KEY)("Groq", () => { + it("llama-3.3-70b-versatile - should detect overflow via isContextOverflow", async () => { + const model = getModel("groq", "llama-3.3-70b-versatile"); + const result = await testContextOverflow(model, process.env.GROQ_API_KEY!); + logResult(result); + + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toMatch(/reduce the length of the messages/i); + expect(isContextOverflow(result.response, model.contextWindow)).toBe(true); + }, 120000); + }); + + // ============================================================================= + // Cerebras + // Expected: 400/413 status code with no body + // ============================================================================= + + describe.skipIf(!process.env.CEREBRAS_API_KEY)("Cerebras", () => { + it("qwen-3-235b - should detect overflow via isContextOverflow", async () => { + const model = getModel("cerebras", "qwen-3-235b-a22b-instruct-2507"); + const result = await testContextOverflow(model, process.env.CEREBRAS_API_KEY!); + logResult(result); + + expect(result.stopReason).toBe("error"); + // Cerebras returns status code with no body + expect(result.errorMessage).toMatch(/4(00|13).*\(no body\)/i); + expect(isContextOverflow(result.response, model.contextWindow)).toBe(true); + }, 120000); + }); + + // ============================================================================= + // z.ai + // Special case: Sometimes accepts overflow silently, sometimes rate limits + // Detection via usage.input > contextWindow when successful + // ============================================================================= + + describe.skipIf(!process.env.ZAI_API_KEY)("z.ai", () => { + it("glm-4.5-flash - should detect overflow via isContextOverflow (silent overflow or rate limit)", async () => { + const model = getModel("zai", "glm-4.5-flash"); + const result = await testContextOverflow(model, process.env.ZAI_API_KEY!); + logResult(result); + + // z.ai behavior is inconsistent: + // - Sometimes accepts overflow and returns successfully with usage.input > contextWindow + // - Sometimes returns rate limit error + // Either way, isContextOverflow should detect it (via usage check or we skip if rate limited) + if (result.stopReason === "stop") { + expect(result.hasUsageData).toBe(true); + expect(result.usage.input).toBeGreaterThan(model.contextWindow); + expect(isContextOverflow(result.response, model.contextWindow)).toBe(true); + } else { + // Rate limited or other error - just log and pass + console.log(" z.ai returned error (possibly rate limited), skipping overflow detection"); + } + }, 120000); + }); + + // ============================================================================= + // OpenRouter - Multiple backend providers + // Expected pattern: "maximum context length is X tokens" + // ============================================================================= + + describe.skipIf(!process.env.OPENROUTER_API_KEY)("OpenRouter", () => { + // Anthropic backend + it("anthropic/claude-sonnet-4 via OpenRouter - should detect overflow via isContextOverflow", async () => { + const model = getModel("openrouter", "anthropic/claude-sonnet-4"); + const result = await testContextOverflow(model, process.env.OPENROUTER_API_KEY!); + logResult(result); + + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toMatch(/maximum context length is \d+ tokens/i); + expect(isContextOverflow(result.response, model.contextWindow)).toBe(true); + }, 120000); + + // DeepSeek backend + it("deepseek/deepseek-v3.2 via OpenRouter - should detect overflow via isContextOverflow", async () => { + const model = getModel("openrouter", "deepseek/deepseek-v3.2"); + const result = await testContextOverflow(model, process.env.OPENROUTER_API_KEY!); + logResult(result); + + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toMatch(/maximum context length is \d+ tokens/i); + expect(isContextOverflow(result.response, model.contextWindow)).toBe(true); + }, 120000); + + // Mistral backend + it("mistralai/mistral-large-2512 via OpenRouter - should detect overflow via isContextOverflow", async () => { + const model = getModel("openrouter", "mistralai/mistral-large-2512"); + const result = await testContextOverflow(model, process.env.OPENROUTER_API_KEY!); + logResult(result); + + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toMatch(/maximum context length is \d+ tokens/i); + expect(isContextOverflow(result.response, model.contextWindow)).toBe(true); + }, 120000); + + // Google backend + it("google/gemini-2.5-flash via OpenRouter - should detect overflow via isContextOverflow", async () => { + const model = getModel("openrouter", "google/gemini-2.5-flash"); + const result = await testContextOverflow(model, process.env.OPENROUTER_API_KEY!); + logResult(result); + + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toMatch(/maximum context length is \d+ tokens/i); + expect(isContextOverflow(result.response, model.contextWindow)).toBe(true); + }, 120000); + + // Meta/Llama backend + it("meta-llama/llama-4-maverick via OpenRouter - should detect overflow via isContextOverflow", async () => { + const model = getModel("openrouter", "meta-llama/llama-4-maverick"); + const result = await testContextOverflow(model, process.env.OPENROUTER_API_KEY!); + logResult(result); + + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toMatch(/maximum context length is \d+ tokens/i); + expect(isContextOverflow(result.response, model.contextWindow)).toBe(true); + }, 120000); + }); + + // ============================================================================= + // Ollama (local) + // ============================================================================= + + // Check if ollama is installed + let ollamaInstalled = false; + try { + execSync("which ollama", { stdio: "ignore" }); + ollamaInstalled = true; + } catch { + ollamaInstalled = false; + } + + describe.skipIf(!ollamaInstalled)("Ollama (local)", () => { + let ollamaProcess: ChildProcess | null = null; + let model: Model<"openai-completions">; + + beforeAll(async () => { + // Check if model is available, if not pull it + try { + execSync("ollama list | grep -q 'gpt-oss:20b'", { stdio: "ignore" }); + } catch { + console.log("Pulling gpt-oss:20b model for Ollama overflow tests..."); + try { + execSync("ollama pull gpt-oss:20b", { stdio: "inherit" }); + } catch (e) { + console.warn("Failed to pull gpt-oss:20b model, tests will be skipped"); + return; + } + } + + // Start ollama server + ollamaProcess = spawn("ollama", ["serve"], { + detached: false, + stdio: "ignore", + }); + + // Wait for server to be ready + await new Promise((resolve) => { + const checkServer = async () => { + try { + const response = await fetch("http://localhost:11434/api/tags"); + if (response.ok) { + resolve(); + } else { + setTimeout(checkServer, 500); + } + } catch { + setTimeout(checkServer, 500); + } + }; + setTimeout(checkServer, 1000); + }); + + model = { + id: "gpt-oss:20b", + api: "openai-completions", + provider: "ollama", + baseUrl: "http://localhost:11434/v1", + reasoning: true, + input: ["text"], + contextWindow: 128000, + maxTokens: 16000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + name: "Ollama GPT-OSS 20B", + }; + }, 60000); + + afterAll(() => { + if (ollamaProcess) { + ollamaProcess.kill("SIGTERM"); + ollamaProcess = null; + } + }); + + it("gpt-oss:20b - should detect overflow via isContextOverflow (ollama silently truncates)", async () => { + const result = await testContextOverflow(model, "ollama"); + logResult(result); + + // Ollama silently truncates input instead of erroring + // It returns stopReason "stop" with truncated usage + // We cannot detect overflow via error message, only via usage comparison + if (result.stopReason === "stop" && result.hasUsageData) { + // Ollama truncated - check if reported usage is less than what we sent + // This is a "silent overflow" - we can detect it if we know expected input size + console.log(" Ollama silently truncated input to", result.usage.input, "tokens"); + // For now, we accept this behavior - Ollama doesn't give us a way to detect overflow + } else if (result.stopReason === "error") { + expect(isContextOverflow(result.response, model.contextWindow)).toBe(true); + } + }, 300000); // 5 min timeout for local model + }); + + // ============================================================================= + // LM Studio (local) - Skip if not running + // ============================================================================= + + let lmStudioRunning = false; + try { + execSync("curl -s --max-time 1 http://localhost:1234/v1/models > /dev/null", { stdio: "ignore" }); + lmStudioRunning = true; + } catch { + lmStudioRunning = false; + } + + describe.skipIf(!lmStudioRunning)("LM Studio (local)", () => { + it("should detect overflow via isContextOverflow", async () => { + const model: Model<"openai-completions"> = { + id: "local-model", + api: "openai-completions", + provider: "lm-studio", + baseUrl: "http://localhost:1234/v1", + reasoning: false, + input: ["text"], + contextWindow: 8192, + maxTokens: 2048, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + name: "LM Studio Local Model", + }; + + const result = await testContextOverflow(model, "lm-studio"); + logResult(result); + + expect(result.stopReason).toBe("error"); + expect(isContextOverflow(result.response, model.contextWindow)).toBe(true); + }, 120000); + }); + + // ============================================================================= + // llama.cpp server (local) - Skip if not running + // ============================================================================= + + let llamaCppRunning = false; + try { + execSync("curl -s --max-time 1 http://localhost:8081/health > /dev/null", { stdio: "ignore" }); + llamaCppRunning = true; + } catch { + llamaCppRunning = false; + } + + describe.skipIf(!llamaCppRunning)("llama.cpp (local)", () => { + it("should detect overflow via isContextOverflow", async () => { + // Using small context (4096) to match server --ctx-size setting + const model: Model<"openai-completions"> = { + id: "local-model", + api: "openai-completions", + provider: "llama.cpp", + baseUrl: "http://localhost:8081/v1", + reasoning: false, + input: ["text"], + contextWindow: 4096, + maxTokens: 2048, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + name: "llama.cpp Local Model", + }; + + const result = await testContextOverflow(model, "llama.cpp"); + logResult(result); + + expect(result.stopReason).toBe("error"); + expect(isContextOverflow(result.response, model.contextWindow)).toBe(true); + }, 120000); + }); +}); From d7f84469a7f74c4accbef6204282f55e6fa4ac66 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 6 Dec 2025 21:24:26 +0100 Subject: [PATCH 38/54] Fix editor crash with wide characters (emojis, CJK) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Editor text wrapping now uses grapheme-aware width calculation instead of string length. Fixes crash when pasting text containing emojis like ✅ or CJK characters that are 2 terminal columns wide. --- packages/coding-agent/CHANGELOG.md | 4 + packages/tui/src/components/editor.ts | 140 +++++++++++++++++++------- packages/tui/test/editor.test.ts | 103 +++++++++++++++++++ 3 files changed, 212 insertions(+), 35 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ccb7eb39..653f8b47 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- **Editor crash with emojis/CJK characters**: Fixed crash when pasting or typing text containing wide characters (emojis like ✅, CJK characters) that caused line width to exceed terminal width. The editor now uses grapheme-aware text wrapping with proper visible width calculation. + ## [0.12.14] - 2025-12-06 ### Added diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 0bf06d41..9f5ee2aa 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -1,7 +1,11 @@ import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete.js"; import type { Component } from "../tui.js"; +import { visibleWidth } from "../utils.js"; import { SelectList, type SelectListTheme } from "./select-list.js"; +// Grapheme segmenter for proper Unicode iteration (handles emojis, etc.) +const segmenter = new Intl.Segmenter(); + interface EditorState { lines: string[]; cursorLine: number; @@ -146,7 +150,7 @@ export class Editor implements Component { // Render each layout line for (const layoutLine of layoutLines) { let displayText = layoutLine.text; - let visibleLength = layoutLine.text.length; + let lineVisibleWidth = visibleWidth(layoutLine.text); // Add cursor if this line has it if (layoutLine.hasCursor && layoutLine.cursorPos !== undefined) { @@ -154,34 +158,43 @@ export class Editor implements Component { const after = displayText.slice(layoutLine.cursorPos); if (after.length > 0) { - // Cursor is on a character - replace it with highlighted version - const cursor = `\x1b[7m${after[0]}\x1b[0m`; - const restAfter = after.slice(1); + // Cursor is on a character (grapheme) - replace it with highlighted version + // Get the first grapheme from 'after' + const afterGraphemes = [...segmenter.segment(after)]; + const firstGrapheme = afterGraphemes[0]?.segment || ""; + const restAfter = after.slice(firstGrapheme.length); + const cursor = `\x1b[7m${firstGrapheme}\x1b[0m`; displayText = before + cursor + restAfter; - // visibleLength stays the same - we're replacing, not adding + // lineVisibleWidth stays the same - we're replacing, not adding } else { // Cursor is at the end - check if we have room for the space - if (layoutLine.text.length < width) { + if (lineVisibleWidth < width) { // We have room - add highlighted space const cursor = "\x1b[7m \x1b[0m"; displayText = before + cursor; - // visibleLength increases by 1 - we're adding a space - visibleLength = layoutLine.text.length + 1; + // lineVisibleWidth increases by 1 - we're adding a space + lineVisibleWidth = lineVisibleWidth + 1; } else { - // Line is at full width - use reverse video on last character if possible + // Line is at full width - use reverse video on last grapheme if possible // or just show cursor at the end without adding space - if (before.length > 0) { - const lastChar = before[before.length - 1]; - const cursor = `\x1b[7m${lastChar}\x1b[0m`; - displayText = before.slice(0, -1) + cursor; + const beforeGraphemes = [...segmenter.segment(before)]; + if (beforeGraphemes.length > 0) { + const lastGrapheme = beforeGraphemes[beforeGraphemes.length - 1]?.segment || ""; + const cursor = `\x1b[7m${lastGrapheme}\x1b[0m`; + // Rebuild 'before' without the last grapheme + const beforeWithoutLast = beforeGraphemes + .slice(0, -1) + .map((g) => g.segment) + .join(""); + displayText = beforeWithoutLast + cursor; } - // visibleLength stays the same + // lineVisibleWidth stays the same } } } - // Calculate padding based on actual visible length - const padding = " ".repeat(Math.max(0, width - visibleLength)); + // Calculate padding based on actual visible width + const padding = " ".repeat(Math.max(0, width - lineVisibleWidth)); // Render the line (no side borders, just horizontal lines above and below) result.push(displayText + padding); @@ -493,9 +506,9 @@ export class Editor implements Component { for (let i = 0; i < this.state.lines.length; i++) { const line = this.state.lines[i] || ""; const isCurrentLine = i === this.state.cursorLine; - const maxLineLength = contentWidth; + const lineVisibleWidth = visibleWidth(line); - if (line.length <= maxLineLength) { + if (lineVisibleWidth <= contentWidth) { // Line fits in one layout line if (isCurrentLine) { layoutLines.push({ @@ -510,35 +523,64 @@ export class Editor implements Component { }); } } else { - // Line needs wrapping - const chunks = []; - for (let pos = 0; pos < line.length; pos += maxLineLength) { - chunks.push(line.slice(pos, pos + maxLineLength)); + // Line needs wrapping - use grapheme-aware chunking + const chunks: { text: string; startIndex: number; endIndex: number }[] = []; + let currentChunk = ""; + let currentWidth = 0; + let chunkStartIndex = 0; + let currentIndex = 0; + + for (const seg of segmenter.segment(line)) { + const grapheme = seg.segment; + const graphemeWidth = visibleWidth(grapheme); + + if (currentWidth + graphemeWidth > contentWidth && currentChunk !== "") { + // Start a new chunk + chunks.push({ + text: currentChunk, + startIndex: chunkStartIndex, + endIndex: currentIndex, + }); + currentChunk = grapheme; + currentWidth = graphemeWidth; + chunkStartIndex = currentIndex; + } else { + currentChunk += grapheme; + currentWidth += graphemeWidth; + } + currentIndex += grapheme.length; + } + + // Push the last chunk + if (currentChunk !== "") { + chunks.push({ + text: currentChunk, + startIndex: chunkStartIndex, + endIndex: currentIndex, + }); } for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) { const chunk = chunks[chunkIndex]; if (!chunk) continue; - const chunkStart = chunkIndex * maxLineLength; - const chunkEnd = chunkStart + chunk.length; const cursorPos = this.state.cursorCol; const isLastChunk = chunkIndex === chunks.length - 1; - // For non-last chunks, cursor at chunkEnd belongs to the next chunk + // For non-last chunks, cursor at endIndex belongs to the next chunk const hasCursorInChunk = isCurrentLine && - cursorPos >= chunkStart && - (isLastChunk ? cursorPos <= chunkEnd : cursorPos < chunkEnd); + cursorPos >= chunk.startIndex && + (isLastChunk ? cursorPos <= chunk.endIndex : cursorPos < chunk.endIndex); if (hasCursorInChunk) { layoutLines.push({ - text: chunk, + text: chunk.text, hasCursor: true, - cursorPos: cursorPos - chunkStart, + cursorPos: cursorPos - chunk.startIndex, }); } else { layoutLines.push({ - text: chunk, + text: chunk.text, hasCursor: false, }); } @@ -917,16 +959,44 @@ export class Editor implements Component { for (let i = 0; i < this.state.lines.length; i++) { const line = this.state.lines[i] || ""; + const lineVisWidth = visibleWidth(line); if (line.length === 0) { // Empty line still takes one visual line visualLines.push({ logicalLine: i, startCol: 0, length: 0 }); - } else if (line.length <= width) { + } else if (lineVisWidth <= width) { visualLines.push({ logicalLine: i, startCol: 0, length: line.length }); } else { - // Line needs wrapping - for (let pos = 0; pos < line.length; pos += width) { - const segmentLength = Math.min(width, line.length - pos); - visualLines.push({ logicalLine: i, startCol: pos, length: segmentLength }); + // Line needs wrapping - use grapheme-aware chunking + let currentWidth = 0; + let chunkStartIndex = 0; + let currentIndex = 0; + + for (const seg of segmenter.segment(line)) { + const grapheme = seg.segment; + const graphemeWidth = visibleWidth(grapheme); + + if (currentWidth + graphemeWidth > width && currentIndex > chunkStartIndex) { + // Start a new chunk + visualLines.push({ + logicalLine: i, + startCol: chunkStartIndex, + length: currentIndex - chunkStartIndex, + }); + chunkStartIndex = currentIndex; + currentWidth = graphemeWidth; + } else { + currentWidth += graphemeWidth; + } + currentIndex += grapheme.length; + } + + // Push the last chunk + if (currentIndex > chunkStartIndex) { + visualLines.push({ + logicalLine: i, + startCol: chunkStartIndex, + length: currentIndex - chunkStartIndex, + }); } } } diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index 2df17051..d0b3d2f7 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -1,6 +1,8 @@ import assert from "node:assert"; import { describe, it } from "node:test"; +import { stripVTControlCharacters } from "node:util"; import { Editor } from "../src/components/editor.js"; +import { visibleWidth } from "../src/utils.js"; import { defaultEditorTheme } from "./test-themes.js"; describe("Editor component", () => { @@ -370,4 +372,105 @@ describe("Editor component", () => { assert.strictEqual(text, "xab"); }); }); + + describe("Grapheme-aware text wrapping", () => { + it("wraps lines correctly when text contains wide emojis", () => { + const editor = new Editor(defaultEditorTheme); + const width = 20; + + // ✅ is 2 columns wide, so "Hello ✅ World" is 14 columns + editor.setText("Hello ✅ World"); + const lines = editor.render(width); + + // All content lines (between borders) should fit within width + for (let i = 1; i < lines.length - 1; i++) { + const lineWidth = visibleWidth(lines[i]!); + assert.strictEqual(lineWidth, width, `Line ${i} has width ${lineWidth}, expected ${width}`); + } + }); + + it("wraps long text with emojis at correct positions", () => { + const editor = new Editor(defaultEditorTheme); + const width = 10; + + // Each ✅ is 2 columns. "✅✅✅✅✅" = 10 columns, fits exactly + // "✅✅✅✅✅✅" = 12 columns, needs wrap + editor.setText("✅✅✅✅✅✅"); + const lines = editor.render(width); + + // Should have 2 content lines (plus 2 border lines) + // First line: 5 emojis (10 cols), second line: 1 emoji (2 cols) + padding + for (let i = 1; i < lines.length - 1; i++) { + const lineWidth = visibleWidth(lines[i]!); + assert.strictEqual(lineWidth, width, `Line ${i} has width ${lineWidth}, expected ${width}`); + } + }); + + it("wraps CJK characters correctly (each is 2 columns wide)", () => { + const editor = new Editor(defaultEditorTheme); + const width = 10; + + // Each CJK char is 2 columns. "日本語テスト" = 6 chars = 12 columns + editor.setText("日本語テスト"); + const lines = editor.render(width); + + for (let i = 1; i < lines.length - 1; i++) { + const lineWidth = visibleWidth(lines[i]!); + assert.strictEqual(lineWidth, width, `Line ${i} has width ${lineWidth}, expected ${width}`); + } + + // Verify content split correctly + const contentLines = lines.slice(1, -1).map((l) => stripVTControlCharacters(l).trim()); + assert.strictEqual(contentLines.length, 2); + assert.strictEqual(contentLines[0], "日本語テス"); // 5 chars = 10 columns + assert.strictEqual(contentLines[1], "ト"); // 1 char = 2 columns (+ padding) + }); + + it("handles mixed ASCII and wide characters in wrapping", () => { + const editor = new Editor(defaultEditorTheme); + const width = 15; + + // "Test ✅ OK 日本" = 4 + 1 + 2 + 1 + 2 + 1 + 4 = 15 columns (fits exactly) + editor.setText("Test ✅ OK 日本"); + const lines = editor.render(width); + + // Should fit in one content line + const contentLines = lines.slice(1, -1); + assert.strictEqual(contentLines.length, 1); + + const lineWidth = visibleWidth(contentLines[0]!); + assert.strictEqual(lineWidth, width); + }); + + it("renders cursor correctly on wide characters", () => { + const editor = new Editor(defaultEditorTheme); + const width = 20; + + editor.setText("A✅B"); + // Cursor should be at end (after B) + const lines = editor.render(width); + + // The cursor (reverse video space) should be visible + const contentLine = lines[1]!; + assert.ok(contentLine.includes("\x1b[7m"), "Should have reverse video cursor"); + + // Line should still be correct width + assert.strictEqual(visibleWidth(contentLine), width); + }); + + it("does not exceed terminal width with emoji at wrap boundary", () => { + const editor = new Editor(defaultEditorTheme); + const width = 11; + + // "0123456789✅" = 10 ASCII + 2-wide emoji = 12 columns + // Should wrap before the emoji since it would exceed width + editor.setText("0123456789✅"); + const lines = editor.render(width); + + for (let i = 1; i < lines.length - 1; i++) { + const lineWidth = visibleWidth(lines[i]!); + assert.ok(lineWidth <= width, `Line ${i} has width ${lineWidth}, exceeds max ${width}`); + } + }); + }); }); From 301c6ba11fe3608d9b20b6453e898dc53184c1e6 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 6 Dec 2025 21:29:03 +0100 Subject: [PATCH 39/54] Release v0.12.15 --- package-lock.json | 38 ++++++++++++++-------------- packages/agent/package.json | 6 ++--- packages/ai/package.json | 2 +- packages/coding-agent/CHANGELOG.md | 2 ++ packages/coding-agent/package.json | 8 +++--- packages/mom/package.json | 6 ++--- packages/pods/package.json | 4 +-- packages/proxy/package.json | 2 +- packages/tui/package.json | 2 +- packages/web-ui/example/package.json | 2 +- packages/web-ui/package.json | 6 ++--- 11 files changed, 40 insertions(+), 38 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8247c37b..758de6c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5975,11 +5975,11 @@ }, "packages/agent": { "name": "@mariozechner/pi-agent-core", - "version": "0.12.14", + "version": "0.12.15", "license": "MIT", "dependencies": { - "@mariozechner/pi-ai": "^0.12.13", - "@mariozechner/pi-tui": "^0.12.13" + "@mariozechner/pi-ai": "^0.12.14", + "@mariozechner/pi-tui": "^0.12.14" }, "devDependencies": { "@types/node": "^24.3.0", @@ -6009,7 +6009,7 @@ }, "packages/ai": { "name": "@mariozechner/pi-ai", - "version": "0.12.14", + "version": "0.12.15", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.61.0", @@ -6050,12 +6050,12 @@ }, "packages/coding-agent": { "name": "@mariozechner/pi-coding-agent", - "version": "0.12.14", + "version": "0.12.15", "license": "MIT", "dependencies": { - "@mariozechner/pi-agent-core": "^0.12.13", - "@mariozechner/pi-ai": "^0.12.13", - "@mariozechner/pi-tui": "^0.12.13", + "@mariozechner/pi-agent-core": "^0.12.14", + "@mariozechner/pi-ai": "^0.12.14", + "@mariozechner/pi-tui": "^0.12.14", "chalk": "^5.5.0", "diff": "^8.0.2", "glob": "^11.0.3" @@ -6092,12 +6092,12 @@ }, "packages/mom": { "name": "@mariozechner/pi-mom", - "version": "0.12.14", + "version": "0.12.15", "license": "MIT", "dependencies": { "@anthropic-ai/sandbox-runtime": "^0.0.16", - "@mariozechner/pi-agent-core": "^0.12.13", - "@mariozechner/pi-ai": "^0.12.13", + "@mariozechner/pi-agent-core": "^0.12.14", + "@mariozechner/pi-ai": "^0.12.14", "@sinclair/typebox": "^0.34.0", "@slack/socket-mode": "^2.0.0", "@slack/web-api": "^7.0.0", @@ -6135,10 +6135,10 @@ }, "packages/pods": { "name": "@mariozechner/pi", - "version": "0.12.14", + "version": "0.12.15", "license": "MIT", "dependencies": { - "@mariozechner/pi-agent-core": "^0.12.13", + "@mariozechner/pi-agent-core": "^0.12.14", "chalk": "^5.5.0" }, "bin": { @@ -6151,7 +6151,7 @@ }, "packages/proxy": { "name": "@mariozechner/pi-proxy", - "version": "0.12.14", + "version": "0.12.15", "dependencies": { "@hono/node-server": "^1.14.0", "hono": "^4.6.16" @@ -6167,7 +6167,7 @@ }, "packages/tui": { "name": "@mariozechner/pi-tui", - "version": "0.12.14", + "version": "0.12.15", "license": "MIT", "dependencies": { "@types/mime-types": "^2.1.4", @@ -6211,12 +6211,12 @@ }, "packages/web-ui": { "name": "@mariozechner/pi-web-ui", - "version": "0.12.14", + "version": "0.12.15", "license": "MIT", "dependencies": { "@lmstudio/sdk": "^1.5.0", - "@mariozechner/pi-ai": "^0.12.13", - "@mariozechner/pi-tui": "^0.12.13", + "@mariozechner/pi-ai": "^0.12.14", + "@mariozechner/pi-tui": "^0.12.14", "docx-preview": "^0.3.7", "jszip": "^3.10.1", "lucide": "^0.544.0", @@ -6237,7 +6237,7 @@ }, "packages/web-ui/example": { "name": "pi-web-ui-example", - "version": "1.0.10", + "version": "1.0.11", "dependencies": { "@mariozechner/mini-lit": "^0.2.0", "@mariozechner/pi-ai": "file:../../ai", diff --git a/packages/agent/package.json b/packages/agent/package.json index e00b84ae..96da3a89 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-agent-core", - "version": "0.12.14", + "version": "0.12.15", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "type": "module", "main": "./dist/index.js", @@ -18,8 +18,8 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@mariozechner/pi-ai": "^0.12.14", - "@mariozechner/pi-tui": "^0.12.14" + "@mariozechner/pi-ai": "^0.12.15", + "@mariozechner/pi-tui": "^0.12.15" }, "keywords": [ "ai", diff --git a/packages/ai/package.json b/packages/ai/package.json index 4a662cde..2ee940bc 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-ai", - "version": "0.12.14", + "version": "0.12.15", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 653f8b47..93a15b8b 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +## [0.12.15] - 2025-12-06 + ### Fixed - **Editor crash with emojis/CJK characters**: Fixed crash when pasting or typing text containing wide characters (emojis like ✅, CJK characters) that caused line width to exceed terminal width. The editor now uses grapheme-aware text wrapping with proper visible width calculation. diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index afe2589e..7a48cc76 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-coding-agent", - "version": "0.12.14", + "version": "0.12.15", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "type": "module", "piConfig": { @@ -28,9 +28,9 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@mariozechner/pi-agent-core": "^0.12.14", - "@mariozechner/pi-ai": "^0.12.14", - "@mariozechner/pi-tui": "^0.12.14", + "@mariozechner/pi-agent-core": "^0.12.15", + "@mariozechner/pi-ai": "^0.12.15", + "@mariozechner/pi-tui": "^0.12.15", "chalk": "^5.5.0", "diff": "^8.0.2", "glob": "^11.0.3" diff --git a/packages/mom/package.json b/packages/mom/package.json index 347781ec..3bfd928a 100644 --- a/packages/mom/package.json +++ b/packages/mom/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-mom", - "version": "0.12.14", + "version": "0.12.15", "description": "Slack bot that delegates messages to the pi coding agent", "type": "module", "bin": { @@ -21,8 +21,8 @@ }, "dependencies": { "@anthropic-ai/sandbox-runtime": "^0.0.16", - "@mariozechner/pi-agent-core": "^0.12.14", - "@mariozechner/pi-ai": "^0.12.14", + "@mariozechner/pi-agent-core": "^0.12.15", + "@mariozechner/pi-ai": "^0.12.15", "@sinclair/typebox": "^0.34.0", "@slack/socket-mode": "^2.0.0", "@slack/web-api": "^7.0.0", diff --git a/packages/pods/package.json b/packages/pods/package.json index 55e7fd6e..fc52059c 100644 --- a/packages/pods/package.json +++ b/packages/pods/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi", - "version": "0.12.14", + "version": "0.12.15", "description": "CLI tool for managing vLLM deployments on GPU pods", "type": "module", "bin": { @@ -34,7 +34,7 @@ "node": ">=20.0.0" }, "dependencies": { - "@mariozechner/pi-agent-core": "^0.12.14", + "@mariozechner/pi-agent-core": "^0.12.15", "chalk": "^5.5.0" }, "devDependencies": {} diff --git a/packages/proxy/package.json b/packages/proxy/package.json index 78df4b8f..0c4903b5 100644 --- a/packages/proxy/package.json +++ b/packages/proxy/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-proxy", - "version": "0.12.14", + "version": "0.12.15", "type": "module", "description": "CORS and authentication proxy for pi-ai", "main": "dist/index.js", diff --git a/packages/tui/package.json b/packages/tui/package.json index b2ace90c..4c4889fa 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-tui", - "version": "0.12.14", + "version": "0.12.15", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "type": "module", "main": "dist/index.js", diff --git a/packages/web-ui/example/package.json b/packages/web-ui/example/package.json index 51e5359d..76508afc 100644 --- a/packages/web-ui/example/package.json +++ b/packages/web-ui/example/package.json @@ -1,6 +1,6 @@ { "name": "pi-web-ui-example", - "version": "1.0.10", + "version": "1.0.11", "private": true, "type": "module", "scripts": { diff --git a/packages/web-ui/package.json b/packages/web-ui/package.json index d19d8c83..c03d6545 100644 --- a/packages/web-ui/package.json +++ b/packages/web-ui/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-web-ui", - "version": "0.12.14", + "version": "0.12.15", "description": "Reusable web UI components for AI chat interfaces powered by @mariozechner/pi-ai", "type": "module", "main": "dist/index.js", @@ -18,8 +18,8 @@ }, "dependencies": { "@lmstudio/sdk": "^1.5.0", - "@mariozechner/pi-ai": "^0.12.14", - "@mariozechner/pi-tui": "^0.12.14", + "@mariozechner/pi-ai": "^0.12.15", + "@mariozechner/pi-tui": "^0.12.15", "docx-preview": "^0.3.7", "jszip": "^3.10.1", "lucide": "^0.544.0", From 52f1a8cb31b771a6da7b9fff8a58318f920a03d8 Mon Sep 17 00:00:00 2001 From: badlogic Date: Sat, 6 Dec 2025 22:42:47 +0100 Subject: [PATCH 40/54] Fix Windows binary detection for Bun compiled executables - Updated isBunBinary detection to check for %7EBUN (URL-encoded ~BUN) - Simplified build:binary script to remove Unix-specific shell syntax - Binary now correctly locates supporting files next to executable on Windows --- package-lock.json | 36 +++++++----- packages/ai/src/models.generated.ts | 90 ++++++++++++++--------------- packages/coding-agent/CHANGELOG.md | 4 ++ packages/coding-agent/package.json | 2 +- packages/coding-agent/src/config.ts | 5 +- 5 files changed, 74 insertions(+), 63 deletions(-) diff --git a/package-lock.json b/package-lock.json index 758de6c1..b34f9112 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3661,6 +3661,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.10.7.tgz", "integrity": "sha512-icXIITfw/07Q88nLSkB9aiUrd8rYzSweK681Kjo/TSggaGbOX4RRyxxm71v+3PC8C/j+4rlxGeoTRxQDkaJkUw==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -3921,12 +3922,12 @@ } }, "node_modules/jws": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", - "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", "license": "MIT", "dependencies": { - "jwa": "^2.0.0", + "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, @@ -4218,6 +4219,7 @@ "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.1.tgz", "integrity": "sha512-Ksr/8L3PTapbdXJCk+EJVB78jDodUMaP54gD24W186zGRARvwrsPfS60wae/SSCTCNZVPd1chXqio1qHQmu4NA==", "license": "BSD-3-Clause", + "peer": true, "dependencies": { "@lit/reactive-element": "^2.1.0", "lit-element": "^4.2.0", @@ -5282,6 +5284,7 @@ "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/dcastil" @@ -5310,7 +5313,8 @@ "version": "4.1.17", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tapable": { "version": "2.3.0", @@ -5518,6 +5522,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.4.tgz", "integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -5960,6 +5965,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -5978,8 +5984,8 @@ "version": "0.12.15", "license": "MIT", "dependencies": { - "@mariozechner/pi-ai": "^0.12.14", - "@mariozechner/pi-tui": "^0.12.14" + "@mariozechner/pi-ai": "^0.12.15", + "@mariozechner/pi-tui": "^0.12.15" }, "devDependencies": { "@types/node": "^24.3.0", @@ -6053,9 +6059,9 @@ "version": "0.12.15", "license": "MIT", "dependencies": { - "@mariozechner/pi-agent-core": "^0.12.14", - "@mariozechner/pi-ai": "^0.12.14", - "@mariozechner/pi-tui": "^0.12.14", + "@mariozechner/pi-agent-core": "^0.12.15", + "@mariozechner/pi-ai": "^0.12.15", + "@mariozechner/pi-tui": "^0.12.15", "chalk": "^5.5.0", "diff": "^8.0.2", "glob": "^11.0.3" @@ -6096,8 +6102,8 @@ "license": "MIT", "dependencies": { "@anthropic-ai/sandbox-runtime": "^0.0.16", - "@mariozechner/pi-agent-core": "^0.12.14", - "@mariozechner/pi-ai": "^0.12.14", + "@mariozechner/pi-agent-core": "^0.12.15", + "@mariozechner/pi-ai": "^0.12.15", "@sinclair/typebox": "^0.34.0", "@slack/socket-mode": "^2.0.0", "@slack/web-api": "^7.0.0", @@ -6138,7 +6144,7 @@ "version": "0.12.15", "license": "MIT", "dependencies": { - "@mariozechner/pi-agent-core": "^0.12.14", + "@mariozechner/pi-agent-core": "^0.12.15", "chalk": "^5.5.0" }, "bin": { @@ -6215,8 +6221,8 @@ "license": "MIT", "dependencies": { "@lmstudio/sdk": "^1.5.0", - "@mariozechner/pi-ai": "^0.12.14", - "@mariozechner/pi-tui": "^0.12.14", + "@mariozechner/pi-ai": "^0.12.15", + "@mariozechner/pi-tui": "^0.12.15", "docx-preview": "^0.3.7", "jszip": "^3.10.1", "lucide": "^0.544.0", diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 9a570e4a..4e8b0b84 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -2227,7 +2227,7 @@ export const MODELS = { cacheWrite: 6.25, }, contextWindow: 200000, - maxTokens: 64000, + maxTokens: 32000, } satisfies Model<"openai-completions">, "allenai/olmo-3-7b-instruct": { id: "allenai/olmo-3-7b-instruct", @@ -3241,13 +3241,13 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.19999999999999998, - output: 0.7999999999999999, + input: 0, + output: 0, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 163840, - maxTokens: 163840, + contextWindow: 131072, + maxTokens: 131072, } satisfies Model<"openai-completions">, "openai/gpt-4o-audio-preview": { id: "openai/gpt-4o-audio-preview", @@ -4983,9 +4983,9 @@ export const MODELS = { contextWindow: 32768, maxTokens: 4096, } satisfies Model<"openai-completions">, - "anthropic/claude-3.5-haiku": { - id: "anthropic/claude-3.5-haiku", - name: "Anthropic: Claude 3.5 Haiku", + "anthropic/claude-3.5-haiku-20241022": { + id: "anthropic/claude-3.5-haiku-20241022", + name: "Anthropic: Claude 3.5 Haiku (2024-10-22)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -5000,9 +5000,9 @@ export const MODELS = { contextWindow: 200000, maxTokens: 8192, } satisfies Model<"openai-completions">, - "anthropic/claude-3.5-haiku-20241022": { - id: "anthropic/claude-3.5-haiku-20241022", - name: "Anthropic: Claude 3.5 Haiku (2024-10-22)", + "anthropic/claude-3.5-haiku": { + id: "anthropic/claude-3.5-haiku", + name: "Anthropic: Claude 3.5 Haiku", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -5153,23 +5153,6 @@ export const MODELS = { contextWindow: 32768, maxTokens: 4096, } satisfies Model<"openai-completions">, - "cohere/command-r-plus-08-2024": { - id: "cohere/command-r-plus-08-2024", - name: "Cohere: Command R+ (08-2024)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 2.5, - output: 10, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 4000, - } satisfies Model<"openai-completions">, "cohere/command-r-08-2024": { id: "cohere/command-r-08-2024", name: "Cohere: Command R (08-2024)", @@ -5187,6 +5170,23 @@ export const MODELS = { contextWindow: 128000, maxTokens: 4000, } satisfies Model<"openai-completions">, + "cohere/command-r-plus-08-2024": { + id: "cohere/command-r-plus-08-2024", + name: "Cohere: Command R+ (08-2024)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 2.5, + output: 10, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4000, + } satisfies Model<"openai-completions">, "sao10k/l3.1-euryale-70b": { id: "sao10k/l3.1-euryale-70b", name: "Sao10K: Llama 3.1 Euryale 70B v2.2", @@ -5238,23 +5238,6 @@ export const MODELS = { contextWindow: 128000, maxTokens: 16384, } satisfies Model<"openai-completions">, - "meta-llama/llama-3.1-405b-instruct": { - id: "meta-llama/llama-3.1-405b-instruct", - name: "Meta: Llama 3.1 405B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 3.5, - output: 3.5, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 130815, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "meta-llama/llama-3.1-8b-instruct": { id: "meta-llama/llama-3.1-8b-instruct", name: "Meta: Llama 3.1 8B Instruct", @@ -5272,6 +5255,23 @@ export const MODELS = { contextWindow: 131072, maxTokens: 16384, } satisfies Model<"openai-completions">, + "meta-llama/llama-3.1-405b-instruct": { + id: "meta-llama/llama-3.1-405b-instruct", + name: "Meta: Llama 3.1 405B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 3.5, + output: 3.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 130815, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "meta-llama/llama-3.1-70b-instruct": { id: "meta-llama/llama-3.1-70b-instruct", name: "Meta: Llama 3.1 70B Instruct", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 93a15b8b..60844cfd 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- **Windows binary detection**: Fixed Bun compiled binary detection on Windows by checking for URL-encoded `%7EBUN` in addition to `$bunfs` and `~BUN` in `import.meta.url`. This ensures the binary correctly locates supporting files (package.json, themes, etc.) next to the executable. + ## [0.12.15] - 2025-12-06 ### Fixed diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 7a48cc76..f651cce4 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -19,7 +19,7 @@ "scripts": { "clean": "rm -rf dist", "build": "tsgo -p tsconfig.build.json && chmod +x dist/cli.js && npm run copy-assets", - "build:binary": "command -v bun >/dev/null 2>&1 || { echo 'Error: Bun is required for building the binary. Install it from https://bun.sh'; exit 1; } && npm run build && bun build --compile ./dist/cli.js --outfile dist/pi && npm run copy-binary-assets", + "build:binary": "npm run build && bun build --compile ./dist/cli.js --outfile dist/pi && npm run copy-binary-assets", "copy-assets": "cp src/theme/*.json dist/theme/", "copy-binary-assets": "cp package.json dist/ && cp README.md dist/ && cp CHANGELOG.md dist/", "dev": "tsgo -p tsconfig.build.json --watch --preserveWatchOutput", diff --git a/packages/coding-agent/src/config.ts b/packages/coding-agent/src/config.ts index bcb5f711..0a7bd7a9 100644 --- a/packages/coding-agent/src/config.ts +++ b/packages/coding-agent/src/config.ts @@ -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) From 86e5a70ec4061c1ba0ebf8af3dae1af99c24222f Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 6 Dec 2025 22:46:02 +0100 Subject: [PATCH 41/54] Add totalTokens field to Usage type - Added totalTokens field to Usage interface in pi-ai - Anthropic: computed as input + output + cacheRead + cacheWrite - OpenAI/Google: uses native total_tokens/totalTokenCount - Fixed openai-completions to compute totalTokens when reasoning tokens present - Updated calculateContextTokens() to use totalTokens field - Added comprehensive test covering 13 providers fixes #130 --- packages/agent/src/agent.ts | 1 + packages/agent/src/transports/AppTransport.ts | 1 + packages/ai/CHANGELOG.md | 4 + packages/ai/src/models.generated.ts | 130 +++---- packages/ai/src/providers/anthropic.ts | 7 + packages/ai/src/providers/google.ts | 2 + .../ai/src/providers/openai-completions.ts | 13 +- packages/ai/src/providers/openai-responses.ts | 2 + packages/ai/src/types.ts | 1 + packages/ai/test/empty.test.ts | 1 + packages/ai/test/handoff.test.ts | 5 + packages/ai/test/total-tokens.test.ts | 331 ++++++++++++++++++ packages/ai/test/unicode-surrogate.test.ts | 3 + packages/coding-agent/src/compaction.ts | 3 +- packages/coding-agent/test/compaction.test.ts | 1 + packages/web-ui/example/src/main.ts | 1 + packages/web-ui/src/agent/agent.ts | 1 + .../src/agent/transports/AppTransport.ts | 1 + .../web-ui/src/components/AgentInterface.ts | 1 + .../src/storage/stores/sessions-store.ts | 1 + packages/web-ui/src/storage/types.ts | 2 + packages/web-ui/src/utils/test-sessions.ts | 110 ++++++ 22 files changed, 552 insertions(+), 70 deletions(-) create mode 100644 packages/ai/test/total-tokens.test.ts diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 08791bc6..9897984d 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -335,6 +335,7 @@ export class Agent { output: 0, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, stopReason: this.abortController?.signal.aborted ? "aborted" : "error", diff --git a/packages/agent/src/transports/AppTransport.ts b/packages/agent/src/transports/AppTransport.ts index 9ef1e8ce..5beb9dc6 100644 --- a/packages/agent/src/transports/AppTransport.ts +++ b/packages/agent/src/transports/AppTransport.ts @@ -44,6 +44,7 @@ function streamSimpleProxy( output: 0, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, timestamp: Date.now(), diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 61da782e..2c59840d 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Breaking Changes + +- **Added `totalTokens` field to `Usage` type**: All code that constructs `Usage` objects must now include the `totalTokens` field. This field represents the total tokens processed by the LLM (input + output + cache). For OpenAI and Google, this uses native API values (`total_tokens`, `totalTokenCount`). For Anthropic, it's computed as `input + output + cacheRead + cacheWrite`. + ## [0.12.10] - 2025-12-04 ### Added diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 4e8b0b84..c8a738cd 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -5255,23 +5255,6 @@ export const MODELS = { contextWindow: 131072, maxTokens: 16384, } satisfies Model<"openai-completions">, - "meta-llama/llama-3.1-405b-instruct": { - id: "meta-llama/llama-3.1-405b-instruct", - name: "Meta: Llama 3.1 405B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 3.5, - output: 3.5, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 130815, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "meta-llama/llama-3.1-70b-instruct": { id: "meta-llama/llama-3.1-70b-instruct", name: "Meta: Llama 3.1 70B Instruct", @@ -5289,6 +5272,23 @@ export const MODELS = { contextWindow: 131072, maxTokens: 4096, } satisfies Model<"openai-completions">, + "meta-llama/llama-3.1-405b-instruct": { + id: "meta-llama/llama-3.1-405b-instruct", + name: "Meta: Llama 3.1 405B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 3.5, + output: 3.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 130815, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "mistralai/mistral-nemo": { id: "mistralai/mistral-nemo", name: "Mistral: Mistral Nemo", @@ -5306,9 +5306,9 @@ export const MODELS = { contextWindow: 131072, maxTokens: 16384, } satisfies Model<"openai-completions">, - "openai/gpt-4o-mini-2024-07-18": { - id: "openai/gpt-4o-mini-2024-07-18", - name: "OpenAI: GPT-4o-mini (2024-07-18)", + "openai/gpt-4o-mini": { + id: "openai/gpt-4o-mini", + name: "OpenAI: GPT-4o-mini", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -5323,9 +5323,9 @@ export const MODELS = { contextWindow: 128000, maxTokens: 16384, } satisfies Model<"openai-completions">, - "openai/gpt-4o-mini": { - id: "openai/gpt-4o-mini", - name: "OpenAI: GPT-4o-mini", + "openai/gpt-4o-mini-2024-07-18": { + id: "openai/gpt-4o-mini-2024-07-18", + name: "OpenAI: GPT-4o-mini (2024-07-18)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -5425,23 +5425,6 @@ export const MODELS = { contextWindow: 128000, maxTokens: 4096, } satisfies Model<"openai-completions">, - "openai/gpt-4o-2024-05-13": { - id: "openai/gpt-4o-2024-05-13", - name: "OpenAI: GPT-4o (2024-05-13)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 5, - output: 15, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "openai/gpt-4o": { id: "openai/gpt-4o", name: "OpenAI: GPT-4o", @@ -5476,22 +5459,22 @@ export const MODELS = { contextWindow: 128000, maxTokens: 64000, } satisfies Model<"openai-completions">, - "meta-llama/llama-3-70b-instruct": { - id: "meta-llama/llama-3-70b-instruct", - name: "Meta: Llama 3 70B Instruct", + "openai/gpt-4o-2024-05-13": { + id: "openai/gpt-4o-2024-05-13", + name: "OpenAI: GPT-4o (2024-05-13)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: false, - input: ["text"], + input: ["text", "image"], cost: { - input: 0.3, - output: 0.39999999999999997, + input: 5, + output: 15, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 8192, - maxTokens: 16384, + contextWindow: 128000, + maxTokens: 4096, } satisfies Model<"openai-completions">, "meta-llama/llama-3-8b-instruct": { id: "meta-llama/llama-3-8b-instruct", @@ -5510,6 +5493,23 @@ export const MODELS = { contextWindow: 8192, maxTokens: 16384, } satisfies Model<"openai-completions">, + "meta-llama/llama-3-70b-instruct": { + id: "meta-llama/llama-3-70b-instruct", + name: "Meta: Llama 3 70B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.3, + output: 0.39999999999999997, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 8192, + maxTokens: 16384, + } satisfies Model<"openai-completions">, "mistralai/mixtral-8x22b-instruct": { id: "mistralai/mixtral-8x22b-instruct", name: "Mistral: Mixtral 8x22B Instruct", @@ -5595,23 +5595,6 @@ export const MODELS = { contextWindow: 128000, maxTokens: 4096, } satisfies Model<"openai-completions">, - "openai/gpt-3.5-turbo-0613": { - id: "openai/gpt-3.5-turbo-0613", - name: "OpenAI: GPT-3.5 Turbo (older v0613)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 1, - output: 2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 4095, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "openai/gpt-4-turbo-preview": { id: "openai/gpt-4-turbo-preview", name: "OpenAI: GPT-4 Turbo Preview", @@ -5629,6 +5612,23 @@ export const MODELS = { contextWindow: 128000, maxTokens: 4096, } satisfies Model<"openai-completions">, + "openai/gpt-3.5-turbo-0613": { + id: "openai/gpt-3.5-turbo-0613", + name: "OpenAI: GPT-3.5 Turbo (older v0613)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 1, + output: 2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 4095, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "mistralai/mistral-tiny": { id: "mistralai/mistral-tiny", name: "Mistral Tiny", diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 39523886..e2e91be2 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -105,6 +105,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( output: 0, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, stopReason: "stop", @@ -129,6 +130,9 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( output.usage.output = event.message.usage.output_tokens || 0; output.usage.cacheRead = event.message.usage.cache_read_input_tokens || 0; output.usage.cacheWrite = event.message.usage.cache_creation_input_tokens || 0; + // Anthropic doesn't provide total_tokens, compute from components + output.usage.totalTokens = + output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite; calculateCost(model, output.usage); } else if (event.type === "content_block_start") { if (event.content_block.type === "text") { @@ -253,6 +257,9 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( output.usage.output = event.usage.output_tokens || 0; output.usage.cacheRead = event.usage.cache_read_input_tokens || 0; output.usage.cacheWrite = event.usage.cache_creation_input_tokens || 0; + // Anthropic doesn't provide total_tokens, compute from components + output.usage.totalTokens = + output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite; calculateCost(model, output.usage); } } diff --git a/packages/ai/src/providers/google.ts b/packages/ai/src/providers/google.ts index 078bac7b..9d3ade4f 100644 --- a/packages/ai/src/providers/google.ts +++ b/packages/ai/src/providers/google.ts @@ -56,6 +56,7 @@ export const streamGoogle: StreamFunction<"google-generative-ai"> = ( output: 0, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, stopReason: "stop", @@ -200,6 +201,7 @@ export const streamGoogle: StreamFunction<"google-generative-ai"> = ( (chunk.usageMetadata.candidatesTokenCount || 0) + (chunk.usageMetadata.thoughtsTokenCount || 0), cacheRead: chunk.usageMetadata.cachedContentTokenCount || 0, cacheWrite: 0, + totalTokens: chunk.usageMetadata.totalTokenCount || 0, cost: { input: 0, output: 0, diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index 22f57503..a3c0a17e 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -50,6 +50,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = ( output: 0, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, stopReason: "stop", @@ -106,14 +107,18 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = ( for await (const chunk of openaiStream) { if (chunk.usage) { const cachedTokens = chunk.usage.prompt_tokens_details?.cached_tokens || 0; + const reasoningTokens = chunk.usage.completion_tokens_details?.reasoning_tokens || 0; + const input = (chunk.usage.prompt_tokens || 0) - cachedTokens; + const outputTokens = (chunk.usage.completion_tokens || 0) + reasoningTokens; output.usage = { // OpenAI includes cached tokens in prompt_tokens, so subtract to get non-cached input - input: (chunk.usage.prompt_tokens || 0) - cachedTokens, - output: - (chunk.usage.completion_tokens || 0) + - (chunk.usage.completion_tokens_details?.reasoning_tokens || 0), + input, + output: outputTokens, cacheRead: cachedTokens, cacheWrite: 0, + // Compute totalTokens ourselves since we add reasoning_tokens to output + // and some providers (e.g., Groq) don't include them in total_tokens + totalTokens: input + outputTokens + cachedTokens, cost: { input: 0, output: 0, diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index 45569b38..76a582be 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -59,6 +59,7 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = ( output: 0, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, stopReason: "stop", @@ -260,6 +261,7 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = ( output: response.usage.output_tokens || 0, cacheRead: cachedTokens, cacheWrite: 0, + totalTokens: response.usage.total_tokens || 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }; } diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index f53b4366..a7269bc8 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -82,6 +82,7 @@ export interface Usage { output: number; cacheRead: number; cacheWrite: number; + totalTokens: number; cost: { input: number; output: number; diff --git a/packages/ai/test/empty.test.ts b/packages/ai/test/empty.test.ts index 0d0a8a54..cff10612 100644 --- a/packages/ai/test/empty.test.ts +++ b/packages/ai/test/empty.test.ts @@ -92,6 +92,7 @@ async function testEmptyAssistantMessage(llm: Model, opt output: 0, cacheRead: 0, cacheWrite: 0, + totalTokens: 10, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, stopReason: "stop", diff --git a/packages/ai/test/handoff.test.ts b/packages/ai/test/handoff.test.ts index fad942c6..5504b71c 100644 --- a/packages/ai/test/handoff.test.ts +++ b/packages/ai/test/handoff.test.ts @@ -46,6 +46,7 @@ const providerContexts = { output: 50, cacheRead: 0, cacheWrite: 0, + totalTokens: 150, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, stopReason: "toolUse", @@ -97,6 +98,7 @@ const providerContexts = { output: 60, cacheRead: 0, cacheWrite: 0, + totalTokens: 180, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, stopReason: "toolUse", @@ -147,6 +149,7 @@ const providerContexts = { output: 55, cacheRead: 0, cacheWrite: 0, + totalTokens: 165, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, stopReason: "toolUse", @@ -199,6 +202,7 @@ const providerContexts = { output: 58, cacheRead: 0, cacheWrite: 0, + totalTokens: 173, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, stopReason: "toolUse", @@ -243,6 +247,7 @@ const providerContexts = { output: 25, cacheRead: 0, cacheWrite: 0, + totalTokens: 75, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, stopReason: "error", diff --git a/packages/ai/test/total-tokens.test.ts b/packages/ai/test/total-tokens.test.ts new file mode 100644 index 00000000..8dc18971 --- /dev/null +++ b/packages/ai/test/total-tokens.test.ts @@ -0,0 +1,331 @@ +/** + * Test totalTokens field across all providers. + * + * totalTokens represents the total number of tokens processed by the LLM, + * including input (with cache) and output (with thinking). This is the + * base for calculating context size for the next request. + * + * - OpenAI Completions: Uses native total_tokens field + * - OpenAI Responses: Uses native total_tokens field + * - Google: Uses native totalTokenCount field + * - Anthropic: Computed as input + output + cacheRead + cacheWrite + * - Other OpenAI-compatible providers: Uses native total_tokens field + */ + +import { describe, expect, it } from "vitest"; +import { getModel } from "../src/models.js"; +import { complete } from "../src/stream.js"; +import type { Api, Context, Model, OptionsForApi, Usage } from "../src/types.js"; + +// Generate a long system prompt to trigger caching (>2k bytes for most providers) +const LONG_SYSTEM_PROMPT = `You are a helpful assistant. Be concise in your responses. + +Here is some additional context that makes this system prompt long enough to trigger caching: + +${Array(50) + .fill( + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.", + ) + .join("\n\n")} + +Remember: Always be helpful and concise.`; + +async function testTotalTokensWithCache( + llm: Model, + options: OptionsForApi = {} as OptionsForApi, +): Promise<{ first: Usage; second: Usage }> { + // First request - no cache + const context1: Context = { + systemPrompt: LONG_SYSTEM_PROMPT, + messages: [ + { + role: "user", + content: "What is 2 + 2? Reply with just the number.", + timestamp: Date.now(), + }, + ], + }; + + const response1 = await complete(llm, context1, options); + expect(response1.stopReason).toBe("stop"); + + // Second request - should trigger cache read (same system prompt, add conversation) + const context2: Context = { + systemPrompt: LONG_SYSTEM_PROMPT, + messages: [ + ...context1.messages, + response1, // Include previous assistant response + { + role: "user", + content: "What is 3 + 3? Reply with just the number.", + timestamp: Date.now(), + }, + ], + }; + + const response2 = await complete(llm, context2, options); + expect(response2.stopReason).toBe("stop"); + + return { first: response1.usage, second: response2.usage }; +} + +function logUsage(label: string, usage: Usage) { + const computed = usage.input + usage.output + usage.cacheRead + usage.cacheWrite; + console.log(` ${label}:`); + console.log( + ` input: ${usage.input}, output: ${usage.output}, cacheRead: ${usage.cacheRead}, cacheWrite: ${usage.cacheWrite}`, + ); + console.log(` totalTokens: ${usage.totalTokens}, computed: ${computed}`); +} + +function assertTotalTokensEqualsComponents(usage: Usage) { + const computed = usage.input + usage.output + usage.cacheRead + usage.cacheWrite; + expect(usage.totalTokens).toBe(computed); +} + +describe("totalTokens field", () => { + // ========================================================================= + // Anthropic + // ========================================================================= + + describe.skipIf(!process.env.ANTHROPIC_API_KEY)("Anthropic (API Key)", () => { + it("claude-3-5-haiku - should return totalTokens equal to sum of components", async () => { + const llm = getModel("anthropic", "claude-3-5-haiku-20241022"); + + console.log(`\nAnthropic / ${llm.id}:`); + const { first, second } = await testTotalTokensWithCache(llm, { apiKey: process.env.ANTHROPIC_API_KEY }); + + logUsage("First request", first); + logUsage("Second request", second); + + assertTotalTokensEqualsComponents(first); + assertTotalTokensEqualsComponents(second); + + // Anthropic should have cache activity + const hasCache = second.cacheRead > 0 || second.cacheWrite > 0 || first.cacheWrite > 0; + expect(hasCache).toBe(true); + }, 60000); + }); + + describe.skipIf(!process.env.ANTHROPIC_OAUTH_TOKEN)("Anthropic (OAuth)", () => { + it("claude-sonnet-4 - should return totalTokens equal to sum of components", async () => { + const llm = getModel("anthropic", "claude-sonnet-4-20250514"); + + console.log(`\nAnthropic OAuth / ${llm.id}:`); + const { first, second } = await testTotalTokensWithCache(llm, { apiKey: process.env.ANTHROPIC_OAUTH_TOKEN }); + + logUsage("First request", first); + logUsage("Second request", second); + + assertTotalTokensEqualsComponents(first); + assertTotalTokensEqualsComponents(second); + + // Anthropic should have cache activity + const hasCache = second.cacheRead > 0 || second.cacheWrite > 0 || first.cacheWrite > 0; + expect(hasCache).toBe(true); + }, 60000); + }); + + // ========================================================================= + // OpenAI + // ========================================================================= + + describe.skipIf(!process.env.OPENAI_API_KEY)("OpenAI Completions", () => { + it("gpt-4o-mini - should return totalTokens equal to sum of components", async () => { + const llm: Model<"openai-completions"> = { + ...getModel("openai", "gpt-4o-mini")!, + api: "openai-completions", + }; + + console.log(`\nOpenAI Completions / ${llm.id}:`); + const { first, second } = await testTotalTokensWithCache(llm); + + logUsage("First request", first); + logUsage("Second request", second); + + assertTotalTokensEqualsComponents(first); + assertTotalTokensEqualsComponents(second); + }, 60000); + }); + + describe.skipIf(!process.env.OPENAI_API_KEY)("OpenAI Responses", () => { + it("gpt-4o - should return totalTokens equal to sum of components", async () => { + const llm = getModel("openai", "gpt-4o"); + + console.log(`\nOpenAI Responses / ${llm.id}:`); + const { first, second } = await testTotalTokensWithCache(llm); + + logUsage("First request", first); + logUsage("Second request", second); + + assertTotalTokensEqualsComponents(first); + assertTotalTokensEqualsComponents(second); + }, 60000); + }); + + // ========================================================================= + // Google + // ========================================================================= + + describe.skipIf(!process.env.GEMINI_API_KEY)("Google", () => { + it("gemini-2.0-flash - should return totalTokens equal to sum of components", async () => { + const llm = getModel("google", "gemini-2.0-flash"); + + console.log(`\nGoogle / ${llm.id}:`); + const { first, second } = await testTotalTokensWithCache(llm); + + logUsage("First request", first); + logUsage("Second request", second); + + assertTotalTokensEqualsComponents(first); + assertTotalTokensEqualsComponents(second); + }, 60000); + }); + + // ========================================================================= + // xAI + // ========================================================================= + + describe.skipIf(!process.env.XAI_API_KEY)("xAI", () => { + it("grok-3-fast - should return totalTokens equal to sum of components", async () => { + const llm = getModel("xai", "grok-3-fast"); + + console.log(`\nxAI / ${llm.id}:`); + const { first, second } = await testTotalTokensWithCache(llm, { apiKey: process.env.XAI_API_KEY }); + + logUsage("First request", first); + logUsage("Second request", second); + + assertTotalTokensEqualsComponents(first); + assertTotalTokensEqualsComponents(second); + }, 60000); + }); + + // ========================================================================= + // Groq + // ========================================================================= + + describe.skipIf(!process.env.GROQ_API_KEY)("Groq", () => { + it("openai/gpt-oss-120b - should return totalTokens equal to sum of components", async () => { + const llm = getModel("groq", "openai/gpt-oss-120b"); + + console.log(`\nGroq / ${llm.id}:`); + const { first, second } = await testTotalTokensWithCache(llm, { apiKey: process.env.GROQ_API_KEY }); + + logUsage("First request", first); + logUsage("Second request", second); + + assertTotalTokensEqualsComponents(first); + assertTotalTokensEqualsComponents(second); + }, 60000); + }); + + // ========================================================================= + // Cerebras + // ========================================================================= + + describe.skipIf(!process.env.CEREBRAS_API_KEY)("Cerebras", () => { + it("gpt-oss-120b - should return totalTokens equal to sum of components", async () => { + const llm = getModel("cerebras", "gpt-oss-120b"); + + console.log(`\nCerebras / ${llm.id}:`); + const { first, second } = await testTotalTokensWithCache(llm, { apiKey: process.env.CEREBRAS_API_KEY }); + + logUsage("First request", first); + logUsage("Second request", second); + + assertTotalTokensEqualsComponents(first); + assertTotalTokensEqualsComponents(second); + }, 60000); + }); + + // ========================================================================= + // z.ai + // ========================================================================= + + describe.skipIf(!process.env.ZAI_API_KEY)("z.ai", () => { + it("glm-4.5-flash - should return totalTokens equal to sum of components", async () => { + const llm = getModel("zai", "glm-4.5-flash"); + + console.log(`\nz.ai / ${llm.id}:`); + const { first, second } = await testTotalTokensWithCache(llm, { apiKey: process.env.ZAI_API_KEY }); + + logUsage("First request", first); + logUsage("Second request", second); + + assertTotalTokensEqualsComponents(first); + assertTotalTokensEqualsComponents(second); + }, 60000); + }); + + // ========================================================================= + // OpenRouter - Multiple backend providers + // ========================================================================= + + describe.skipIf(!process.env.OPENROUTER_API_KEY)("OpenRouter", () => { + it("anthropic/claude-sonnet-4 - should return totalTokens equal to sum of components", async () => { + const llm = getModel("openrouter", "anthropic/claude-sonnet-4"); + + console.log(`\nOpenRouter / ${llm.id}:`); + const { first, second } = await testTotalTokensWithCache(llm, { apiKey: process.env.OPENROUTER_API_KEY }); + + logUsage("First request", first); + logUsage("Second request", second); + + assertTotalTokensEqualsComponents(first); + assertTotalTokensEqualsComponents(second); + }, 60000); + + it("deepseek/deepseek-chat - should return totalTokens equal to sum of components", async () => { + const llm = getModel("openrouter", "deepseek/deepseek-chat"); + + console.log(`\nOpenRouter / ${llm.id}:`); + const { first, second } = await testTotalTokensWithCache(llm, { apiKey: process.env.OPENROUTER_API_KEY }); + + logUsage("First request", first); + logUsage("Second request", second); + + assertTotalTokensEqualsComponents(first); + assertTotalTokensEqualsComponents(second); + }, 60000); + + it("mistralai/mistral-small-3.1-24b-instruct - should return totalTokens equal to sum of components", async () => { + const llm = getModel("openrouter", "mistralai/mistral-small-3.1-24b-instruct"); + + console.log(`\nOpenRouter / ${llm.id}:`); + const { first, second } = await testTotalTokensWithCache(llm, { apiKey: process.env.OPENROUTER_API_KEY }); + + logUsage("First request", first); + logUsage("Second request", second); + + assertTotalTokensEqualsComponents(first); + assertTotalTokensEqualsComponents(second); + }, 60000); + + it("google/gemini-2.0-flash-001 - should return totalTokens equal to sum of components", async () => { + const llm = getModel("openrouter", "google/gemini-2.0-flash-001"); + + console.log(`\nOpenRouter / ${llm.id}:`); + const { first, second } = await testTotalTokensWithCache(llm, { apiKey: process.env.OPENROUTER_API_KEY }); + + logUsage("First request", first); + logUsage("Second request", second); + + assertTotalTokensEqualsComponents(first); + assertTotalTokensEqualsComponents(second); + }, 60000); + + it("meta-llama/llama-4-maverick - should return totalTokens equal to sum of components", async () => { + const llm = getModel("openrouter", "meta-llama/llama-4-maverick"); + + console.log(`\nOpenRouter / ${llm.id}:`); + const { first, second } = await testTotalTokensWithCache(llm, { apiKey: process.env.OPENROUTER_API_KEY }); + + logUsage("First request", first); + logUsage("Second request", second); + + assertTotalTokensEqualsComponents(first); + assertTotalTokensEqualsComponents(second); + }, 60000); + }); +}); diff --git a/packages/ai/test/unicode-surrogate.test.ts b/packages/ai/test/unicode-surrogate.test.ts index c52a311a..d77a2623 100644 --- a/packages/ai/test/unicode-surrogate.test.ts +++ b/packages/ai/test/unicode-surrogate.test.ts @@ -42,6 +42,7 @@ async function testEmojiInToolResults(llm: Model, option output: 0, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, stopReason: "toolUse", @@ -126,6 +127,7 @@ async function testRealWorldLinkedInData(llm: Model, opt output: 0, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, stopReason: "toolUse", @@ -213,6 +215,7 @@ async function testUnpairedHighSurrogate(llm: Model, opt output: 0, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, stopReason: "toolUse", diff --git a/packages/coding-agent/src/compaction.ts b/packages/coding-agent/src/compaction.ts index 3756718d..34ec7f1f 100644 --- a/packages/coding-agent/src/compaction.ts +++ b/packages/coding-agent/src/compaction.ts @@ -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; } /** diff --git a/packages/coding-agent/test/compaction.test.ts b/packages/coding-agent/test/compaction.test.ts index 1153bd29..fea8649f 100644 --- a/packages/coding-agent/test/compaction.test.ts +++ b/packages/coding-agent/test/compaction.test.ts @@ -38,6 +38,7 @@ function createMockUsage(input: number, output: number, cacheRead = 0, cacheWrit output, cacheRead, cacheWrite, + totalTokens: input + output + cacheRead + cacheWrite, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }; } diff --git a/packages/web-ui/example/src/main.ts b/packages/web-ui/example/src/main.ts index 5d51352b..7c7f4d9f 100644 --- a/packages/web-ui/example/src/main.ts +++ b/packages/web-ui/example/src/main.ts @@ -131,6 +131,7 @@ const saveSession = async () => { output: 0, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0, output: 0, diff --git a/packages/web-ui/src/agent/agent.ts b/packages/web-ui/src/agent/agent.ts index cdcebe42..893354ca 100644 --- a/packages/web-ui/src/agent/agent.ts +++ b/packages/web-ui/src/agent/agent.ts @@ -308,6 +308,7 @@ export class Agent { output: 0, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, stopReason: this.abortController?.signal.aborted ? "aborted" : "error", diff --git a/packages/web-ui/src/agent/transports/AppTransport.ts b/packages/web-ui/src/agent/transports/AppTransport.ts index 810f78c1..0d5135a8 100644 --- a/packages/web-ui/src/agent/transports/AppTransport.ts +++ b/packages/web-ui/src/agent/transports/AppTransport.ts @@ -46,6 +46,7 @@ function streamSimpleProxy( output: 0, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, timestamp: Date.now(), diff --git a/packages/web-ui/src/components/AgentInterface.ts b/packages/web-ui/src/components/AgentInterface.ts index 5b964d8e..3d44faa3 100644 --- a/packages/web-ui/src/components/AgentInterface.ts +++ b/packages/web-ui/src/components/AgentInterface.ts @@ -266,6 +266,7 @@ export class AgentInterface extends LitElement { output: 0, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, } satisfies Usage, ); diff --git a/packages/web-ui/src/storage/stores/sessions-store.ts b/packages/web-ui/src/storage/stores/sessions-store.ts index aed3dbef..40a34edb 100644 --- a/packages/web-ui/src/storage/stores/sessions-store.ts +++ b/packages/web-ui/src/storage/stores/sessions-store.ts @@ -101,6 +101,7 @@ export class SessionsStore extends Store { output: 0, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, thinkingLevel: state.thinkingLevel || "off", diff --git a/packages/web-ui/src/storage/types.ts b/packages/web-ui/src/storage/types.ts index e6bdd628..038f9657 100644 --- a/packages/web-ui/src/storage/types.ts +++ b/packages/web-ui/src/storage/types.ts @@ -118,6 +118,8 @@ export interface SessionMetadata { cacheRead: number; /** Total cache write tokens */ cacheWrite: number; + /** Total tokens processed */ + totalTokens: number; /** Total cost breakdown */ cost: { input: number; diff --git a/packages/web-ui/src/utils/test-sessions.ts b/packages/web-ui/src/utils/test-sessions.ts index 37154b99..5d54c093 100644 --- a/packages/web-ui/src/utils/test-sessions.ts +++ b/packages/web-ui/src/utils/test-sessions.ts @@ -56,11 +56,13 @@ export const simpleHtml = { output: 375, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0.0030632000000000003, output: 0.0015, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, total: 0.0045632, }, }, @@ -89,11 +91,13 @@ export const simpleHtml = { output: 162, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0.003376, output: 0.0006479999999999999, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, total: 0.004024, }, }, @@ -159,11 +163,13 @@ export const longSession = { output: 455, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0.0030632000000000003, output: 0.00182, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, total: 0.004883200000000001, }, }, @@ -192,11 +198,13 @@ export const longSession = { output: 147, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0.0034384000000000003, output: 0.000588, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, total: 0.0040264, }, }, @@ -235,11 +243,13 @@ export const longSession = { output: 96, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0.0035656000000000004, output: 0.000384, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, total: 0.0039496, }, }, @@ -267,11 +277,13 @@ export const longSession = { output: 0, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, total: 0, }, }, @@ -312,11 +324,13 @@ export const longSession = { output: 115, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0.0049456000000000005, output: 0.00045999999999999996, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, total: 0.005405600000000001, }, }, @@ -348,11 +362,13 @@ export const longSession = { output: 86, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0.0050696000000000005, output: 0.00034399999999999996, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, total: 0.0054136, }, }, @@ -391,11 +407,13 @@ export const longSession = { output: 294, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0.005151200000000001, output: 0.001176, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, total: 0.006327200000000001, }, }, @@ -428,11 +446,13 @@ export const longSession = { output: 159, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0.0054152, output: 0.000636, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, total: 0.0060512000000000005, }, }, @@ -471,11 +491,13 @@ export const longSession = { output: 379, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0.005566400000000001, output: 0.001516, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, total: 0.007082400000000001, }, }, @@ -516,11 +538,13 @@ export const longSession = { output: 537, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0.005900000000000001, output: 0.0021479999999999997, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, total: 0.008048, }, }, @@ -547,11 +571,13 @@ export const longSession = { output: 0, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, total: 0, }, }, @@ -583,11 +609,13 @@ export const longSession = { output: 0, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, total: 0, }, }, @@ -627,11 +655,13 @@ export const longSession = { output: 492, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0.024597, output: 0.00738, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, total: 0.031977, }, }, @@ -672,11 +702,13 @@ export const longSession = { output: 213, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0.026211, output: 0.003195, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, total: 0.029406, }, }, @@ -709,11 +741,13 @@ export const longSession = { output: 134, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0.026958, output: 0.00201, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, total: 0.028968, }, }, @@ -752,11 +786,13 @@ export const longSession = { output: 331, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0.02739, output: 0.004965, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, total: 0.032355, }, }, @@ -788,11 +824,13 @@ export const longSession = { output: 53, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0.028443, output: 0.000795, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, total: 0.029238, }, }, @@ -831,11 +869,13 @@ export const longSession = { output: 329, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0.028623, output: 0.004935, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, total: 0.033558, }, }, @@ -867,11 +907,13 @@ export const longSession = { output: 46, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0.029670000000000002, output: 0.00069, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, total: 0.03036, }, }, @@ -897,11 +939,13 @@ export const longSession = { output: 0, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, total: 0, }, }, @@ -937,11 +981,13 @@ export const longSession = { output: 285, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0.029856, output: 0.004275, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, total: 0.034131, }, }, @@ -974,11 +1020,13 @@ export const longSession = { output: 39, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0.030831, output: 0.000585, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, total: 0.031416, }, }, @@ -1017,11 +1065,13 @@ export const longSession = { output: 473, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0.030993, output: 0.007095000000000001, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, total: 0.038088, }, }, @@ -1048,11 +1098,13 @@ export const longSession = { output: 0, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, total: 0, }, }, @@ -1088,11 +1140,13 @@ export const longSession = { output: 348, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0.032556, output: 0.00522, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, total: 0.037776000000000004, }, }, @@ -1133,11 +1187,13 @@ export const longSession = { output: 310, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0.033942, output: 0.0046500000000000005, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, total: 0.038592, }, }, @@ -1170,11 +1226,13 @@ export const longSession = { output: 53, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0.034977, output: 0.000795, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, total: 0.035772, }, }, @@ -1213,11 +1271,13 @@ export const longSession = { output: 423, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0.035160000000000004, output: 0.006345, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, total: 0.041505, }, }, @@ -1258,11 +1318,13 @@ export const longSession = { output: 193, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0.036651, output: 0.002895, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, total: 0.039546000000000005, }, }, @@ -1295,11 +1357,13 @@ export const longSession = { output: 104, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0.037557, output: 0.00156, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, total: 0.039117, }, }, @@ -1334,11 +1398,13 @@ export const longSession = { output: 146, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0.037911, output: 0.00219, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, total: 0.040101, }, }, @@ -1371,11 +1437,13 @@ export const longSession = { output: 63, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0.038535, output: 0.000945, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, total: 0.03948, }, }, @@ -1401,11 +1469,13 @@ export const longSession = { output: 0, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, total: 0, }, }, @@ -1445,11 +1515,13 @@ export const longSession = { output: 324, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0.038823, output: 0.00486, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, total: 0.043683, }, }, @@ -1490,11 +1562,13 @@ export const longSession = { output: 385, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0.040605, output: 0.005775, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, total: 0.046380000000000005, }, }, @@ -1531,11 +1605,13 @@ export const longSession = { output: 436, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0.043749, output: 0.00654, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, total: 0.050289, }, }, @@ -1571,11 +1647,13 @@ export const longSession = { output: 685, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0.045105, output: 0.010275, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, total: 0.05538, }, }, @@ -1615,11 +1693,13 @@ export const longSession = { output: 683, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0.047214, output: 0.010245, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, total: 0.057458999999999996, }, }, @@ -1664,11 +1744,13 @@ export const longSession = { output: 3462, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0.058758000000000005, output: 0.051930000000000004, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, total: 0.11068800000000001, }, }, @@ -1697,11 +1779,13 @@ export const longSession = { output: 223, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0.069195, output: 0.003345, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, total: 0.07254000000000001, }, }, @@ -1740,11 +1824,13 @@ export const longSession = { output: 335, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0.06991800000000001, output: 0.005025, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, total: 0.07494300000000001, }, }, @@ -1785,11 +1871,13 @@ export const longSession = { output: 499, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0.075036, output: 0.007485, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, total: 0.08252100000000001, }, }, @@ -1830,11 +1918,13 @@ export const longSession = { output: 462, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0.078387, output: 0.00693, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, total: 0.085317, }, }, @@ -1875,11 +1965,13 @@ export const longSession = { output: 431, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0.079914, output: 0.006465, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, total: 0.086379, }, }, @@ -1920,11 +2012,13 @@ export const longSession = { output: 335, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0.083382, output: 0.005025, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, total: 0.088407, }, }, @@ -1969,11 +2063,13 @@ export const longSession = { output: 1209, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0.08655600000000001, output: 0.018135000000000002, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, total: 0.104691, }, }, @@ -2002,11 +2098,13 @@ export const longSession = { output: 249, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0.09024, output: 0.003735, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, total: 0.093975, }, }, @@ -2045,11 +2143,13 @@ export const longSession = { output: 279, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0.091008, output: 0.004185, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, total: 0.095193, }, }, @@ -2078,11 +2178,13 @@ export const longSession = { output: 54, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0.091893, output: 0.0008100000000000001, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, total: 0.09270300000000001, }, }, @@ -2121,11 +2223,13 @@ export const longSession = { output: 162, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0.092097, output: 0.00243, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, total: 0.094527, }, }, @@ -2155,11 +2259,13 @@ export const longSession = { output: 67, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0.09271800000000001, output: 0.001005, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, total: 0.09372300000000001, }, }, @@ -2199,11 +2305,13 @@ export const longSession = { output: 182, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0.092937, output: 0.0027300000000000002, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, total: 0.095667, }, }, @@ -2233,11 +2341,13 @@ export const longSession = { output: 33, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, cost: { input: 0.093642, output: 0.000495, cacheRead: 0, cacheWrite: 0, + totalTokens: 0, total: 0.094137, }, }, From ecdbd88f5d63ed2de6cc57c9346c0293013111f0 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 6 Dec 2025 22:48:39 +0100 Subject: [PATCH 42/54] Release v0.13.0 --- package-lock.json | 920 ++++++++++++++++++++------- packages/agent/package.json | 6 +- packages/ai/CHANGELOG.md | 2 +- packages/ai/package.json | 2 +- packages/ai/src/models.generated.ts | 130 ++-- packages/coding-agent/package.json | 8 +- packages/mom/package.json | 6 +- packages/pods/package.json | 4 +- packages/proxy/package.json | 2 +- packages/tui/package.json | 2 +- packages/web-ui/example/package.json | 2 +- packages/web-ui/package.json | 6 +- 12 files changed, 784 insertions(+), 306 deletions(-) diff --git a/package-lock.json b/package-lock.json index b34f9112..ba87f87c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -217,12 +217,13 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.1.tgz", + "integrity": "sha512-HHB50pdsBX6k47S4u5g/CaLjqS3qwaOVE5ILsq64jyzgMhLuCuZ8rGzM9yhsAjfjkbgUPMzZEPa7DAp7yz6vuA==", "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -233,12 +234,13 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.1.tgz", + "integrity": "sha512-kFqa6/UcaTbGm/NncN9kzVOODjhZW8e+FRdSeypWe6j33gzclHtwlANs26JrupOntlcWmB0u8+8HZo8s7thHvg==", "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -249,12 +251,13 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.1.tgz", + "integrity": "sha512-45fuKmAJpxnQWixOGCrS+ro4Uvb4Re9+UTieUY2f8AEc+t7d4AaZ6eUJ3Hva7dtrxAAWHtlEFsXFMAgNnGU9uQ==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -265,12 +268,13 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.1.tgz", + "integrity": "sha512-LBEpOz0BsgMEeHgenf5aqmn/lLNTFXVfoWMUox8CtWWYK9X4jmQzWjoGoNb8lmAYml/tQ/Ysvm8q7szu7BoxRQ==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -281,12 +285,13 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.1.tgz", + "integrity": "sha512-veg7fL8eMSCVKL7IW4pxb54QERtedFDfY/ASrumK/SbFsXnRazxY4YykN/THYqFnFwJ0aVjiUrVG2PwcdAEqQQ==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -297,12 +302,13 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.1.tgz", + "integrity": "sha512-+3ELd+nTzhfWb07Vol7EZ+5PTbJ/u74nC6iv4/lwIU99Ip5uuY6QoIf0Hn4m2HoV0qcnRivN3KSqc+FyCHjoVQ==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -313,12 +319,13 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.1.tgz", + "integrity": "sha512-/8Rfgns4XD9XOSXlzUDepG8PX+AVWHliYlUkFI3K3GB6tqbdjYqdhcb4BKRd7C0BhZSoaCxhv8kTcBrcZWP+xg==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -329,12 +336,13 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.1.tgz", + "integrity": "sha512-GITpD8dK9C+r+5yRT/UKVT36h/DQLOHdwGVwwoHidlnA168oD3uxA878XloXebK4Ul3gDBBIvEdL7go9gCUFzQ==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -345,12 +353,13 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.1.tgz", + "integrity": "sha512-ieMID0JRZY/ZeCrsFQ3Y3NlHNCqIhTprJfDgSB3/lv5jJZ8FX3hqPyXWhe+gvS5ARMBJ242PM+VNz/ctNj//eA==", "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -361,12 +370,13 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.1.tgz", + "integrity": "sha512-W9//kCrh/6in9rWIBdKaMtuTTzNj6jSeG/haWBADqLLa9P8O5YSRDzgD5y9QBok4AYlzS6ARHifAb75V6G670Q==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -377,12 +387,13 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.1.tgz", + "integrity": "sha512-VIUV4z8GD8rtSVMfAj1aXFahsi/+tcoXXNYmXgzISL+KB381vbSTNdeZHHHIYqFyXcoEhu9n5cT+05tRv13rlw==", "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -393,12 +404,13 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.1.tgz", + "integrity": "sha512-l4rfiiJRN7sTNI//ff65zJ9z8U+k6zcCg0LALU5iEWzY+a1mVZ8iWC1k5EsNKThZ7XCQ6YWtsZ8EWYm7r1UEsg==", "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -409,12 +421,13 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.1.tgz", + "integrity": "sha512-U0bEuAOLvO/DWFdygTHWY8C067FXz+UbzKgxYhXC0fDieFa0kDIra1FAhsAARRJbvEyso8aAqvPdNxzWuStBnA==", "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -425,12 +438,13 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.1.tgz", + "integrity": "sha512-NzdQ/Xwu6vPSf/GkdmRNsOfIeSGnh7muundsWItmBsVpMoNPVpM61qNzAVY3pZ1glzzAxLR40UyYM23eaDDbYQ==", "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -441,12 +455,13 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.1.tgz", + "integrity": "sha512-7zlw8p3IApcsN7mFw0O1Z1PyEk6PlKMu18roImfl3iQHTnr/yAfYv6s4hXPidbDoI2Q0pW+5xeoM4eTCC0UdrQ==", "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -457,12 +472,13 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.1.tgz", + "integrity": "sha512-cGj5wli+G+nkVQdZo3+7FDKC25Uh4ZVwOAK6A06Hsvgr8WqBBuOy/1s+PUEd/6Je+vjfm6stX0kmib5b/O2Ykw==", "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -473,12 +489,13 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.1.tgz", + "integrity": "sha512-z3H/HYI9MM0HTv3hQZ81f+AKb+yEoCRlUby1F80vbQ5XdzEMyY/9iNlAmhqiBKw4MJXwfgsh7ERGEOhrM1niMA==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -489,12 +506,13 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.1.tgz", + "integrity": "sha512-wzC24DxAvk8Em01YmVXyjl96Mr+ecTPyOuADAvjGg+fyBpGmxmcr2E5ttf7Im8D0sXZihpxzO1isus8MdjMCXQ==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -505,12 +523,13 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.1.tgz", + "integrity": "sha512-1YQ8ybGi2yIXswu6eNzJsrYIGFpnlzEWRl6iR5gMgmsrR0FcNoV1m9k9sc3PuP5rUBLshOZylc9nqSgymI+TYg==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -521,12 +540,13 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.1.tgz", + "integrity": "sha512-5Z+DzLCrq5wmU7RDaMDe2DVXMRm2tTDvX2KU14JJVBN2CT/qov7XVix85QoJqHltpvAOZUAc3ndU56HSMWrv8g==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -537,12 +557,13 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.1.tgz", + "integrity": "sha512-Q73ENzIdPF5jap4wqLtsfh8YbYSZ8Q0wnxplOlZUOyZy7B4ZKW8DXGWgTCZmF8VWD7Tciwv5F4NsRf6vYlZtqg==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -553,12 +574,13 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.1.tgz", + "integrity": "sha512-ajbHrGM/XiK+sXM0JzEbJAen+0E+JMQZ2l4RR4VFwvV9JEERx+oxtgkpoKv1SevhjavK2z2ReHk32pjzktWbGg==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -569,12 +591,13 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.1.tgz", + "integrity": "sha512-IPUW+y4VIjuDVn+OMzHc5FV4GubIwPnsz6ubkvN8cuhEqH81NovB53IUlrlBkPMEPxvNnf79MGBoz8rZ2iW8HA==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -585,12 +608,13 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.1.tgz", + "integrity": "sha512-RIVRWiljWA6CdVu8zkWcRmGP7iRRIIwvhDKem8UMBjPql2TXM5PkDVvvrzMtj1V+WFPB4K7zkIGM7VzRtFkjdg==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -601,12 +625,13 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.1.tgz", + "integrity": "sha512-2BR5M8CPbptC1AK5JbJT1fWrHLvejwZidKx3UMSF0ecHMa+smhi16drIrCEggkgviBwLYd5nwrFLSl5Kho96RQ==", "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -617,12 +642,13 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.1.tgz", + "integrity": "sha512-d5X6RMYv6taIymSk8JBP+nxv8DQAMY6A51GPgusqLdK9wBz5wWIXy1KjTck6HnjE9hqJzJRdk+1p/t5soSbCtw==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -633,9 +659,9 @@ } }, "node_modules/@google/genai": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.30.0.tgz", - "integrity": "sha512-3MRcgczBFbUat1wIlZoLJ0vCCfXgm7Qxjh59cZi2X08RgWLtm9hKOspzp7TOg1TV2e26/MLxR2GR5yD5GmBV2w==", + "version": "1.31.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.31.0.tgz", + "integrity": "sha512-rK0RKXxNkbK35eDl+G651SxtxwHNEOogjyeZJUJe+Ed4yxu3xy5ufCiU0+QLT7xo4M9Spey8OAYfD8LPRlYBKw==", "license": "Apache-2.0", "dependencies": { "google-auth-library": "^10.3.0", @@ -917,9 +943,9 @@ "link": true }, "node_modules/@napi-rs/canvas": { - "version": "0.1.83", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.83.tgz", - "integrity": "sha512-f9GVB9VNc9vn/nroc9epXRNkVpvNPZh69+qzLJIm9DfruxFqX0/jsXG46OGWAJgkO4mN0HvFHjRROMXKVmPszg==", + "version": "0.1.84", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.84.tgz", + "integrity": "sha512-88FTNFs4uuiFKP0tUrPsEXhpe9dg7za9ILZJE08pGdUveMIDeana1zwfVkqRHJDPJFAmGY3dXmJ99dzsy57YnA==", "license": "MIT", "optional": true, "workspaces": [ @@ -929,22 +955,22 @@ "node": ">= 10" }, "optionalDependencies": { - "@napi-rs/canvas-android-arm64": "0.1.83", - "@napi-rs/canvas-darwin-arm64": "0.1.83", - "@napi-rs/canvas-darwin-x64": "0.1.83", - "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.83", - "@napi-rs/canvas-linux-arm64-gnu": "0.1.83", - "@napi-rs/canvas-linux-arm64-musl": "0.1.83", - "@napi-rs/canvas-linux-riscv64-gnu": "0.1.83", - "@napi-rs/canvas-linux-x64-gnu": "0.1.83", - "@napi-rs/canvas-linux-x64-musl": "0.1.83", - "@napi-rs/canvas-win32-x64-msvc": "0.1.83" + "@napi-rs/canvas-android-arm64": "0.1.84", + "@napi-rs/canvas-darwin-arm64": "0.1.84", + "@napi-rs/canvas-darwin-x64": "0.1.84", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.84", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.84", + "@napi-rs/canvas-linux-arm64-musl": "0.1.84", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.84", + "@napi-rs/canvas-linux-x64-gnu": "0.1.84", + "@napi-rs/canvas-linux-x64-musl": "0.1.84", + "@napi-rs/canvas-win32-x64-msvc": "0.1.84" } }, "node_modules/@napi-rs/canvas-android-arm64": { - "version": "0.1.83", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.83.tgz", - "integrity": "sha512-TbKM2fh9zXjqFIU8bgMfzG7rkrIYdLKMafgPhFoPwKrpWk1glGbWP7LEu8Y/WrMDqTGFdRqUmuX89yQEzZbkiw==", + "version": "0.1.84", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.84.tgz", + "integrity": "sha512-pdvuqvj3qtwVryqgpAGornJLV6Ezpk39V6wT4JCnRVGy8I3Tk1au8qOalFGrx/r0Ig87hWslysPpHBxVpBMIww==", "cpu": [ "arm64" ], @@ -958,9 +984,9 @@ } }, "node_modules/@napi-rs/canvas-darwin-arm64": { - "version": "0.1.83", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.83.tgz", - "integrity": "sha512-gp8IDVUloPUmkepHly4xRUOfUJSFNvA4jR7ZRF5nk3YcGzegSFGeICiT4PnYyPgSKEhYAFe1Y2XNy0Mp6Tu8mQ==", + "version": "0.1.84", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.84.tgz", + "integrity": "sha512-A8IND3Hnv0R6abc6qCcCaOCujTLMmGxtucMTZ5vbQUrEN/scxi378MyTLtyWg+MRr6bwQJ6v/orqMS9datIcww==", "cpu": [ "arm64" ], @@ -974,9 +1000,9 @@ } }, "node_modules/@napi-rs/canvas-darwin-x64": { - "version": "0.1.83", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.83.tgz", - "integrity": "sha512-r4ZJxiP9OgUbdGZhPDEXD3hQ0aIPcVaywtcTXvamYxTU/SWKAbKVhFNTtpRe1J30oQ25gWyxTkUKSBgUkNzdnw==", + "version": "0.1.84", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.84.tgz", + "integrity": "sha512-AUW45lJhYWwnA74LaNeqhvqYKK/2hNnBBBl03KRdqeCD4tKneUSrxUqIv8d22CBweOvrAASyKN3W87WO2zEr/A==", "cpu": [ "x64" ], @@ -990,9 +1016,9 @@ } }, "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { - "version": "0.1.83", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.83.tgz", - "integrity": "sha512-Uc6aSB05qH1r+9GUDxIE6F5ZF7L0nTFyyzq8ublWUZhw8fEGK8iy931ff1ByGFT04+xHJad1kBcL4R1ZEV8z7Q==", + "version": "0.1.84", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.84.tgz", + "integrity": "sha512-8zs5ZqOrdgs4FioTxSBrkl/wHZB56bJNBqaIsfPL4ZkEQCinOkrFF7xIcXiHiKp93J3wUtbIzeVrhTIaWwqk+A==", "cpu": [ "arm" ], @@ -1006,9 +1032,9 @@ } }, "node_modules/@napi-rs/canvas-linux-arm64-gnu": { - "version": "0.1.83", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.83.tgz", - "integrity": "sha512-eEeaJA7V5KOFq7W0GtoRVbd3ak8UZpK+XLkCgUiFGtlunNw+ZZW9Cr/92MXflGe7o3SqqMUg+f975LPxO/vsOQ==", + "version": "0.1.84", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.84.tgz", + "integrity": "sha512-i204vtowOglJUpbAFWU5mqsJgH0lVpNk/Ml4mQtB4Lndd86oF+Otr6Mr5KQnZHqYGhlSIKiU2SYnUbhO28zGQA==", "cpu": [ "arm64" ], @@ -1022,9 +1048,9 @@ } }, "node_modules/@napi-rs/canvas-linux-arm64-musl": { - "version": "0.1.83", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.83.tgz", - "integrity": "sha512-cAvonp5XpbatVGegF9lMQNchs3z5RH6EtamRVnQvtoRtwbzOMcdzwuLBqDBQxQF79MFbuZNkWj3YRJjZCjHVzw==", + "version": "0.1.84", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.84.tgz", + "integrity": "sha512-VyZq0EEw+OILnWk7G3ZgLLPaz1ERaPP++jLjeyLMbFOF+Tr4zHzWKiKDsEV/cT7btLPZbVoR3VX+T9/QubnURQ==", "cpu": [ "arm64" ], @@ -1038,9 +1064,9 @@ } }, "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { - "version": "0.1.83", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.83.tgz", - "integrity": "sha512-WFUPQ9qZy31vmLxIJ3MfmHw+R2g/mLCgk8zmh7maJW8snV3vLPA7pZfIS65Dc61EVDp1vaBskwQ2RqPPzwkaew==", + "version": "0.1.84", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.84.tgz", + "integrity": "sha512-PSMTh8DiThvLRsbtc/a065I/ceZk17EXAATv9uNvHgkgo7wdEfTh2C3aveNkBMGByVO3tvnvD5v/YFtZL07cIg==", "cpu": [ "riscv64" ], @@ -1054,9 +1080,9 @@ } }, "node_modules/@napi-rs/canvas-linux-x64-gnu": { - "version": "0.1.83", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.83.tgz", - "integrity": "sha512-X9YwIjsuy50WwOyYeNhEHjKHO8rrfH9M4U8vNqLuGmqsZdKua/GrUhdQGdjq7lTgdY3g4+Ta5jF8MzAa7UAs/g==", + "version": "0.1.84", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.84.tgz", + "integrity": "sha512-N1GY3noO1oqgEo3rYQIwY44kfM11vA0lDbN0orTOHfCSUZTUyiYCY0nZ197QMahZBm1aR/vYgsWpV74MMMDuNA==", "cpu": [ "x64" ], @@ -1070,9 +1096,9 @@ } }, "node_modules/@napi-rs/canvas-linux-x64-musl": { - "version": "0.1.83", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.83.tgz", - "integrity": "sha512-Vv2pLWQS8EnlSM1bstJ7vVhKA+mL4+my4sKUIn/bgIxB5O90dqiDhQjUDLP+5xn9ZMestRWDt3tdQEkGAmzq/A==", + "version": "0.1.84", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.84.tgz", + "integrity": "sha512-vUZmua6ADqTWyHyei81aXIt9wp0yjeNwTH0KdhdeoBb6azHmFR8uKTukZMXfLCC3bnsW0t4lW7K78KNMknmtjg==", "cpu": [ "x64" ], @@ -1086,9 +1112,9 @@ } }, "node_modules/@napi-rs/canvas-win32-x64-msvc": { - "version": "0.1.83", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.83.tgz", - "integrity": "sha512-K1TtjbScfRNYhq8dengLLufXGbtEtWdUXPV505uLFPovyGHzDUGXLFP/zUJzj6xWXwgUjHNLgEPIt7mye0zr6Q==", + "version": "0.1.84", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.84.tgz", + "integrity": "sha512-YSs8ncurc1xzegUMNnQUTYrdrAuaXdPMOa+iYYyAxydOtg0ppV386hyYMsy00Yip1NlTgLCseRG4sHSnjQx6og==", "cpu": [ "x64" ], @@ -2165,28 +2191,28 @@ } }, "node_modules/@typescript/native-preview": { - "version": "7.0.0-dev.20251128.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview/-/native-preview-7.0.0-dev.20251128.1.tgz", - "integrity": "sha512-VCrCHJ+TWWDwjGlZIQbcx5sg+IkNmRYviDf+9gjXvNNrK6PSnCoOSHuWDBIpGUdCWUg9uq4Mn2H5y3CvTQCriQ==", + "version": "7.0.0-dev.20251206.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview/-/native-preview-7.0.0-dev.20251206.1.tgz", + "integrity": "sha512-wog5hWVS+bYjFTpxtr+8qxqjhTKpAvlDCivvW592zctOcO+e9iLbBBLU3nDZdXW06rEA2zwjOqEPbsUcM/mcEw==", "dev": true, "license": "Apache-2.0", "bin": { "tsgo": "bin/tsgo.js" }, "optionalDependencies": { - "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20251128.1", - "@typescript/native-preview-darwin-x64": "7.0.0-dev.20251128.1", - "@typescript/native-preview-linux-arm": "7.0.0-dev.20251128.1", - "@typescript/native-preview-linux-arm64": "7.0.0-dev.20251128.1", - "@typescript/native-preview-linux-x64": "7.0.0-dev.20251128.1", - "@typescript/native-preview-win32-arm64": "7.0.0-dev.20251128.1", - "@typescript/native-preview-win32-x64": "7.0.0-dev.20251128.1" + "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20251206.1", + "@typescript/native-preview-darwin-x64": "7.0.0-dev.20251206.1", + "@typescript/native-preview-linux-arm": "7.0.0-dev.20251206.1", + "@typescript/native-preview-linux-arm64": "7.0.0-dev.20251206.1", + "@typescript/native-preview-linux-x64": "7.0.0-dev.20251206.1", + "@typescript/native-preview-win32-arm64": "7.0.0-dev.20251206.1", + "@typescript/native-preview-win32-x64": "7.0.0-dev.20251206.1" } }, "node_modules/@typescript/native-preview-darwin-arm64": { - "version": "7.0.0-dev.20251128.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-arm64/-/native-preview-darwin-arm64-7.0.0-dev.20251128.1.tgz", - "integrity": "sha512-O9dvEAR5988SE1GGQgGUT0mJfi8ztmKMaAM46z3ue9oe0uypEo94++5g/tZyZ6CMoIhbxtbwVoBDN5j+HzNwIg==", + "version": "7.0.0-dev.20251206.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-arm64/-/native-preview-darwin-arm64-7.0.0-dev.20251206.1.tgz", + "integrity": "sha512-Lcm+cO/0d+iUNzpequh8v8rm7FKLLxnMv4cg59jkmeMNm6zLe8UHFJVKd4KSUeTBp6DrF1BBXkahIwecR21YXA==", "cpu": [ "arm64" ], @@ -2198,9 +2224,9 @@ ] }, "node_modules/@typescript/native-preview-darwin-x64": { - "version": "7.0.0-dev.20251128.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-x64/-/native-preview-darwin-x64-7.0.0-dev.20251128.1.tgz", - "integrity": "sha512-oSuo4ty52I0V1n/NImWJMR5kH07xUUzD2xOzvU0Y5M34VNMW+qzbiVHLVo/uaXVLpflyiU1rM9Luan69VVdxdA==", + "version": "7.0.0-dev.20251206.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-x64/-/native-preview-darwin-x64-7.0.0-dev.20251206.1.tgz", + "integrity": "sha512-RR3flM+2oRZP8HMh8mfUzOitUVBE3kbXBT1qqjQvU5GkFGKEU0DH2OZ35YV/ufVHBsRygXNbahw2heE08Jvk6A==", "cpu": [ "x64" ], @@ -2212,9 +2238,9 @@ ] }, "node_modules/@typescript/native-preview-linux-arm": { - "version": "7.0.0-dev.20251128.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm/-/native-preview-linux-arm-7.0.0-dev.20251128.1.tgz", - "integrity": "sha512-OjtVdR2gEyj5x8eJ7l9WEmRBgdYBXwqqMUyV25eWkW/NBC89EKMwMNvJnMSMxeIDdMwi0jOPo5XV2doC2HuI7A==", + "version": "7.0.0-dev.20251206.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm/-/native-preview-linux-arm-7.0.0-dev.20251206.1.tgz", + "integrity": "sha512-NMndfbNZiYALizEJ6+5RJl5vp7TMjVa0mW4sBFP/W+AECf1D0NZ1jffHBdwRgHr6inehw93cL78XcNWm+l89QA==", "cpu": [ "arm" ], @@ -2226,9 +2252,9 @@ ] }, "node_modules/@typescript/native-preview-linux-arm64": { - "version": "7.0.0-dev.20251128.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm64/-/native-preview-linux-arm64-7.0.0-dev.20251128.1.tgz", - "integrity": "sha512-PW9vZorzVp22hVSm8Hd8Q6Uwokb28GUbpRZ+Ff2Rifm/fUafxuzjs6INZWfd+mwi7IuqAIbKvlmdKHieNQC9oA==", + "version": "7.0.0-dev.20251206.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm64/-/native-preview-linux-arm64-7.0.0-dev.20251206.1.tgz", + "integrity": "sha512-/KXFbcTuW/2k+CspZjraCVLNBnoDGFARYYaydKj/OrZwcGb1HgCT8X52ICzf0lHnc7W0slKAIzEtq8vonyCrAw==", "cpu": [ "arm64" ], @@ -2240,9 +2266,9 @@ ] }, "node_modules/@typescript/native-preview-linux-x64": { - "version": "7.0.0-dev.20251128.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-x64/-/native-preview-linux-x64-7.0.0-dev.20251128.1.tgz", - "integrity": "sha512-BlGrr1GgWGNwyY5kc0qh9BabqcDKeUhqxtXis/bG95rxQLGFCACiCwqXUC2LUp9TfaTIjzc/hxbcEQRsAOl1XQ==", + "version": "7.0.0-dev.20251206.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-x64/-/native-preview-linux-x64-7.0.0-dev.20251206.1.tgz", + "integrity": "sha512-qOKTazp43tE6imN/+y+s8MdtaJLnH98YILdr6V5pLVUxY1fiFnAX/LXv/zDd6ree6rEn102kLuaMGih7PNJqHw==", "cpu": [ "x64" ], @@ -2254,9 +2280,9 @@ ] }, "node_modules/@typescript/native-preview-win32-arm64": { - "version": "7.0.0-dev.20251128.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-arm64/-/native-preview-win32-arm64-7.0.0-dev.20251128.1.tgz", - "integrity": "sha512-dpscn71w6hCXuNDSu5aRnBSqZyNjIxUsIGkW/In5v+8/dEDKmOkIrEkkBPLTVjeQn6OWKmnCY02Wt8OLTGc1sw==", + "version": "7.0.0-dev.20251206.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-arm64/-/native-preview-win32-arm64-7.0.0-dev.20251206.1.tgz", + "integrity": "sha512-6p2aDhxDr5lBGetFlZMNs3+W5lzUnAIR6BVnoPBc/oiNqWY929Sh2kA0poTx2piUvpi8xUs6k1MWjonZP5UbgQ==", "cpu": [ "arm64" ], @@ -2268,9 +2294,9 @@ ] }, "node_modules/@typescript/native-preview-win32-x64": { - "version": "7.0.0-dev.20251128.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-x64/-/native-preview-win32-x64-7.0.0-dev.20251128.1.tgz", - "integrity": "sha512-96XD3QoGGzvuaK3FWk8dZoam9O3r0mFZngahmuWXs4IAMWv1Y9AUkXmGUlfwOgB0tgmDnbfv3jA2QZcV+idYAg==", + "version": "7.0.0-dev.20251206.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-x64/-/native-preview-win32-x64-7.0.0-dev.20251206.1.tgz", + "integrity": "sha512-fsLQ21YABSaIrtuHLs+9fL2s3kc8bHF7UB4TatMwnFCzGvQJ7L1b5snfSjnSZeh3ETTeING8f9YHjiIf0h4jpA==", "cpu": [ "x64" ], @@ -3149,9 +3175,10 @@ } }, "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.1.tgz", + "integrity": "sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA==", + "devOptional": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -3161,32 +3188,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" + "@esbuild/aix-ppc64": "0.27.1", + "@esbuild/android-arm": "0.27.1", + "@esbuild/android-arm64": "0.27.1", + "@esbuild/android-x64": "0.27.1", + "@esbuild/darwin-arm64": "0.27.1", + "@esbuild/darwin-x64": "0.27.1", + "@esbuild/freebsd-arm64": "0.27.1", + "@esbuild/freebsd-x64": "0.27.1", + "@esbuild/linux-arm": "0.27.1", + "@esbuild/linux-arm64": "0.27.1", + "@esbuild/linux-ia32": "0.27.1", + "@esbuild/linux-loong64": "0.27.1", + "@esbuild/linux-mips64el": "0.27.1", + "@esbuild/linux-ppc64": "0.27.1", + "@esbuild/linux-riscv64": "0.27.1", + "@esbuild/linux-s390x": "0.27.1", + "@esbuild/linux-x64": "0.27.1", + "@esbuild/netbsd-arm64": "0.27.1", + "@esbuild/netbsd-x64": "0.27.1", + "@esbuild/openbsd-arm64": "0.27.1", + "@esbuild/openbsd-x64": "0.27.1", + "@esbuild/openharmony-arm64": "0.27.1", + "@esbuild/sunos-x64": "0.27.1", + "@esbuild/win32-arm64": "0.27.1", + "@esbuild/win32-ia32": "0.27.1", + "@esbuild/win32-x64": "0.27.1" } }, "node_modules/escalade": { @@ -3661,7 +3688,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.10.7.tgz", "integrity": "sha512-icXIITfw/07Q88nLSkB9aiUrd8rYzSweK681Kjo/TSggaGbOX4RRyxxm71v+3PC8C/j+4rlxGeoTRxQDkaJkUw==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -4219,7 +4245,6 @@ "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.1.tgz", "integrity": "sha512-Ksr/8L3PTapbdXJCk+EJVB78jDodUMaP54gD24W186zGRARvwrsPfS60wae/SSCTCNZVPd1chXqio1qHQmu4NA==", "license": "BSD-3-Clause", - "peer": true, "dependencies": { "@lit/reactive-element": "^2.1.0", "lit-element": "^4.2.0", @@ -4260,10 +4285,10 @@ "license": "MIT" }, "node_modules/lru-cache": { - "version": "11.2.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", - "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==", - "license": "ISC", + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" } @@ -5284,7 +5309,6 @@ "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/dcastil" @@ -5313,8 +5337,7 @@ "version": "4.1.17", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tapable": { "version": "2.3.0", @@ -5450,13 +5473,13 @@ "license": "0BSD" }, "node_modules/tsx": { - "version": "4.20.6", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.6.tgz", - "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "devOptional": true, "license": "MIT", "dependencies": { - "esbuild": "~0.25.0", + "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "bin": { @@ -5518,11 +5541,10 @@ "license": "MIT" }, "node_modules/vite": { - "version": "7.2.4", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.4.tgz", - "integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==", + "version": "7.2.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.6.tgz", + "integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -5615,6 +5637,463 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, "node_modules/vitest": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", @@ -5965,7 +6444,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -5981,11 +6459,11 @@ }, "packages/agent": { "name": "@mariozechner/pi-agent-core", - "version": "0.12.15", + "version": "0.13.0", "license": "MIT", "dependencies": { - "@mariozechner/pi-ai": "^0.12.15", - "@mariozechner/pi-tui": "^0.12.15" + "@mariozechner/pi-ai": "^0.13.0", + "@mariozechner/pi-tui": "^0.13.0" }, "devDependencies": { "@types/node": "^24.3.0", @@ -6015,7 +6493,7 @@ }, "packages/ai": { "name": "@mariozechner/pi-ai", - "version": "0.12.15", + "version": "0.13.0", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.61.0", @@ -6056,12 +6534,12 @@ }, "packages/coding-agent": { "name": "@mariozechner/pi-coding-agent", - "version": "0.12.15", + "version": "0.13.0", "license": "MIT", "dependencies": { - "@mariozechner/pi-agent-core": "^0.12.15", - "@mariozechner/pi-ai": "^0.12.15", - "@mariozechner/pi-tui": "^0.12.15", + "@mariozechner/pi-agent-core": "^0.13.0", + "@mariozechner/pi-ai": "^0.13.0", + "@mariozechner/pi-tui": "^0.13.0", "chalk": "^5.5.0", "diff": "^8.0.2", "glob": "^11.0.3" @@ -6098,12 +6576,12 @@ }, "packages/mom": { "name": "@mariozechner/pi-mom", - "version": "0.12.15", + "version": "0.13.0", "license": "MIT", "dependencies": { "@anthropic-ai/sandbox-runtime": "^0.0.16", - "@mariozechner/pi-agent-core": "^0.12.15", - "@mariozechner/pi-ai": "^0.12.15", + "@mariozechner/pi-agent-core": "^0.13.0", + "@mariozechner/pi-ai": "^0.13.0", "@sinclair/typebox": "^0.34.0", "@slack/socket-mode": "^2.0.0", "@slack/web-api": "^7.0.0", @@ -6141,10 +6619,10 @@ }, "packages/pods": { "name": "@mariozechner/pi", - "version": "0.12.15", + "version": "0.13.0", "license": "MIT", "dependencies": { - "@mariozechner/pi-agent-core": "^0.12.15", + "@mariozechner/pi-agent-core": "^0.13.0", "chalk": "^5.5.0" }, "bin": { @@ -6157,7 +6635,7 @@ }, "packages/proxy": { "name": "@mariozechner/pi-proxy", - "version": "0.12.15", + "version": "0.13.0", "dependencies": { "@hono/node-server": "^1.14.0", "hono": "^4.6.16" @@ -6173,7 +6651,7 @@ }, "packages/tui": { "name": "@mariozechner/pi-tui", - "version": "0.12.15", + "version": "0.13.0", "license": "MIT", "dependencies": { "@types/mime-types": "^2.1.4", @@ -6217,12 +6695,12 @@ }, "packages/web-ui": { "name": "@mariozechner/pi-web-ui", - "version": "0.12.15", + "version": "0.13.0", "license": "MIT", "dependencies": { "@lmstudio/sdk": "^1.5.0", - "@mariozechner/pi-ai": "^0.12.15", - "@mariozechner/pi-tui": "^0.12.15", + "@mariozechner/pi-ai": "^0.13.0", + "@mariozechner/pi-tui": "^0.13.0", "docx-preview": "^0.3.7", "jszip": "^3.10.1", "lucide": "^0.544.0", @@ -6243,7 +6721,7 @@ }, "packages/web-ui/example": { "name": "pi-web-ui-example", - "version": "1.0.11", + "version": "1.1.0", "dependencies": { "@mariozechner/mini-lit": "^0.2.0", "@mariozechner/pi-ai": "file:../../ai", diff --git a/packages/agent/package.json b/packages/agent/package.json index 96da3a89..6d8fb366 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-agent-core", - "version": "0.12.15", + "version": "0.13.0", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "type": "module", "main": "./dist/index.js", @@ -18,8 +18,8 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@mariozechner/pi-ai": "^0.12.15", - "@mariozechner/pi-tui": "^0.12.15" + "@mariozechner/pi-ai": "^0.13.0", + "@mariozechner/pi-tui": "^0.13.0" }, "keywords": [ "ai", diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 2c59840d..9c43074a 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.13.0] - 2025-12-06 ### Breaking Changes diff --git a/packages/ai/package.json b/packages/ai/package.json index 2ee940bc..901b7264 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-ai", - "version": "0.12.15", + "version": "0.13.0", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index c8a738cd..4e8b0b84 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -5255,23 +5255,6 @@ export const MODELS = { contextWindow: 131072, maxTokens: 16384, } satisfies Model<"openai-completions">, - "meta-llama/llama-3.1-70b-instruct": { - id: "meta-llama/llama-3.1-70b-instruct", - name: "Meta: Llama 3.1 70B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.39999999999999997, - output: 0.39999999999999997, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "meta-llama/llama-3.1-405b-instruct": { id: "meta-llama/llama-3.1-405b-instruct", name: "Meta: Llama 3.1 405B Instruct", @@ -5289,6 +5272,23 @@ export const MODELS = { contextWindow: 130815, maxTokens: 4096, } satisfies Model<"openai-completions">, + "meta-llama/llama-3.1-70b-instruct": { + id: "meta-llama/llama-3.1-70b-instruct", + name: "Meta: Llama 3.1 70B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.39999999999999997, + output: 0.39999999999999997, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "mistralai/mistral-nemo": { id: "mistralai/mistral-nemo", name: "Mistral: Mistral Nemo", @@ -5306,9 +5306,9 @@ export const MODELS = { contextWindow: 131072, maxTokens: 16384, } satisfies Model<"openai-completions">, - "openai/gpt-4o-mini": { - id: "openai/gpt-4o-mini", - name: "OpenAI: GPT-4o-mini", + "openai/gpt-4o-mini-2024-07-18": { + id: "openai/gpt-4o-mini-2024-07-18", + name: "OpenAI: GPT-4o-mini (2024-07-18)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -5323,9 +5323,9 @@ export const MODELS = { contextWindow: 128000, maxTokens: 16384, } satisfies Model<"openai-completions">, - "openai/gpt-4o-mini-2024-07-18": { - id: "openai/gpt-4o-mini-2024-07-18", - name: "OpenAI: GPT-4o-mini (2024-07-18)", + "openai/gpt-4o-mini": { + id: "openai/gpt-4o-mini", + name: "OpenAI: GPT-4o-mini", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -5425,6 +5425,23 @@ export const MODELS = { contextWindow: 128000, maxTokens: 4096, } satisfies Model<"openai-completions">, + "openai/gpt-4o-2024-05-13": { + id: "openai/gpt-4o-2024-05-13", + name: "OpenAI: GPT-4o (2024-05-13)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 5, + output: 15, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "openai/gpt-4o": { id: "openai/gpt-4o", name: "OpenAI: GPT-4o", @@ -5459,22 +5476,22 @@ export const MODELS = { contextWindow: 128000, maxTokens: 64000, } satisfies Model<"openai-completions">, - "openai/gpt-4o-2024-05-13": { - id: "openai/gpt-4o-2024-05-13", - name: "OpenAI: GPT-4o (2024-05-13)", + "meta-llama/llama-3-70b-instruct": { + id: "meta-llama/llama-3-70b-instruct", + name: "Meta: Llama 3 70B Instruct", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: false, - input: ["text", "image"], + input: ["text"], cost: { - input: 5, - output: 15, + input: 0.3, + output: 0.39999999999999997, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 128000, - maxTokens: 4096, + contextWindow: 8192, + maxTokens: 16384, } satisfies Model<"openai-completions">, "meta-llama/llama-3-8b-instruct": { id: "meta-llama/llama-3-8b-instruct", @@ -5493,23 +5510,6 @@ export const MODELS = { contextWindow: 8192, maxTokens: 16384, } satisfies Model<"openai-completions">, - "meta-llama/llama-3-70b-instruct": { - id: "meta-llama/llama-3-70b-instruct", - name: "Meta: Llama 3 70B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.3, - output: 0.39999999999999997, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 8192, - maxTokens: 16384, - } satisfies Model<"openai-completions">, "mistralai/mixtral-8x22b-instruct": { id: "mistralai/mixtral-8x22b-instruct", name: "Mistral: Mixtral 8x22B Instruct", @@ -5595,23 +5595,6 @@ export const MODELS = { contextWindow: 128000, maxTokens: 4096, } satisfies Model<"openai-completions">, - "openai/gpt-4-turbo-preview": { - id: "openai/gpt-4-turbo-preview", - name: "OpenAI: GPT-4 Turbo Preview", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 10, - output: 30, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "openai/gpt-3.5-turbo-0613": { id: "openai/gpt-3.5-turbo-0613", name: "OpenAI: GPT-3.5 Turbo (older v0613)", @@ -5629,6 +5612,23 @@ export const MODELS = { contextWindow: 4095, maxTokens: 4096, } satisfies Model<"openai-completions">, + "openai/gpt-4-turbo-preview": { + id: "openai/gpt-4-turbo-preview", + name: "OpenAI: GPT-4 Turbo Preview", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 10, + output: 30, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "mistralai/mistral-tiny": { id: "mistralai/mistral-tiny", name: "Mistral Tiny", diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index f651cce4..71850815 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-coding-agent", - "version": "0.12.15", + "version": "0.13.0", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "type": "module", "piConfig": { @@ -28,9 +28,9 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@mariozechner/pi-agent-core": "^0.12.15", - "@mariozechner/pi-ai": "^0.12.15", - "@mariozechner/pi-tui": "^0.12.15", + "@mariozechner/pi-agent-core": "^0.13.0", + "@mariozechner/pi-ai": "^0.13.0", + "@mariozechner/pi-tui": "^0.13.0", "chalk": "^5.5.0", "diff": "^8.0.2", "glob": "^11.0.3" diff --git a/packages/mom/package.json b/packages/mom/package.json index 3bfd928a..2d283cbc 100644 --- a/packages/mom/package.json +++ b/packages/mom/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-mom", - "version": "0.12.15", + "version": "0.13.0", "description": "Slack bot that delegates messages to the pi coding agent", "type": "module", "bin": { @@ -21,8 +21,8 @@ }, "dependencies": { "@anthropic-ai/sandbox-runtime": "^0.0.16", - "@mariozechner/pi-agent-core": "^0.12.15", - "@mariozechner/pi-ai": "^0.12.15", + "@mariozechner/pi-agent-core": "^0.13.0", + "@mariozechner/pi-ai": "^0.13.0", "@sinclair/typebox": "^0.34.0", "@slack/socket-mode": "^2.0.0", "@slack/web-api": "^7.0.0", diff --git a/packages/pods/package.json b/packages/pods/package.json index fc52059c..88b91f8f 100644 --- a/packages/pods/package.json +++ b/packages/pods/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi", - "version": "0.12.15", + "version": "0.13.0", "description": "CLI tool for managing vLLM deployments on GPU pods", "type": "module", "bin": { @@ -34,7 +34,7 @@ "node": ">=20.0.0" }, "dependencies": { - "@mariozechner/pi-agent-core": "^0.12.15", + "@mariozechner/pi-agent-core": "^0.13.0", "chalk": "^5.5.0" }, "devDependencies": {} diff --git a/packages/proxy/package.json b/packages/proxy/package.json index 0c4903b5..a9aa9509 100644 --- a/packages/proxy/package.json +++ b/packages/proxy/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-proxy", - "version": "0.12.15", + "version": "0.13.0", "type": "module", "description": "CORS and authentication proxy for pi-ai", "main": "dist/index.js", diff --git a/packages/tui/package.json b/packages/tui/package.json index 4c4889fa..fdf9b96a 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-tui", - "version": "0.12.15", + "version": "0.13.0", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "type": "module", "main": "dist/index.js", diff --git a/packages/web-ui/example/package.json b/packages/web-ui/example/package.json index 76508afc..bc1f4a6e 100644 --- a/packages/web-ui/example/package.json +++ b/packages/web-ui/example/package.json @@ -1,6 +1,6 @@ { "name": "pi-web-ui-example", - "version": "1.0.11", + "version": "1.1.0", "private": true, "type": "module", "scripts": { diff --git a/packages/web-ui/package.json b/packages/web-ui/package.json index c03d6545..89578c42 100644 --- a/packages/web-ui/package.json +++ b/packages/web-ui/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-web-ui", - "version": "0.12.15", + "version": "0.13.0", "description": "Reusable web UI components for AI chat interfaces powered by @mariozechner/pi-ai", "type": "module", "main": "dist/index.js", @@ -18,8 +18,8 @@ }, "dependencies": { "@lmstudio/sdk": "^1.5.0", - "@mariozechner/pi-ai": "^0.12.15", - "@mariozechner/pi-tui": "^0.12.15", + "@mariozechner/pi-ai": "^0.13.0", + "@mariozechner/pi-tui": "^0.13.0", "docx-preview": "^0.3.7", "jszip": "^3.10.1", "lucide": "^0.544.0", From 2641424bfabd7c63dac01ef9618bd8fec780eec6 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 6 Dec 2025 22:49:45 +0100 Subject: [PATCH 43/54] Add [Unreleased] section to CHANGELOG --- packages/ai/CHANGELOG.md | 2 + packages/ai/src/models.generated.ts | 164 ++++++++++++++-------------- 2 files changed, 84 insertions(+), 82 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 9c43074a..eda79305 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.13.0] - 2025-12-06 ### Breaking Changes diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 4e8b0b84..130de3be 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -5153,23 +5153,6 @@ export const MODELS = { contextWindow: 32768, maxTokens: 4096, } satisfies Model<"openai-completions">, - "cohere/command-r-08-2024": { - id: "cohere/command-r-08-2024", - name: "Cohere: Command R (08-2024)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.15, - output: 0.6, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 4000, - } satisfies Model<"openai-completions">, "cohere/command-r-plus-08-2024": { id: "cohere/command-r-plus-08-2024", name: "Cohere: Command R+ (08-2024)", @@ -5187,6 +5170,23 @@ export const MODELS = { contextWindow: 128000, maxTokens: 4000, } satisfies Model<"openai-completions">, + "cohere/command-r-08-2024": { + id: "cohere/command-r-08-2024", + name: "Cohere: Command R (08-2024)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.15, + output: 0.6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4000, + } satisfies Model<"openai-completions">, "sao10k/l3.1-euryale-70b": { id: "sao10k/l3.1-euryale-70b", name: "Sao10K: Llama 3.1 Euryale 70B v2.2", @@ -5255,23 +5255,6 @@ export const MODELS = { contextWindow: 131072, maxTokens: 16384, } satisfies Model<"openai-completions">, - "meta-llama/llama-3.1-405b-instruct": { - id: "meta-llama/llama-3.1-405b-instruct", - name: "Meta: Llama 3.1 405B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 3.5, - output: 3.5, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 130815, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "meta-llama/llama-3.1-70b-instruct": { id: "meta-llama/llama-3.1-70b-instruct", name: "Meta: Llama 3.1 70B Instruct", @@ -5289,6 +5272,23 @@ export const MODELS = { contextWindow: 131072, maxTokens: 4096, } satisfies Model<"openai-completions">, + "meta-llama/llama-3.1-405b-instruct": { + id: "meta-llama/llama-3.1-405b-instruct", + name: "Meta: Llama 3.1 405B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 3.5, + output: 3.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 130815, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "mistralai/mistral-nemo": { id: "mistralai/mistral-nemo", name: "Mistral: Mistral Nemo", @@ -5306,9 +5306,9 @@ export const MODELS = { contextWindow: 131072, maxTokens: 16384, } satisfies Model<"openai-completions">, - "openai/gpt-4o-mini-2024-07-18": { - id: "openai/gpt-4o-mini-2024-07-18", - name: "OpenAI: GPT-4o-mini (2024-07-18)", + "openai/gpt-4o-mini": { + id: "openai/gpt-4o-mini", + name: "OpenAI: GPT-4o-mini", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -5323,9 +5323,9 @@ export const MODELS = { contextWindow: 128000, maxTokens: 16384, } satisfies Model<"openai-completions">, - "openai/gpt-4o-mini": { - id: "openai/gpt-4o-mini", - name: "OpenAI: GPT-4o-mini", + "openai/gpt-4o-mini-2024-07-18": { + id: "openai/gpt-4o-mini-2024-07-18", + name: "OpenAI: GPT-4o-mini (2024-07-18)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -5425,23 +5425,6 @@ export const MODELS = { contextWindow: 128000, maxTokens: 4096, } satisfies Model<"openai-completions">, - "openai/gpt-4o-2024-05-13": { - id: "openai/gpt-4o-2024-05-13", - name: "OpenAI: GPT-4o (2024-05-13)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 5, - output: 15, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "openai/gpt-4o": { id: "openai/gpt-4o", name: "OpenAI: GPT-4o", @@ -5476,22 +5459,22 @@ export const MODELS = { contextWindow: 128000, maxTokens: 64000, } satisfies Model<"openai-completions">, - "meta-llama/llama-3-70b-instruct": { - id: "meta-llama/llama-3-70b-instruct", - name: "Meta: Llama 3 70B Instruct", + "openai/gpt-4o-2024-05-13": { + id: "openai/gpt-4o-2024-05-13", + name: "OpenAI: GPT-4o (2024-05-13)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: false, - input: ["text"], + input: ["text", "image"], cost: { - input: 0.3, - output: 0.39999999999999997, + input: 5, + output: 15, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 8192, - maxTokens: 16384, + contextWindow: 128000, + maxTokens: 4096, } satisfies Model<"openai-completions">, "meta-llama/llama-3-8b-instruct": { id: "meta-llama/llama-3-8b-instruct", @@ -5510,6 +5493,23 @@ export const MODELS = { contextWindow: 8192, maxTokens: 16384, } satisfies Model<"openai-completions">, + "meta-llama/llama-3-70b-instruct": { + id: "meta-llama/llama-3-70b-instruct", + name: "Meta: Llama 3 70B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.3, + output: 0.39999999999999997, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 8192, + maxTokens: 16384, + } satisfies Model<"openai-completions">, "mistralai/mixtral-8x22b-instruct": { id: "mistralai/mixtral-8x22b-instruct", name: "Mistral: Mixtral 8x22B Instruct", @@ -5595,23 +5595,6 @@ export const MODELS = { contextWindow: 128000, maxTokens: 4096, } satisfies Model<"openai-completions">, - "openai/gpt-3.5-turbo-0613": { - id: "openai/gpt-3.5-turbo-0613", - name: "OpenAI: GPT-3.5 Turbo (older v0613)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 1, - output: 2, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 4095, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "openai/gpt-4-turbo-preview": { id: "openai/gpt-4-turbo-preview", name: "OpenAI: GPT-4 Turbo Preview", @@ -5629,6 +5612,23 @@ export const MODELS = { contextWindow: 128000, maxTokens: 4096, } satisfies Model<"openai-completions">, + "openai/gpt-3.5-turbo-0613": { + id: "openai/gpt-3.5-turbo-0613", + name: "OpenAI: GPT-3.5 Turbo (older v0613)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 1, + output: 2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 4095, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "mistralai/mistral-tiny": { id: "mistralai/mistral-tiny", name: "Mistral Tiny", From 95eadb9ed7f45e1c1267a71658b55df645414406 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 6 Dec 2025 23:12:36 +0100 Subject: [PATCH 44/54] Release v0.13.1 --- package-lock.json | 18 ++--- packages/agent/package.json | 6 +- packages/ai/package.json | 2 +- packages/coding-agent/CHANGELOG.md | 6 ++ packages/coding-agent/README.md | 26 ++++++- packages/coding-agent/package.json | 8 +-- packages/coding-agent/src/settings-manager.ts | 10 +++ packages/coding-agent/src/tools/bash.ts | 69 +++++++++++++++++-- packages/mom/package.json | 6 +- packages/pods/package.json | 4 +- packages/proxy/package.json | 2 +- packages/tui/package.json | 2 +- packages/web-ui/example/package.json | 2 +- packages/web-ui/package.json | 6 +- 14 files changed, 132 insertions(+), 35 deletions(-) diff --git a/package-lock.json b/package-lock.json index ba87f87c..376f964f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6459,7 +6459,7 @@ }, "packages/agent": { "name": "@mariozechner/pi-agent-core", - "version": "0.13.0", + "version": "0.13.1", "license": "MIT", "dependencies": { "@mariozechner/pi-ai": "^0.13.0", @@ -6493,7 +6493,7 @@ }, "packages/ai": { "name": "@mariozechner/pi-ai", - "version": "0.13.0", + "version": "0.13.1", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.61.0", @@ -6534,7 +6534,7 @@ }, "packages/coding-agent": { "name": "@mariozechner/pi-coding-agent", - "version": "0.13.0", + "version": "0.13.1", "license": "MIT", "dependencies": { "@mariozechner/pi-agent-core": "^0.13.0", @@ -6576,7 +6576,7 @@ }, "packages/mom": { "name": "@mariozechner/pi-mom", - "version": "0.13.0", + "version": "0.13.1", "license": "MIT", "dependencies": { "@anthropic-ai/sandbox-runtime": "^0.0.16", @@ -6619,7 +6619,7 @@ }, "packages/pods": { "name": "@mariozechner/pi", - "version": "0.13.0", + "version": "0.13.1", "license": "MIT", "dependencies": { "@mariozechner/pi-agent-core": "^0.13.0", @@ -6635,7 +6635,7 @@ }, "packages/proxy": { "name": "@mariozechner/pi-proxy", - "version": "0.13.0", + "version": "0.13.1", "dependencies": { "@hono/node-server": "^1.14.0", "hono": "^4.6.16" @@ -6651,7 +6651,7 @@ }, "packages/tui": { "name": "@mariozechner/pi-tui", - "version": "0.13.0", + "version": "0.13.1", "license": "MIT", "dependencies": { "@types/mime-types": "^2.1.4", @@ -6695,7 +6695,7 @@ }, "packages/web-ui": { "name": "@mariozechner/pi-web-ui", - "version": "0.13.0", + "version": "0.13.1", "license": "MIT", "dependencies": { "@lmstudio/sdk": "^1.5.0", @@ -6721,7 +6721,7 @@ }, "packages/web-ui/example": { "name": "pi-web-ui-example", - "version": "1.1.0", + "version": "1.1.1", "dependencies": { "@mariozechner/mini-lit": "^0.2.0", "@mariozechner/pi-ai": "file:../../ai", diff --git a/packages/agent/package.json b/packages/agent/package.json index 6d8fb366..b0c7f939 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-agent-core", - "version": "0.13.0", + "version": "0.13.1", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "type": "module", "main": "./dist/index.js", @@ -18,8 +18,8 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@mariozechner/pi-ai": "^0.13.0", - "@mariozechner/pi-tui": "^0.13.0" + "@mariozechner/pi-ai": "^0.13.1", + "@mariozechner/pi-tui": "^0.13.1" }, "keywords": [ "ai", diff --git a/packages/ai/package.json b/packages/ai/package.json index 901b7264..0f30ee27 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-ai", - "version": "0.13.0", + "version": "0.13.1", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 60844cfd..641a7d59 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +## [0.13.1] - 2025-12-06 + +### Added + +- **Flexible Windows shell configuration**: The bash tool now supports multiple shell sources beyond Git Bash. Resolution order: (1) custom `shellPath` in settings.json, (2) Git Bash in standard locations, (3) any bash.exe on PATH. This enables Cygwin, MSYS2, and other bash environments. Configure with `~/.pi/agent/settings.json`: `{"shellPath": "C:\\cygwin64\\bin\\bash.exe"}`. + ### Fixed - **Windows binary detection**: Fixed Bun compiled binary detection on Windows by checking for URL-encoded `%7EBUN` in addition to `$bunfs` and `~BUN` in `import.meta.url`. This ensures the binary correctly locates supporting files (package.json, themes, etc.) next to the executable. diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 302f80f8..5f47d103 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -2,11 +2,12 @@ A radically simple and opinionated coding agent with multi-model support (including mid-session switching), a simple yet powerful CLI for headless coding tasks, and many creature comforts you might be used to from other coding agents. -Works on Linux, macOS, and Windows (barely tested, needs Git Bash running in the "modern" Windows Terminal). +Works on Linux, macOS, and Windows (needs a bash shell, see [Windows Shell Configuration](#windows-shell-configuration)). ## Table of Contents - [Installation](#installation) +- [Windows Shell Configuration](#windows-shell-configuration) - [Quick Start](#quick-start) - [API Keys](#api-keys) - [OAuth Authentication (Optional)](#oauth-authentication-optional) @@ -81,6 +82,29 @@ npm run build:binary ./dist/pi ``` +## Windows Shell Configuration + +On Windows, pi requires a bash shell. The following locations are checked in order: + +1. **Custom shell path** from `~/.pi/agent/settings.json` (if configured) +2. **Git Bash** in standard locations (`C:\Program Files\Git\bin\bash.exe`) +3. **bash.exe on PATH** (Cygwin, MSYS2, WSL, etc.) + +For most users, installing [Git for Windows](https://git-scm.com/download/win) is sufficient. + +### Custom Shell Path + +If you use Cygwin, MSYS2, or have bash in a non-standard location, add the path to your settings: + +```json +// ~/.pi/agent/settings.json +{ + "shellPath": "C:\\cygwin64\\bin\\bash.exe" +} +``` + +Alternatively, ensure your bash is on the system PATH. + ## Quick Start ```bash diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 71850815..04209576 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-coding-agent", - "version": "0.13.0", + "version": "0.13.1", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "type": "module", "piConfig": { @@ -28,9 +28,9 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@mariozechner/pi-agent-core": "^0.13.0", - "@mariozechner/pi-ai": "^0.13.0", - "@mariozechner/pi-tui": "^0.13.0", + "@mariozechner/pi-agent-core": "^0.13.1", + "@mariozechner/pi-ai": "^0.13.1", + "@mariozechner/pi-tui": "^0.13.1", "chalk": "^5.5.0", "diff": "^8.0.2", "glob": "^11.0.3" diff --git a/packages/coding-agent/src/settings-manager.ts b/packages/coding-agent/src/settings-manager.ts index f9147f28..3b660b8f 100644 --- a/packages/coding-agent/src/settings-manager.ts +++ b/packages/coding-agent/src/settings-manager.ts @@ -17,6 +17,7 @@ export interface Settings { theme?: string; compaction?: CompactionSettings; hideThinkingBlock?: boolean; + shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows) } export class SettingsManager { @@ -153,4 +154,13 @@ export class SettingsManager { this.settings.hideThinkingBlock = hide; this.save(); } + + getShellPath(): string | undefined { + return this.settings.shellPath; + } + + setShellPath(path: string | undefined): void { + this.settings.shellPath = path; + this.save(); + } } diff --git a/packages/coding-agent/src/tools/bash.ts b/packages/coding-agent/src/tools/bash.ts index 4171f95c..fc138fb6 100644 --- a/packages/coding-agent/src/tools/bash.ts +++ b/packages/coding-agent/src/tools/bash.ts @@ -1,13 +1,57 @@ import type { AgentTool } from "@mariozechner/pi-ai"; import { Type } from "@sinclair/typebox"; -import { spawn } from "child_process"; +import { spawn, spawnSync } from "child_process"; import { existsSync } from "fs"; +import { SettingsManager } from "../settings-manager.js"; + +let cachedShellConfig: { shell: string; args: string[] } | null = null; /** - * Get shell configuration based on platform + * 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) */ 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) { @@ -20,16 +64,29 @@ function getShellConfig(): { shell: string; args: string[] } { for (const path of paths) { if (existsSync(path)) { - return { shell: path, args: ["-c"] }; + 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( - `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")}`, + `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")}`, ); } - return { shell: "sh", args: ["-c"] }; + + cachedShellConfig = { shell: "sh", args: ["-c"] }; + return cachedShellConfig; } /** diff --git a/packages/mom/package.json b/packages/mom/package.json index 2d283cbc..f476dd21 100644 --- a/packages/mom/package.json +++ b/packages/mom/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-mom", - "version": "0.13.0", + "version": "0.13.1", "description": "Slack bot that delegates messages to the pi coding agent", "type": "module", "bin": { @@ -21,8 +21,8 @@ }, "dependencies": { "@anthropic-ai/sandbox-runtime": "^0.0.16", - "@mariozechner/pi-agent-core": "^0.13.0", - "@mariozechner/pi-ai": "^0.13.0", + "@mariozechner/pi-agent-core": "^0.13.1", + "@mariozechner/pi-ai": "^0.13.1", "@sinclair/typebox": "^0.34.0", "@slack/socket-mode": "^2.0.0", "@slack/web-api": "^7.0.0", diff --git a/packages/pods/package.json b/packages/pods/package.json index 88b91f8f..eca33948 100644 --- a/packages/pods/package.json +++ b/packages/pods/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi", - "version": "0.13.0", + "version": "0.13.1", "description": "CLI tool for managing vLLM deployments on GPU pods", "type": "module", "bin": { @@ -34,7 +34,7 @@ "node": ">=20.0.0" }, "dependencies": { - "@mariozechner/pi-agent-core": "^0.13.0", + "@mariozechner/pi-agent-core": "^0.13.1", "chalk": "^5.5.0" }, "devDependencies": {} diff --git a/packages/proxy/package.json b/packages/proxy/package.json index a9aa9509..eb036d50 100644 --- a/packages/proxy/package.json +++ b/packages/proxy/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-proxy", - "version": "0.13.0", + "version": "0.13.1", "type": "module", "description": "CORS and authentication proxy for pi-ai", "main": "dist/index.js", diff --git a/packages/tui/package.json b/packages/tui/package.json index fdf9b96a..6c11ad57 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-tui", - "version": "0.13.0", + "version": "0.13.1", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "type": "module", "main": "dist/index.js", diff --git a/packages/web-ui/example/package.json b/packages/web-ui/example/package.json index bc1f4a6e..b2f2eea7 100644 --- a/packages/web-ui/example/package.json +++ b/packages/web-ui/example/package.json @@ -1,6 +1,6 @@ { "name": "pi-web-ui-example", - "version": "1.1.0", + "version": "1.1.1", "private": true, "type": "module", "scripts": { diff --git a/packages/web-ui/package.json b/packages/web-ui/package.json index 89578c42..fc2f4a19 100644 --- a/packages/web-ui/package.json +++ b/packages/web-ui/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-web-ui", - "version": "0.13.0", + "version": "0.13.1", "description": "Reusable web UI components for AI chat interfaces powered by @mariozechner/pi-ai", "type": "module", "main": "dist/index.js", @@ -18,8 +18,8 @@ }, "dependencies": { "@lmstudio/sdk": "^1.5.0", - "@mariozechner/pi-ai": "^0.13.0", - "@mariozechner/pi-tui": "^0.13.0", + "@mariozechner/pi-ai": "^0.13.1", + "@mariozechner/pi-tui": "^0.13.1", "docx-preview": "^0.3.7", "jszip": "^3.10.1", "lucide": "^0.544.0", From de77cd1419093f42b5592f7def1d8fd1325005bc Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 7 Dec 2025 00:03:16 +0100 Subject: [PATCH 45/54] Add tool output truncation with line/byte limits - Add truncate.ts utility with truncateHead/truncateTail functions - Both respect 2000 line and 30KB limits (whichever hits first) - read: head truncation, returns truncation info in details - bash: tail truncation, writes full output to temp file if large - grep: head truncation + 100 match limit - find: head truncation + 1000 result limit - ls: head truncation + 500 entry limit - tool-execution.ts displays truncation notices in warning color - All tools return clean output + structured truncation in details --- packages/coding-agent/src/tools/bash.ts | 137 ++++++---- packages/coding-agent/src/tools/find.ts | 92 ++++--- packages/coding-agent/src/tools/grep.ts | 27 +- packages/coding-agent/src/tools/ls.ts | 34 ++- packages/coding-agent/src/tools/read.ts | 231 ++++++++-------- packages/coding-agent/src/tools/truncate.ts | 252 ++++++++++++++++++ .../coding-agent/src/tui/tool-execution.ts | 57 +++- 7 files changed, 611 insertions(+), 219 deletions(-) create mode 100644 packages/coding-agent/src/tools/truncate.ts diff --git a/packages/coding-agent/src/tools/bash.ts b/packages/coding-agent/src/tools/bash.ts index fc138fb6..836e8a79 100644 --- a/packages/coding-agent/src/tools/bash.ts +++ b/packages/coding-agent/src/tools/bash.ts @@ -1,8 +1,12 @@ +import { randomBytes } from "node:crypto"; +import { createWriteStream, existsSync } 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, spawnSync } from "child_process"; -import { existsSync } from "fs"; import { SettingsManager } from "../settings-manager.js"; +import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, type TruncationResult, truncateTail } from "./truncate.js"; let cachedShellConfig: { shell: string; args: string[] } | null = null; @@ -118,31 +122,52 @@ function killProcessTree(pid: number): void { } } +/** + * Generate a unique temp file path for bash output + */ +function getTempFilePath(): string { + const id = randomBytes(8).toString("hex"); + return join(tmpdir(), `pi-bash-${id}.log`); +} + const bashSchema = Type.Object({ command: Type.String({ description: "Bash command to execute" }), timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })), }); +interface BashToolDetails { + truncation?: TruncationResult; + fullOutputPath?: string; +} + export const bashTool: AgentTool = { 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 | 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 @@ -154,26 +179,41 @@ export const bashTool: AgentTool = { }, 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 @@ -185,44 +225,49 @@ export const bashTool: AgentTool = { 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, + }; } 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 }); } }); diff --git a/packages/coding-agent/src/tools/find.ts b/packages/coding-agent/src/tools/find.ts index 36838890..cb8a7336 100644 --- a/packages/coding-agent/src/tools/find.ts +++ b/packages/coding-agent/src/tools/find.ts @@ -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, 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 = { 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 = { 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,56 @@ export const findTool: AgentTool = { } 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); + } + + const rawOutput = relativized.join("\n"); + let details: FindToolDetails | undefined; + + // Check if we hit the result limit + const hitResultLimit = relativized.length >= effectiveLimit; + + // Apply byte truncation + const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER }); + const resultOutput = truncation.content; + + // Include truncation info in details (result limit or byte limit) + if (hitResultLimit || truncation.truncated) { + details = { + truncation: truncation.truncated ? truncation : undefined, + resultLimitReached: hitResultLimit ? effectiveLimit : undefined, + }; + } + + resolve({ content: [{ type: "text", text: resultOutput }], details }); } catch (e: any) { signal?.removeEventListener("abort", onAbort); reject(e); diff --git a/packages/coding-agent/src/tools/grep.ts b/packages/coding-agent/src/tools/grep.ts index 38b8d9df..185a12dd 100644 --- a/packages/coding-agent/src/tools/grep.ts +++ b/packages/coding-agent/src/tools/grep.ts @@ -6,6 +6,7 @@ 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, type TruncationResult, truncateHead } from "./truncate.js"; /** * Expand ~ to home directory @@ -36,11 +37,15 @@ const grepSchema = Type.Object({ const DEFAULT_LIMIT = 100; +interface GrepToolDetails { + truncation?: TruncationResult; + matchLimitReached?: number; +} + export const grepTool: AgentTool = { 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).`, parameters: grepSchema, execute: async ( _toolCallId: string, @@ -251,12 +256,22 @@ export const grepTool: AgentTool = { return; } - let output = outputLines.join("\n"); - if (truncated) { - output += `\n\n(truncated, limit of ${effectiveLimit} matches reached)`; + // Apply byte truncation + const rawOutput = outputLines.join("\n"); + const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER }); + + const output = truncation.content; + let details: GrepToolDetails | undefined; + + // Include truncation info in details (match limit or byte limit) + if (truncated || truncation.truncated) { + details = { + truncation: truncation.truncated ? truncation : undefined, + matchLimitReached: truncated ? effectiveLimit : undefined, + }; } - settle(() => resolve({ content: [{ type: "text", text: output }], details: undefined })); + settle(() => resolve({ content: [{ type: "text", text: output }], details })); }); } catch (err) { settle(() => reject(err as Error)); diff --git a/packages/coding-agent/src/tools/ls.ts b/packages/coding-agent/src/tools/ls.ts index 18c88ebe..6dcb4749 100644 --- a/packages/coding-agent/src/tools/ls.ts +++ b/packages/coding-agent/src/tools/ls.ts @@ -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, 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 = { 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) => { @@ -97,16 +102,27 @@ export const lsTool: AgentTool = { 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 }); + const rawOutput = results.join("\n"); + let details: LsToolDetails | undefined; + + // Apply byte truncation + const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER }); + const output = truncation.content; + + // Include truncation info in details (entry limit or byte limit) + if (truncated || truncation.truncated) { + details = { + truncation: truncation.truncated ? truncation : undefined, + entryLimitReached: truncated ? effectiveLimit : undefined, + }; + } + + resolve({ content: [{ type: "text", text: output }], details }); } catch (e: any) { signal?.removeEventListener("abort", onAbort); reject(e); diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index 26128af0..13c3daca 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -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, 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 = { 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,115 @@ export const readTool: AgentTool = { 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 lines = textContent.split("\n"); + + // Apply offset if specified (1-indexed to 0-indexed) + const startLine = offset ? Math.max(0, offset - 1) : 0; + + // 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)`); + } + + // If limit is specified by user, use it; otherwise we'll let truncateHead decide + let selectedContent: string; + if (limit !== undefined) { + const endLine = Math.min(startLine + limit, lines.length); + selectedContent = lines.slice(startLine, endLine).join("\n"); + } else { + selectedContent = lines.slice(startLine).join("\n"); + } + + // Apply truncation (respects both line and byte limits) + const truncation = truncateHead(selectedContent); + + let outputText = truncation.content; + + // Add continuation hint if there's more content after our selection + // (only relevant when user specified limit and there's more in the file) + if (limit !== undefined && startLine + limit < lines.length && !truncation.truncated) { + const remaining = lines.length - (startLine + limit); + outputText += `\n\n[${remaining} more lines in file. Use offset=${startLine + limit + 1} to continue]`; + } + + content = [{ type: "text", text: outputText }]; + + // Include truncation info in details if truncation occurred + if (truncation.truncated) { + details = { truncation }; + } + } + + // 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); + } + } + })(); + }, + ); }, }; diff --git a/packages/coding-agent/src/tools/truncate.ts b/packages/coding-agent/src/tools/truncate.ts new file mode 100644 index 00000000..9f8be37e --- /dev/null +++ b/packages/coding-agent/src/tools/truncate.ts @@ -0,0 +1,252 @@ +/** + * 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) + */ + +export const DEFAULT_MAX_LINES = 2000; +export const DEFAULT_MAX_BYTES = 30 * 1024; // 30KB + +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 lines in the truncated output */ + outputLines: number; + /** Number of bytes in the truncated output */ + outputBytes: number; + /** Human-readable truncation notice (empty if not truncated) */ + notice: string; +} + +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. + */ +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`; + } +} + +/** + * Generate a truncation notice. + */ +function makeNotice( + direction: "head" | "tail", + truncatedBy: "lines" | "bytes", + totalLines: number, + totalBytes: number, + outputLines: number, + outputBytes: number, +): string { + const totalSize = formatSize(totalBytes); + const outputSize = formatSize(outputBytes); + const directionText = direction === "head" ? "first" : "last"; + + if (truncatedBy === "lines") { + return `[Truncated: ${totalLines} lines / ${totalSize} total, showing ${directionText} ${outputLines} lines]`; + } else { + return `[Truncated: ${totalLines} lines / ${totalSize} total, showing ${directionText} ${outputSize}]`; + } +} + +/** + * Truncate content from the head (keep first N lines/bytes). + * Suitable for file reads where you want to see the beginning. + */ +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, + notice: "", + }; + } + + // Determine which limit we'll hit first + 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"; + // If this is the first line and it alone exceeds maxBytes, include partial + if (i === 0) { + const truncatedLine = truncateStringToBytes(line, maxBytes); + outputLinesArr.push(truncatedLine); + outputBytesCount = Buffer.byteLength(truncatedLine, "utf-8"); + } + 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, + notice: makeNotice("head", truncatedBy, totalLines, totalBytes, outputLinesArr.length, finalOutputBytes), + }; +} + +/** + * Truncate content from the tail (keep last N lines/bytes). + * Suitable for bash output where you want to see the end (errors, final results). + */ +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, + notice: "", + }; + } + + // Work backwards from the end + const outputLinesArr: string[] = []; + let outputBytesCount = 0; + let truncatedBy: "lines" | "bytes" = "lines"; + + 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"; + // If this is the first line we're adding and it alone exceeds maxBytes, include partial + if (outputLinesArr.length === 0) { + // Take the end of the line + const truncatedLine = truncateStringToBytesFromEnd(line, maxBytes); + outputLinesArr.unshift(truncatedLine); + outputBytesCount = Buffer.byteLength(truncatedLine, "utf-8"); + } + 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, + notice: makeNotice("tail", truncatedBy, totalLines, totalBytes, outputLinesArr.length, finalOutputBytes), + }; +} + +/** + * Truncate a string to fit within a byte limit (from the start). + * Handles multi-byte UTF-8 characters correctly. + */ +function truncateStringToBytes(str: string, maxBytes: number): string { + const buf = Buffer.from(str, "utf-8"); + if (buf.length <= maxBytes) { + return str; + } + + // Find a valid UTF-8 boundary + let end = maxBytes; + while (end > 0 && (buf[end] & 0xc0) === 0x80) { + end--; + } + + return buf.slice(0, end).toString("utf-8"); +} + +/** + * 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"); +} diff --git a/packages/coding-agent/src/tui/tool-execution.ts b/packages/coding-agent/src/tui/tool-execution.ts index 8f7c66d6..576c3669 100644 --- a/packages/coding-agent/src/tui/tool-execution.ts +++ b/packages/coding-agent/src/tui/tool-execution.ts @@ -115,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"); @@ -128,17 +127,28 @@ export class ToolExecutionComponent extends Container { text += theme.fg("toolOutput", `\n... (${remaining} more lines)`); } } + + // Show truncation notice at the bottom in warning color if present in details + const truncation = this.result.details?.truncation; + const fullOutputPath = this.result.details?.fullOutputPath; + if (truncation?.truncated) { + if (fullOutputPath) { + text += "\n" + theme.fg("warning", `[Full output: ${fullOutputPath}]`); + } + text += "\n" + theme.fg("warning", truncation.notice); + } } } 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; @@ -146,6 +156,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; @@ -154,6 +165,12 @@ export class ToolExecutionComponent extends Container { if (remaining > 0) { text += theme.fg("toolOutput", `\n... (${remaining} more lines)`); } + + // Show truncation notice at the bottom in warning color if present in details + const truncation = this.result.details?.truncation; + if (truncation?.truncated) { + text += "\n" + theme.fg("warning", truncation.notice); + } } } else if (this.toolName === "write") { const path = shortenPath(this.args?.file_path || this.args?.path || ""); @@ -231,6 +248,16 @@ export class ToolExecutionComponent extends Container { text += theme.fg("toolOutput", `\n... (${remaining} more lines)`); } } + + // Show truncation notice from details + const entryLimit = this.result.details?.entryLimitReached; + const truncation = this.result.details?.truncation; + if (entryLimit) { + text += "\n" + theme.fg("warning", `[Truncated: ${entryLimit} entries limit reached]`); + } + if (truncation?.truncated) { + text += "\n" + theme.fg("warning", truncation.notice); + } } } else if (this.toolName === "find") { const pattern = this.args?.pattern || ""; @@ -259,6 +286,16 @@ export class ToolExecutionComponent extends Container { text += theme.fg("toolOutput", `\n... (${remaining} more lines)`); } } + + // Show truncation notice from details + const resultLimit = this.result.details?.resultLimitReached; + const truncation = this.result.details?.truncation; + if (resultLimit) { + text += "\n" + theme.fg("warning", `[Truncated: ${resultLimit} results limit reached]`); + } + if (truncation?.truncated) { + text += "\n" + theme.fg("warning", truncation.notice); + } } } else if (this.toolName === "grep") { const pattern = this.args?.pattern || ""; @@ -291,6 +328,16 @@ export class ToolExecutionComponent extends Container { text += theme.fg("toolOutput", `\n... (${remaining} more lines)`); } } + + // Show truncation notice from details + const matchLimit = this.result.details?.matchLimitReached; + const truncation = this.result.details?.truncation; + if (matchLimit) { + text += "\n" + theme.fg("warning", `[Truncated: ${matchLimit} matches limit reached]`); + } + if (truncation?.truncated) { + text += "\n" + theme.fg("warning", truncation.notice); + } } } else { // Generic tool From b813a8b92bf9b906608b9644490c9fec424f8476 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 7 Dec 2025 01:11:31 +0100 Subject: [PATCH 46/54] Implement tool result truncation with actionable notices (#134) - read: actionable notices with offset for continuation - First line > 30KB: return empty + bash command suggestion - Hit limit: '[Showing lines X-Y of Z. Use offset=N to continue]' - bash: tail truncation with temp file - Notice includes line range + temp file path - Edge case: last line > 30KB shows partial - grep: pre-truncate match lines to 500 chars - '[... truncated]' suffix on long lines - Notice for match limit and line truncation - find/ls: result/entry limit notices - '[N results limit reached. Use limit=M for more]' - All notices now in text content (LLM sees them) - TUI simplified (notices render as part of output) - Never return partial lines (except bash edge case) --- packages/ai/test/context-overflow.test.ts | 3 +- packages/coding-agent/docs/truncation.md | 235 ++++++++++++++++++ packages/coding-agent/src/tools/bash.ts | 17 +- packages/coding-agent/src/tools/find.ts | 42 ++-- packages/coding-agent/src/tools/grep.ts | 72 ++++-- packages/coding-agent/src/tools/ls.ts | 40 +-- packages/coding-agent/src/tools/read.ts | 59 +++-- packages/coding-agent/src/tools/truncate.ts | 115 +++++---- .../coding-agent/src/tui/tool-execution.ts | 46 +--- 9 files changed, 465 insertions(+), 164 deletions(-) create mode 100644 packages/coding-agent/docs/truncation.md diff --git a/packages/ai/test/context-overflow.test.ts b/packages/ai/test/context-overflow.test.ts index 94f3e4e0..e2fcce04 100644 --- a/packages/ai/test/context-overflow.test.ts +++ b/packages/ai/test/context-overflow.test.ts @@ -118,7 +118,8 @@ describe("Context overflow error handling", () => { describe.skipIf(!process.env.OPENAI_API_KEY)("OpenAI Completions", () => { it("gpt-4o-mini - should detect overflow via isContextOverflow", async () => { - const model = getModel("openai", "gpt-4o-mini"); + const model = { ...getModel("openai", "gpt-4o-mini") }; + model.api = "openai-completions" as any; const result = await testContextOverflow(model, process.env.OPENAI_API_KEY!); logResult(result); diff --git a/packages/coding-agent/docs/truncation.md b/packages/coding-agent/docs/truncation.md new file mode 100644 index 00000000..7651ca54 --- /dev/null +++ b/packages/coding-agent/docs/truncation.md @@ -0,0 +1,235 @@ +# Tool Output Truncation + +## Limits + +- **Line limit**: 2000 lines +- **Byte limit**: 30KB +- **Grep line limit**: 500 chars per match line + +Whichever limit is hit first wins. **Never return partial lines** (except bash edge case). + +--- + +## read + +Head truncation (first N lines). Has offset/limit params for continuation. + +### Scenarios + +**First line > 30KB:** +``` +LLM sees: +[Line 1 is 50KB, exceeds 30KB limit. Use bash to read: head -c 30000 path/to/file] + +Details: +{ truncation: { truncated: true, truncatedBy: "bytes", outputLines: 0, ... } } +``` + +**Hit line limit (2000 lines, < 30KB):** +``` +LLM sees: +[lines 1-2000 content] + +[Showing lines 1-2000 of 5000. Use offset=2001 to continue] + +Details: +{ truncation: { truncated: true, truncatedBy: "lines", outputLines: 2000, totalLines: 5000 } } +``` + +**Hit byte limit (< 2000 lines, 30KB):** +``` +LLM sees: +[lines 1-500 content] + +[Showing lines 1-500 of 5000 (30KB limit). Use offset=501 to continue] + +Details: +{ truncation: { truncated: true, truncatedBy: "bytes", outputLines: 500, totalLines: 5000 } } +``` + +**With offset, hit line limit (e.g., offset=1000):** +``` +LLM sees: +[lines 1000-2999 content] + +[Showing lines 1000-2999 of 5000. Use offset=3000 to continue] + +Details: +{ truncation: { truncatedBy: "lines", ... } } +``` + +**With offset, hit byte limit (e.g., offset=1000, 30KB after 500 lines):** +``` +LLM sees: +[lines 1000-1499 content] + +[Showing lines 1000-1499 of 5000 (30KB limit). Use offset=1500 to continue] + +Details: +{ truncation: { truncatedBy: "bytes", outputLines: 500, ... } } +``` + +**With offset, first line at offset > 30KB (e.g., offset=1000, line 1000 is 50KB):** +``` +LLM sees: +[Line 1000 is 50KB, exceeds 30KB limit. Use bash: sed -n '1000p' file | head -c 30000] + +Details: +{ truncation: { truncated: true, truncatedBy: "bytes", outputLines: 0 } } +``` + +--- + +## bash + +Tail truncation (last N lines). Writes full output to temp file if truncated. + +### Scenarios + +**Hit line limit (2000 lines):** +``` +LLM sees: +[lines 48001-50000 content] + +[Showing lines 48001-50000 of 50000. Full output: /tmp/pi-bash-xxx.log] + +Details: +{ truncation: { truncated: true, truncatedBy: "lines", outputLines: 2000, totalLines: 50000 }, fullOutputPath: "/tmp/..." } +``` + +**Hit byte limit (< 2000 lines, 30KB):** +``` +LLM sees: +[lines 49501-50000 content] + +[Showing lines 49501-50000 of 50000 (30KB limit). Full output: /tmp/pi-bash-xxx.log] + +Details: +{ truncation: { truncatedBy: "bytes", ... }, fullOutputPath: "/tmp/..." } +``` + +**Last line alone > 30KB (edge case, partial OK here):** +``` +LLM sees: +[last 30KB of final line] + +[Showing last 30KB of line 50000 (line is 100KB). Full output: /tmp/pi-bash-xxx.log] + +Details: +{ truncation: { truncatedBy: "bytes", lastLinePartial: true }, fullOutputPath: "/tmp/..." } +``` + +--- + +## grep + +Head truncation. Primary limit: 100 matches. Each match line truncated to 500 chars. + +### Scenarios + +**Hit match limit (100 matches):** +``` +LLM sees: +file.ts:10: matching content here... +file.ts:25: another match... +... + +[100 matches limit reached. Use limit=200 for more, or refine pattern] + +Details: +{ matchLimitReached: 100 } +``` + +**Hit byte limit (< 100 matches, 30KB):** +``` +LLM sees: +[matches that fit in 30KB] + +[30KB limit reached (50 of 100+ matches shown)] + +Details: +{ truncation: { truncatedBy: "bytes", ... } } +``` + +**Match lines truncated (any line > 500 chars):** +``` +LLM sees: +file.ts:10: very long matching content that exceeds 500 chars gets cut off here... [truncated] +file.ts:25: normal match + +[Some lines truncated to 500 chars. Use read tool to see full lines] + +Details: +{ linesTruncated: true } +``` + +--- + +## find + +Head truncation. Primary limit: 1000 results. File paths only (never > 30KB each). + +### Scenarios + +**Hit result limit (1000 results):** +``` +LLM sees: +src/file1.ts +src/file2.ts +[998 more paths] + +[1000 results limit reached. Use limit=2000 for more, or refine pattern] + +Details: +{ resultLimitReached: 1000 } +``` + +**Hit byte limit (unlikely, < 1000 results, 30KB):** +``` +LLM sees: +[paths that fit] + +[30KB limit reached] + +Details: +{ truncation: { truncatedBy: "bytes", ... } } +``` + +--- + +## ls + +Head truncation. Primary limit: 500 entries. Entry names only (never > 30KB each). + +### Scenarios + +**Hit entry limit (500 entries):** +``` +LLM sees: +.gitignore +README.md +src/ +[497 more entries] + +[500 entries limit reached. Use limit=1000 for more] + +Details: +{ entryLimitReached: 500 } +``` + +**Hit byte limit (unlikely):** +``` +LLM sees: +[entries that fit] + +[30KB limit reached] + +Details: +{ truncation: { truncatedBy: "bytes", ... } } +``` + +--- + +## TUI Display + +`tool-execution.ts` reads `details.truncation` and related fields to display truncation notices in warning color. The LLM text content and TUI display show the same information. diff --git a/packages/coding-agent/src/tools/bash.ts b/packages/coding-agent/src/tools/bash.ts index 836e8a79..26a9c90d 100644 --- a/packages/coding-agent/src/tools/bash.ts +++ b/packages/coding-agent/src/tools/bash.ts @@ -6,7 +6,7 @@ import type { AgentTool } from "@mariozechner/pi-ai"; import { Type } from "@sinclair/typebox"; import { spawn, spawnSync } from "child_process"; import { SettingsManager } from "../settings-manager.js"; -import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, type TruncationResult, truncateTail } from "./truncate.js"; +import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateTail } from "./truncate.js"; let cachedShellConfig: { shell: string; args: string[] } | null = null; @@ -256,11 +256,26 @@ export const bashTool: AgentTool = { // 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) { diff --git a/packages/coding-agent/src/tools/find.ts b/packages/coding-agent/src/tools/find.ts index cb8a7336..6cc4b0c0 100644 --- a/packages/coding-agent/src/tools/find.ts +++ b/packages/coding-agent/src/tools/find.ts @@ -6,7 +6,7 @@ import { globSync } from "glob"; import { homedir } from "os"; import path from "path"; import { ensureTool } from "../tools-manager.js"; -import { DEFAULT_MAX_BYTES, type TruncationResult, truncateHead } from "./truncate.js"; +import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.js"; /** * Expand ~ to home directory @@ -160,25 +160,39 @@ export const findTool: AgentTool = { relativized.push(relativePath); } - const rawOutput = relativized.join("\n"); - let details: FindToolDetails | undefined; - // Check if we hit the result limit - const hitResultLimit = relativized.length >= effectiveLimit; + const resultLimitReached = relativized.length >= effectiveLimit; - // Apply byte truncation + // 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 }); - const resultOutput = truncation.content; - // Include truncation info in details (result limit or byte limit) - if (hitResultLimit || truncation.truncated) { - details = { - truncation: truncation.truncated ? truncation : undefined, - resultLimitReached: hitResultLimit ? effectiveLimit : undefined, - }; + 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; } - resolve({ content: [{ type: "text", text: resultOutput }], details }); + 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); diff --git a/packages/coding-agent/src/tools/grep.ts b/packages/coding-agent/src/tools/grep.ts index 185a12dd..d984a6df 100644 --- a/packages/coding-agent/src/tools/grep.ts +++ b/packages/coding-agent/src/tools/grep.ts @@ -6,7 +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, type TruncationResult, truncateHead } from "./truncate.js"; +import { + DEFAULT_MAX_BYTES, + formatSize, + GREP_MAX_LINE_LENGTH, + type TruncationResult, + truncateHead, + truncateLine, +} from "./truncate.js"; /** * Expand ~ to home directory @@ -40,12 +47,13 @@ const DEFAULT_LIMIT = 100; interface GrepToolDetails { truncation?: TruncationResult; matchLimitReached?: number; + linesTruncated?: boolean; } export const grepTool: AgentTool = { name: "grep", label: "grep", - 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).`, + 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, @@ -148,7 +156,8 @@ export const grepTool: AgentTool = { 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[] = []; @@ -176,7 +185,7 @@ export const grepTool: AgentTool = { 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) { @@ -192,10 +201,16 @@ export const grepTool: AgentTool = { 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}`); } } @@ -224,7 +239,7 @@ export const grepTool: AgentTool = { } if (matchCount >= effectiveLimit) { - truncated = true; + matchLimitReached = true; stopChild(true); } } @@ -256,22 +271,45 @@ export const grepTool: AgentTool = { return; } - // Apply byte truncation + // 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 }); - const output = truncation.content; - let details: GrepToolDetails | undefined; + let output = truncation.content; + const details: GrepToolDetails = {}; - // Include truncation info in details (match limit or byte limit) - if (truncated || truncation.truncated) { - details = { - truncation: truncation.truncated ? truncation : undefined, - matchLimitReached: truncated ? effectiveLimit : undefined, - }; + // 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 })); + 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)); diff --git a/packages/coding-agent/src/tools/ls.ts b/packages/coding-agent/src/tools/ls.ts index 6dcb4749..9b9c4b56 100644 --- a/packages/coding-agent/src/tools/ls.ts +++ b/packages/coding-agent/src/tools/ls.ts @@ -3,7 +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, type TruncationResult, truncateHead } from "./truncate.js"; +import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.js"; /** * Expand ~ to home directory @@ -76,11 +76,11 @@ export const lsTool: AgentTool = { // 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; } @@ -107,22 +107,34 @@ export const lsTool: AgentTool = { return; } + // Apply byte truncation (no line limit since we already have entry limit) const rawOutput = results.join("\n"); - let details: LsToolDetails | undefined; - - // Apply byte truncation const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER }); - const output = truncation.content; - // Include truncation info in details (entry limit or byte limit) - if (truncated || truncation.truncated) { - details = { - truncation: truncation.truncated ? truncation : undefined, - entryLimitReached: truncated ? effectiveLimit : undefined, - }; + 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; } - resolve({ content: [{ type: "text", text: output }], details }); + 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); diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index 13c3daca..2634000d 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -4,7 +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, type TruncationResult, truncateHead } from "./truncate.js"; +import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateHead } from "./truncate.js"; /** * Expand ~ to home directory @@ -108,43 +108,66 @@ export const readTool: AgentTool = { } else { // Read as text const textContent = await readFile(absolutePath, "utf-8"); - const lines = textContent.split("\n"); + 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 >= lines.length) { - throw new Error(`Offset ${offset} is beyond end of file (${lines.length} lines total)`); + 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, lines.length); - selectedContent = lines.slice(startLine, endLine).join("\n"); + const endLine = Math.min(startLine + limit, allLines.length); + selectedContent = allLines.slice(startLine, endLine).join("\n"); + userLimitedLines = endLine - startLine; } else { - selectedContent = lines.slice(startLine).join("\n"); + selectedContent = allLines.slice(startLine).join("\n"); } // Apply truncation (respects both line and byte limits) const truncation = truncateHead(selectedContent); - let outputText = truncation.content; + let outputText: string; - // Add continuation hint if there's more content after our selection - // (only relevant when user specified limit and there's more in the file) - if (limit !== undefined && startLine + limit < lines.length && !truncation.truncated) { - const remaining = lines.length - (startLine + limit); - outputText += `\n\n[${remaining} more lines in file. Use offset=${startLine + limit + 1} to continue]`; + 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 }]; - - // Include truncation info in details if truncation occurred - if (truncation.truncated) { - details = { truncation }; - } } // Check if aborted after reading diff --git a/packages/coding-agent/src/tools/truncate.ts b/packages/coding-agent/src/tools/truncate.ts index 9f8be37e..94fba575 100644 --- a/packages/coding-agent/src/tools/truncate.ts +++ b/packages/coding-agent/src/tools/truncate.ts @@ -4,10 +4,13 @@ * 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 = 30 * 1024; // 30KB +export const GREP_MAX_LINE_LENGTH = 500; // Max chars per grep match line export interface TruncationResult { /** The truncated content */ @@ -20,12 +23,14 @@ export interface TruncationResult { totalLines: number; /** Total number of bytes in the original content */ totalBytes: number; - /** Number of lines in the truncated output */ + /** Number of complete lines in the truncated output */ outputLines: number; /** Number of bytes in the truncated output */ outputBytes: number; - /** Human-readable truncation notice (empty if not truncated) */ - notice: string; + /** 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 { @@ -38,7 +43,7 @@ export interface TruncationOptions { /** * Format bytes as human-readable size. */ -function formatSize(bytes: number): string { +export function formatSize(bytes: number): string { if (bytes < 1024) { return `${bytes}B`; } else if (bytes < 1024 * 1024) { @@ -48,31 +53,12 @@ function formatSize(bytes: number): string { } } -/** - * Generate a truncation notice. - */ -function makeNotice( - direction: "head" | "tail", - truncatedBy: "lines" | "bytes", - totalLines: number, - totalBytes: number, - outputLines: number, - outputBytes: number, -): string { - const totalSize = formatSize(totalBytes); - const outputSize = formatSize(outputBytes); - const directionText = direction === "head" ? "first" : "last"; - - if (truncatedBy === "lines") { - return `[Truncated: ${totalLines} lines / ${totalSize} total, showing ${directionText} ${outputLines} lines]`; - } else { - return `[Truncated: ${totalLines} lines / ${totalSize} total, showing ${directionText} ${outputSize}]`; - } -} - /** * 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; @@ -92,11 +78,28 @@ export function truncateHead(content: string, options: TruncationOptions = {}): totalBytes, outputLines: totalLines, outputBytes: totalBytes, - notice: "", + lastLinePartial: false, + firstLineExceedsLimit: false, }; } - // Determine which limit we'll hit first + // 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"; @@ -107,12 +110,6 @@ export function truncateHead(content: string, options: TruncationOptions = {}): if (outputBytesCount + lineBytes > maxBytes) { truncatedBy = "bytes"; - // If this is the first line and it alone exceeds maxBytes, include partial - if (i === 0) { - const truncatedLine = truncateStringToBytes(line, maxBytes); - outputLinesArr.push(truncatedLine); - outputBytesCount = Buffer.byteLength(truncatedLine, "utf-8"); - } break; } @@ -136,13 +133,16 @@ export function truncateHead(content: string, options: TruncationOptions = {}): totalBytes, outputLines: outputLinesArr.length, outputBytes: finalOutputBytes, - notice: makeNotice("head", truncatedBy, totalLines, totalBytes, outputLinesArr.length, 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; @@ -162,7 +162,8 @@ export function truncateTail(content: string, options: TruncationOptions = {}): totalBytes, outputLines: totalLines, outputBytes: totalBytes, - notice: "", + lastLinePartial: false, + firstLineExceedsLimit: false, }; } @@ -170,6 +171,7 @@ export function truncateTail(content: string, options: TruncationOptions = {}): 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]; @@ -177,12 +179,13 @@ export function truncateTail(content: string, options: TruncationOptions = {}): if (outputBytesCount + lineBytes > maxBytes) { truncatedBy = "bytes"; - // If this is the first line we're adding and it alone exceeds maxBytes, include partial + // 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) { - // Take the end of the line const truncatedLine = truncateStringToBytesFromEnd(line, maxBytes); outputLinesArr.unshift(truncatedLine); outputBytesCount = Buffer.byteLength(truncatedLine, "utf-8"); + lastLinePartial = true; } break; } @@ -207,29 +210,11 @@ export function truncateTail(content: string, options: TruncationOptions = {}): totalBytes, outputLines: outputLinesArr.length, outputBytes: finalOutputBytes, - notice: makeNotice("tail", truncatedBy, totalLines, totalBytes, outputLinesArr.length, finalOutputBytes), + lastLinePartial, + firstLineExceedsLimit: false, }; } -/** - * Truncate a string to fit within a byte limit (from the start). - * Handles multi-byte UTF-8 characters correctly. - */ -function truncateStringToBytes(str: string, maxBytes: number): string { - const buf = Buffer.from(str, "utf-8"); - if (buf.length <= maxBytes) { - return str; - } - - // Find a valid UTF-8 boundary - let end = maxBytes; - while (end > 0 && (buf[end] & 0xc0) === 0x80) { - end--; - } - - return buf.slice(0, end).toString("utf-8"); -} - /** * Truncate a string to fit within a byte limit (from the end). * Handles multi-byte UTF-8 characters correctly. @@ -250,3 +235,17 @@ function truncateStringToBytesFromEnd(str: string, maxBytes: number): string { 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 }; +} diff --git a/packages/coding-agent/src/tui/tool-execution.ts b/packages/coding-agent/src/tui/tool-execution.ts index 576c3669..6dd9d872 100644 --- a/packages/coding-agent/src/tui/tool-execution.ts +++ b/packages/coding-agent/src/tui/tool-execution.ts @@ -128,15 +128,7 @@ export class ToolExecutionComponent extends Container { } } - // Show truncation notice at the bottom in warning color if present in details - const truncation = this.result.details?.truncation; - const fullOutputPath = this.result.details?.fullOutputPath; - if (truncation?.truncated) { - if (fullOutputPath) { - text += "\n" + theme.fg("warning", `[Full output: ${fullOutputPath}]`); - } - text += "\n" + theme.fg("warning", truncation.notice); - } + // Truncation notice is now in the text content itself, TUI just shows it } } else if (this.toolName === "read") { const path = shortenPath(this.args?.file_path || this.args?.path || ""); @@ -166,11 +158,7 @@ export class ToolExecutionComponent extends Container { text += theme.fg("toolOutput", `\n... (${remaining} more lines)`); } - // Show truncation notice at the bottom in warning color if present in details - const truncation = this.result.details?.truncation; - if (truncation?.truncated) { - text += "\n" + theme.fg("warning", truncation.notice); - } + // Truncation notice is now in the text content itself } } else if (this.toolName === "write") { const path = shortenPath(this.args?.file_path || this.args?.path || ""); @@ -249,15 +237,7 @@ export class ToolExecutionComponent extends Container { } } - // Show truncation notice from details - const entryLimit = this.result.details?.entryLimitReached; - const truncation = this.result.details?.truncation; - if (entryLimit) { - text += "\n" + theme.fg("warning", `[Truncated: ${entryLimit} entries limit reached]`); - } - if (truncation?.truncated) { - text += "\n" + theme.fg("warning", truncation.notice); - } + // Truncation notice is now in the text content itself } } else if (this.toolName === "find") { const pattern = this.args?.pattern || ""; @@ -287,15 +267,7 @@ export class ToolExecutionComponent extends Container { } } - // Show truncation notice from details - const resultLimit = this.result.details?.resultLimitReached; - const truncation = this.result.details?.truncation; - if (resultLimit) { - text += "\n" + theme.fg("warning", `[Truncated: ${resultLimit} results limit reached]`); - } - if (truncation?.truncated) { - text += "\n" + theme.fg("warning", truncation.notice); - } + // Truncation notice is now in the text content itself } } else if (this.toolName === "grep") { const pattern = this.args?.pattern || ""; @@ -329,15 +301,7 @@ export class ToolExecutionComponent extends Container { } } - // Show truncation notice from details - const matchLimit = this.result.details?.matchLimitReached; - const truncation = this.result.details?.truncation; - if (matchLimit) { - text += "\n" + theme.fg("warning", `[Truncated: ${matchLimit} matches limit reached]`); - } - if (truncation?.truncated) { - text += "\n" + theme.fg("warning", truncation.notice); - } + // Truncation notice is now in the text content itself } } else { // Generic tool From 5a549cc7da4d5da4661a084ef3ee71b957cdb0e3 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 7 Dec 2025 01:14:57 +0100 Subject: [PATCH 47/54] Restore TUI warning notices for truncated tool output Warnings now shown at bottom of tool execution (outside collapsed area) so users can see truncation occurred even when content is collapsed. --- .../coding-agent/src/tui/tool-execution.ts | 81 +++++++++++++++++-- 1 file changed, 76 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/src/tui/tool-execution.ts b/packages/coding-agent/src/tui/tool-execution.ts index 6dd9d872..e0071806 100644 --- a/packages/coding-agent/src/tui/tool-execution.ts +++ b/packages/coding-agent/src/tui/tool-execution.ts @@ -128,7 +128,23 @@ export class ToolExecutionComponent extends Container { } } - // Truncation notice is now in the text content itself, TUI just shows it + // 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 || ""); @@ -158,7 +174,22 @@ export class ToolExecutionComponent extends Container { text += theme.fg("toolOutput", `\n... (${remaining} more lines)`); } - // Truncation notice is now in the text content itself + // 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 || ""); @@ -237,7 +268,19 @@ export class ToolExecutionComponent extends Container { } } - // Truncation notice is now in the text content itself + // 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 || ""; @@ -267,7 +310,19 @@ export class ToolExecutionComponent extends Container { } } - // Truncation notice is now in the text content itself + // 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 || ""; @@ -301,7 +356,23 @@ export class ToolExecutionComponent extends Container { } } - // Truncation notice is now in the text content itself + // 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 From 306f9cc6609754cc3270ad475f60b134a99dadbe Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 7 Dec 2025 01:24:41 +0100 Subject: [PATCH 48/54] Add changelog entry for tool output truncation (#134) --- packages/coding-agent/CHANGELOG.md | 10 ++++++++++ packages/coding-agent/src/tools/truncate.ts | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 641a7d59..57054112 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,16 @@ ## [Unreleased] +### Changed + +- **Tool output truncation**: All tools now enforce consistent truncation limits with actionable notices for the LLM. ([#134](https://github.com/badlogic/pi-mono/issues/134)) + - **Limits**: 2000 lines OR 50KB (whichever hits first), never partial lines + - **read**: Shows `[Showing lines X-Y of Z. Use offset=N to continue]`. If first line exceeds 50KB, suggests bash command + - **bash**: Tail truncation with temp file. Shows `[Showing lines X-Y of Z. Full output: /tmp/...]` + - **grep**: Pre-truncates match lines to 500 chars. Shows match limit and line truncation notices + - **find/ls**: Shows result/entry limit notices + - TUI displays truncation warnings in yellow at bottom of tool output (visible even when collapsed) + ## [0.13.1] - 2025-12-06 ### Added diff --git a/packages/coding-agent/src/tools/truncate.ts b/packages/coding-agent/src/tools/truncate.ts index 94fba575..cf297555 100644 --- a/packages/coding-agent/src/tools/truncate.ts +++ b/packages/coding-agent/src/tools/truncate.ts @@ -9,7 +9,7 @@ */ export const DEFAULT_MAX_LINES = 2000; -export const DEFAULT_MAX_BYTES = 30 * 1024; // 30KB +export const DEFAULT_MAX_BYTES = 50 * 1024; // 50KB export const GREP_MAX_LINE_LENGTH = 500; // Max chars per grep match line export interface TruncationResult { From a0bbc292015d3ae0f5b8aaa07d6b5e00c15adedb Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 7 Dec 2025 01:25:17 +0100 Subject: [PATCH 49/54] Release v0.13.2 --- package-lock.json | 38 ++++++++++++++-------------- packages/agent/package.json | 6 ++--- packages/ai/package.json | 2 +- packages/coding-agent/CHANGELOG.md | 2 ++ packages/coding-agent/package.json | 8 +++--- packages/mom/package.json | 6 ++--- packages/pods/package.json | 4 +-- packages/proxy/package.json | 2 +- packages/tui/package.json | 2 +- packages/web-ui/example/package.json | 2 +- packages/web-ui/package.json | 6 ++--- 11 files changed, 40 insertions(+), 38 deletions(-) diff --git a/package-lock.json b/package-lock.json index 376f964f..44632dcd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6459,11 +6459,11 @@ }, "packages/agent": { "name": "@mariozechner/pi-agent-core", - "version": "0.13.1", + "version": "0.13.2", "license": "MIT", "dependencies": { - "@mariozechner/pi-ai": "^0.13.0", - "@mariozechner/pi-tui": "^0.13.0" + "@mariozechner/pi-ai": "^0.13.1", + "@mariozechner/pi-tui": "^0.13.1" }, "devDependencies": { "@types/node": "^24.3.0", @@ -6493,7 +6493,7 @@ }, "packages/ai": { "name": "@mariozechner/pi-ai", - "version": "0.13.1", + "version": "0.13.2", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.61.0", @@ -6534,12 +6534,12 @@ }, "packages/coding-agent": { "name": "@mariozechner/pi-coding-agent", - "version": "0.13.1", + "version": "0.13.2", "license": "MIT", "dependencies": { - "@mariozechner/pi-agent-core": "^0.13.0", - "@mariozechner/pi-ai": "^0.13.0", - "@mariozechner/pi-tui": "^0.13.0", + "@mariozechner/pi-agent-core": "^0.13.1", + "@mariozechner/pi-ai": "^0.13.1", + "@mariozechner/pi-tui": "^0.13.1", "chalk": "^5.5.0", "diff": "^8.0.2", "glob": "^11.0.3" @@ -6576,12 +6576,12 @@ }, "packages/mom": { "name": "@mariozechner/pi-mom", - "version": "0.13.1", + "version": "0.13.2", "license": "MIT", "dependencies": { "@anthropic-ai/sandbox-runtime": "^0.0.16", - "@mariozechner/pi-agent-core": "^0.13.0", - "@mariozechner/pi-ai": "^0.13.0", + "@mariozechner/pi-agent-core": "^0.13.1", + "@mariozechner/pi-ai": "^0.13.1", "@sinclair/typebox": "^0.34.0", "@slack/socket-mode": "^2.0.0", "@slack/web-api": "^7.0.0", @@ -6619,10 +6619,10 @@ }, "packages/pods": { "name": "@mariozechner/pi", - "version": "0.13.1", + "version": "0.13.2", "license": "MIT", "dependencies": { - "@mariozechner/pi-agent-core": "^0.13.0", + "@mariozechner/pi-agent-core": "^0.13.1", "chalk": "^5.5.0" }, "bin": { @@ -6635,7 +6635,7 @@ }, "packages/proxy": { "name": "@mariozechner/pi-proxy", - "version": "0.13.1", + "version": "0.13.2", "dependencies": { "@hono/node-server": "^1.14.0", "hono": "^4.6.16" @@ -6651,7 +6651,7 @@ }, "packages/tui": { "name": "@mariozechner/pi-tui", - "version": "0.13.1", + "version": "0.13.2", "license": "MIT", "dependencies": { "@types/mime-types": "^2.1.4", @@ -6695,12 +6695,12 @@ }, "packages/web-ui": { "name": "@mariozechner/pi-web-ui", - "version": "0.13.1", + "version": "0.13.2", "license": "MIT", "dependencies": { "@lmstudio/sdk": "^1.5.0", - "@mariozechner/pi-ai": "^0.13.0", - "@mariozechner/pi-tui": "^0.13.0", + "@mariozechner/pi-ai": "^0.13.1", + "@mariozechner/pi-tui": "^0.13.1", "docx-preview": "^0.3.7", "jszip": "^3.10.1", "lucide": "^0.544.0", @@ -6721,7 +6721,7 @@ }, "packages/web-ui/example": { "name": "pi-web-ui-example", - "version": "1.1.1", + "version": "1.1.2", "dependencies": { "@mariozechner/mini-lit": "^0.2.0", "@mariozechner/pi-ai": "file:../../ai", diff --git a/packages/agent/package.json b/packages/agent/package.json index b0c7f939..b8f072f9 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-agent-core", - "version": "0.13.1", + "version": "0.13.2", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "type": "module", "main": "./dist/index.js", @@ -18,8 +18,8 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@mariozechner/pi-ai": "^0.13.1", - "@mariozechner/pi-tui": "^0.13.1" + "@mariozechner/pi-ai": "^0.13.2", + "@mariozechner/pi-tui": "^0.13.2" }, "keywords": [ "ai", diff --git a/packages/ai/package.json b/packages/ai/package.json index 0f30ee27..fa94b59f 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-ai", - "version": "0.13.1", + "version": "0.13.2", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 57054112..355bd294 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +## [0.13.2] - 2025-12-07 + ### Changed - **Tool output truncation**: All tools now enforce consistent truncation limits with actionable notices for the LLM. ([#134](https://github.com/badlogic/pi-mono/issues/134)) diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 04209576..d18ae575 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-coding-agent", - "version": "0.13.1", + "version": "0.13.2", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "type": "module", "piConfig": { @@ -28,9 +28,9 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@mariozechner/pi-agent-core": "^0.13.1", - "@mariozechner/pi-ai": "^0.13.1", - "@mariozechner/pi-tui": "^0.13.1", + "@mariozechner/pi-agent-core": "^0.13.2", + "@mariozechner/pi-ai": "^0.13.2", + "@mariozechner/pi-tui": "^0.13.2", "chalk": "^5.5.0", "diff": "^8.0.2", "glob": "^11.0.3" diff --git a/packages/mom/package.json b/packages/mom/package.json index f476dd21..44dc24c2 100644 --- a/packages/mom/package.json +++ b/packages/mom/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-mom", - "version": "0.13.1", + "version": "0.13.2", "description": "Slack bot that delegates messages to the pi coding agent", "type": "module", "bin": { @@ -21,8 +21,8 @@ }, "dependencies": { "@anthropic-ai/sandbox-runtime": "^0.0.16", - "@mariozechner/pi-agent-core": "^0.13.1", - "@mariozechner/pi-ai": "^0.13.1", + "@mariozechner/pi-agent-core": "^0.13.2", + "@mariozechner/pi-ai": "^0.13.2", "@sinclair/typebox": "^0.34.0", "@slack/socket-mode": "^2.0.0", "@slack/web-api": "^7.0.0", diff --git a/packages/pods/package.json b/packages/pods/package.json index eca33948..593659b6 100644 --- a/packages/pods/package.json +++ b/packages/pods/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi", - "version": "0.13.1", + "version": "0.13.2", "description": "CLI tool for managing vLLM deployments on GPU pods", "type": "module", "bin": { @@ -34,7 +34,7 @@ "node": ">=20.0.0" }, "dependencies": { - "@mariozechner/pi-agent-core": "^0.13.1", + "@mariozechner/pi-agent-core": "^0.13.2", "chalk": "^5.5.0" }, "devDependencies": {} diff --git a/packages/proxy/package.json b/packages/proxy/package.json index eb036d50..2ed52712 100644 --- a/packages/proxy/package.json +++ b/packages/proxy/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-proxy", - "version": "0.13.1", + "version": "0.13.2", "type": "module", "description": "CORS and authentication proxy for pi-ai", "main": "dist/index.js", diff --git a/packages/tui/package.json b/packages/tui/package.json index 6c11ad57..f0568445 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-tui", - "version": "0.13.1", + "version": "0.13.2", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "type": "module", "main": "dist/index.js", diff --git a/packages/web-ui/example/package.json b/packages/web-ui/example/package.json index b2f2eea7..e9072ca4 100644 --- a/packages/web-ui/example/package.json +++ b/packages/web-ui/example/package.json @@ -1,6 +1,6 @@ { "name": "pi-web-ui-example", - "version": "1.1.1", + "version": "1.1.2", "private": true, "type": "module", "scripts": { diff --git a/packages/web-ui/package.json b/packages/web-ui/package.json index fc2f4a19..23cdeaa9 100644 --- a/packages/web-ui/package.json +++ b/packages/web-ui/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-web-ui", - "version": "0.13.1", + "version": "0.13.2", "description": "Reusable web UI components for AI chat interfaces powered by @mariozechner/pi-ai", "type": "module", "main": "dist/index.js", @@ -18,8 +18,8 @@ }, "dependencies": { "@lmstudio/sdk": "^1.5.0", - "@mariozechner/pi-ai": "^0.13.1", - "@mariozechner/pi-tui": "^0.13.1", + "@mariozechner/pi-ai": "^0.13.2", + "@mariozechner/pi-tui": "^0.13.2", "docx-preview": "^0.3.7", "jszip": "^3.10.1", "lucide": "^0.544.0", From 2e3ff4a15a53afcd68cc3b76f9710ba860af9e32 Mon Sep 17 00:00:00 2001 From: Nico Bailon Date: Sun, 7 Dec 2025 03:07:15 -0800 Subject: [PATCH 50/54] Fix truncation test assertions to match new message format (#136) --- packages/coding-agent/test/tools.test.ts | 50 ++++++++++++------------ 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index 8331315b..9eeebd1e 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -43,7 +43,8 @@ describe("Coding Agent Tools", () => { const result = await readTool.execute("test-call-1", { path: testFile }); expect(getTextOutput(result)).toBe(content); - expect(getTextOutput(result)).not.toContain("more lines not shown"); + // No truncation message since file fits within limits + expect(getTextOutput(result)).not.toContain("Use offset="); expect(result.details).toBeUndefined(); }); @@ -64,23 +65,21 @@ describe("Coding Agent Tools", () => { expect(output).toContain("Line 1"); expect(output).toContain("Line 2000"); expect(output).not.toContain("Line 2001"); - expect(output).toContain("500 more lines not shown"); - expect(output).toContain("Use offset=2001 to continue reading"); + expect(output).toContain("[Showing lines 1-2000 of 2500. Use offset=2001 to continue]"); }); - it("should truncate long lines and show notice", async () => { - const testFile = join(testDir, "long-lines.txt"); - const longLine = "a".repeat(3000); - const content = `Short line\n${longLine}\nAnother short line`; - writeFileSync(testFile, content); + it("should truncate when byte limit exceeded", async () => { + const testFile = join(testDir, "large-bytes.txt"); + // Create file that exceeds 50KB byte limit but has fewer than 2000 lines + const lines = Array.from({ length: 500 }, (_, i) => `Line ${i + 1}: ${"x".repeat(200)}`); + writeFileSync(testFile, lines.join("\n")); const result = await readTool.execute("test-call-4", { path: testFile }); const output = getTextOutput(result); - expect(output).toContain("Short line"); - expect(output).toContain("Another short line"); - expect(output).toContain("Some lines were truncated to 2000 characters"); - expect(output.split("\n")[1].length).toBe(2000); + expect(output).toContain("Line 1:"); + // Should show byte limit message + expect(output).toMatch(/\[Showing lines 1-\d+ of 500 \(.* limit\)\. Use offset=\d+ to continue\]/); }); it("should handle offset parameter", async () => { @@ -94,7 +93,8 @@ describe("Coding Agent Tools", () => { expect(output).not.toContain("Line 50"); expect(output).toContain("Line 51"); expect(output).toContain("Line 100"); - expect(output).not.toContain("more lines not shown"); + // No truncation message since file fits within limits + expect(output).not.toContain("Use offset="); }); it("should handle limit parameter", async () => { @@ -108,8 +108,7 @@ describe("Coding Agent Tools", () => { expect(output).toContain("Line 1"); expect(output).toContain("Line 10"); expect(output).not.toContain("Line 11"); - expect(output).toContain("90 more lines not shown"); - expect(output).toContain("Use offset=11 to continue reading"); + expect(output).toContain("[90 more lines in file. Use offset=11 to continue]"); }); it("should handle offset + limit together", async () => { @@ -128,8 +127,7 @@ describe("Coding Agent Tools", () => { expect(output).toContain("Line 41"); expect(output).toContain("Line 60"); expect(output).not.toContain("Line 61"); - expect(output).toContain("40 more lines not shown"); - expect(output).toContain("Use offset=61 to continue reading"); + expect(output).toContain("[40 more lines in file. Use offset=61 to continue]"); }); it("should show error when offset is beyond file length", async () => { @@ -141,17 +139,19 @@ describe("Coding Agent Tools", () => { ); }); - it("should show both truncation notices when applicable", async () => { - const testFile = join(testDir, "both-truncations.txt"); - const longLine = "b".repeat(3000); - const lines = Array.from({ length: 2500 }, (_, i) => (i === 500 ? longLine : `Line ${i + 1}`)); + it("should include truncation details when truncated", async () => { + const testFile = join(testDir, "large-file.txt"); + const lines = Array.from({ length: 2500 }, (_, i) => `Line ${i + 1}`); writeFileSync(testFile, lines.join("\n")); const result = await readTool.execute("test-call-9", { path: testFile }); - const output = getTextOutput(result); - expect(output).toContain("Some lines were truncated to 2000 characters"); - expect(output).toContain("500 more lines not shown"); + expect(result.details).toBeDefined(); + expect(result.details?.truncation).toBeDefined(); + expect(result.details?.truncation?.truncated).toBe(true); + expect(result.details?.truncation?.truncatedBy).toBe("lines"); + expect(result.details?.truncation?.totalLines).toBe(2500); + expect(result.details?.truncation?.outputLines).toBe(2000); }); }); @@ -276,7 +276,7 @@ describe("Coding Agent Tools", () => { expect(output).toContain("context.txt-1- before"); expect(output).toContain("context.txt:2: match one"); expect(output).toContain("context.txt-3- after"); - expect(output).toContain("(truncated, limit of 1 matches reached)"); + expect(output).toContain("[1 matches limit reached. Use limit=2 for more, or refine pattern]"); // Ensure second match is not present expect(output).not.toContain("match two"); }); From 01963082664876bdc4e2f405a46453724ec2406a Mon Sep 17 00:00:00 2001 From: Markus Ylisiurunen Date: Sun, 7 Dec 2025 17:24:06 +0200 Subject: [PATCH 51/54] add option to skip provider tool call validation --- packages/agent/src/agent.ts | 1 + packages/agent/src/transports/AppTransport.ts | 2 ++ packages/agent/src/transports/ProviderTransport.ts | 1 + packages/agent/src/transports/types.ts | 1 + packages/ai/CHANGELOG.md | 4 ++++ packages/ai/README.md | 14 ++++++++++++++ packages/ai/src/providers/anthropic.ts | 3 ++- packages/ai/src/providers/google.ts | 3 ++- packages/ai/src/providers/openai-completions.ts | 3 ++- packages/ai/src/providers/openai-responses.ts | 3 ++- packages/ai/src/stream.ts | 1 + packages/ai/src/types.ts | 6 ++++++ 12 files changed, 38 insertions(+), 4 deletions(-) diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 9897984d..df39e925 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -221,6 +221,7 @@ export class Agent { tools: this._state.tools, model, reasoning, + validateToolCallsAtProvider: false, getQueuedMessages: async () => { // Return queued messages based on queue mode if (this.queueMode === "one-at-a-time") { diff --git a/packages/agent/src/transports/AppTransport.ts b/packages/agent/src/transports/AppTransport.ts index 5beb9dc6..0404f37a 100644 --- a/packages/agent/src/transports/AppTransport.ts +++ b/packages/agent/src/transports/AppTransport.ts @@ -77,6 +77,7 @@ function streamSimpleProxy( temperature: options.temperature, maxTokens: options.maxTokens, reasoning: options.reasoning, + validateToolCallsAtProvider: options.validateToolCallsAtProvider, // Don't send apiKey or signal - those are added server-side }, }), @@ -365,6 +366,7 @@ export class AppTransport implements AgentTransport { model: cfg.model, reasoning: cfg.reasoning, getQueuedMessages: cfg.getQueuedMessages, + validateToolCallsAtProvider: cfg.validateToolCallsAtProvider ?? false, }; // Yield events from the upstream agentLoop iterator diff --git a/packages/agent/src/transports/ProviderTransport.ts b/packages/agent/src/transports/ProviderTransport.ts index c46b16c0..1435b160 100644 --- a/packages/agent/src/transports/ProviderTransport.ts +++ b/packages/agent/src/transports/ProviderTransport.ts @@ -65,6 +65,7 @@ export class ProviderTransport implements AgentTransport { reasoning: cfg.reasoning, apiKey, getQueuedMessages: cfg.getQueuedMessages, + validateToolCallsAtProvider: cfg.validateToolCallsAtProvider ?? false, }; // Yield events from agentLoop diff --git a/packages/agent/src/transports/types.ts b/packages/agent/src/transports/types.ts index d5d4053c..43982a69 100644 --- a/packages/agent/src/transports/types.ts +++ b/packages/agent/src/transports/types.ts @@ -9,6 +9,7 @@ export interface AgentRunConfig { model: Model; reasoning?: "low" | "medium" | "high"; getQueuedMessages?: () => Promise[]>; + validateToolCallsAtProvider?: boolean; } /** diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index eda79305..9cd5b522 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added `validateToolCallsAtProvider` option to streaming and agent APIs to optionally skip provider-level tool-call validation (default on), allowing agent loops to surface schema errors as toolResult messages and retry. + ## [0.13.0] - 2025-12-06 ### Breaking Changes diff --git a/packages/ai/README.md b/packages/ai/README.md index c633fa76..acd4b78e 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -270,6 +270,20 @@ for await (const event of s) { - Full validation only occurs at `toolcall_end` when arguments are complete - The Google provider does not support function call streaming. Instead, you will receive a single `toolcall_delta` event with the full arguments. +### Provider tool-call validation + +By default, providers validate streamed tool calls against your tool schema and abort the stream on validation errors. Set `validateToolCallsAtProvider: false` on `stream`, `streamSimple`, `complete`, `completeSimple`, or `AgentLoopConfig` to skip provider-level validation and let downstream code (for example, `agentLoop` via `executeToolCalls` → `validateToolArguments`) surface schema errors as `toolResult` messages. This enables the model to retry after receiving a validation error. + +```typescript +await streamSimple(model, context, { + apiKey: 'your-key', + validateToolCallsAtProvider: false +}); +``` + +- `true` (default): Provider validates tool calls and emits an error if arguments do not match the schema +- `false`: Provider emits tool calls even when arguments are invalid; callers must validate and handle errors themselves + ### Complete Event Reference All streaming events emitted during assistant message generation: diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index e2e91be2..d3421553 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -92,6 +92,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( options?: AnthropicOptions, ): AssistantMessageEventStream => { const stream = new AssistantMessageEventStream(); + const shouldValidateToolCalls = options?.validateToolCallsAtProvider !== false; (async () => { const output: AssistantMessage = { @@ -233,7 +234,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( block.arguments = parseStreamingJson(block.partialJson); // Validate tool arguments if tool definition is available - if (context.tools) { + if (shouldValidateToolCalls && context.tools) { const tool = context.tools.find((t) => t.name === block.name); if (tool) { block.arguments = validateToolArguments(tool, block); diff --git a/packages/ai/src/providers/google.ts b/packages/ai/src/providers/google.ts index 9d3ade4f..e571aeb0 100644 --- a/packages/ai/src/providers/google.ts +++ b/packages/ai/src/providers/google.ts @@ -43,6 +43,7 @@ export const streamGoogle: StreamFunction<"google-generative-ai"> = ( options?: GoogleOptions, ): AssistantMessageEventStream => { const stream = new AssistantMessageEventStream(); + const shouldValidateToolCalls = options?.validateToolCallsAtProvider !== false; (async () => { const output: AssistantMessage = { @@ -167,7 +168,7 @@ export const streamGoogle: StreamFunction<"google-generative-ai"> = ( }; // Validate tool arguments if tool definition is available - if (context.tools) { + if (shouldValidateToolCalls && context.tools) { const tool = context.tools.find((t) => t.name === toolCall.name); if (tool) { toolCall.arguments = validateToolArguments(tool, toolCall); diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index a3c0a17e..dafacd9e 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -37,6 +37,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = ( options?: OpenAICompletionsOptions, ): AssistantMessageEventStream => { const stream = new AssistantMessageEventStream(); + const shouldValidateToolCalls = options?.validateToolCallsAtProvider !== false; (async () => { const output: AssistantMessage = { @@ -86,7 +87,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = ( block.arguments = JSON.parse(block.partialArgs || "{}"); // Validate tool arguments if tool definition is available - if (context.tools) { + if (shouldValidateToolCalls && context.tools) { const tool = context.tools.find((t) => t.name === block.name); if (tool) { block.arguments = validateToolArguments(tool, block); diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index 76a582be..9b2c4fb6 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -45,6 +45,7 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = ( options?: OpenAIResponsesOptions, ): AssistantMessageEventStream => { const stream = new AssistantMessageEventStream(); + const shouldValidateToolCalls = options?.validateToolCallsAtProvider !== false; // Start async processing (async () => { @@ -240,7 +241,7 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = ( }; // Validate tool arguments if tool definition is available - if (context.tools) { + if (shouldValidateToolCalls && context.tools) { const tool = context.tools.find((t) => t.name === toolCall.name); if (tool) { toolCall.arguments = validateToolArguments(tool, toolCall); diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts index 94fb21b1..9c5b20d7 100644 --- a/packages/ai/src/stream.ts +++ b/packages/ai/src/stream.ts @@ -120,6 +120,7 @@ function mapOptionsForApi( maxTokens: options?.maxTokens || Math.min(model.maxTokens, 32000), signal: options?.signal, apiKey: apiKey || options?.apiKey, + validateToolCallsAtProvider: options?.validateToolCallsAtProvider ?? true, }; switch (model.api) { diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index a7269bc8..b3b8a885 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -37,6 +37,12 @@ export interface StreamOptions { maxTokens?: number; signal?: AbortSignal; apiKey?: string; + /** + * Controls whether providers validate streamed tool calls against the tool schema. + * Defaults to true. Set to false to skip provider-level validation and allow + * downstream consumers (e.g., agentLoop) to handle validation failures. + */ + validateToolCallsAtProvider?: boolean; } // Unified options with reasoning passed to streamSimple() and completeSimple() From 8bec289dc6caa4ecae2d4cd3a86e222755634aa4 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 8 Dec 2025 18:04:33 +0100 Subject: [PATCH 52/54] Remove provider-level tool validation, add validateToolCall helper --- packages/agent/src/agent.ts | 1 - packages/agent/src/transports/AppTransport.ts | 2 - .../agent/src/transports/ProviderTransport.ts | 1 - packages/agent/src/transports/types.ts | 1 - packages/ai/CHANGELOG.md | 6 ++- packages/ai/README.md | 47 ++++++++++++++----- packages/ai/src/index.ts | 1 + packages/ai/src/providers/anthropic.ts | 12 +---- packages/ai/src/providers/google.ts | 11 +---- .../ai/src/providers/openai-completions.ts | 12 +---- packages/ai/src/providers/openai-responses.ts | 11 +---- packages/ai/src/stream.ts | 1 - packages/ai/src/types.ts | 6 --- packages/ai/src/utils/validation.ts | 15 ++++++ 14 files changed, 59 insertions(+), 68 deletions(-) diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index df39e925..9897984d 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -221,7 +221,6 @@ export class Agent { tools: this._state.tools, model, reasoning, - validateToolCallsAtProvider: false, getQueuedMessages: async () => { // Return queued messages based on queue mode if (this.queueMode === "one-at-a-time") { diff --git a/packages/agent/src/transports/AppTransport.ts b/packages/agent/src/transports/AppTransport.ts index 0404f37a..5beb9dc6 100644 --- a/packages/agent/src/transports/AppTransport.ts +++ b/packages/agent/src/transports/AppTransport.ts @@ -77,7 +77,6 @@ function streamSimpleProxy( temperature: options.temperature, maxTokens: options.maxTokens, reasoning: options.reasoning, - validateToolCallsAtProvider: options.validateToolCallsAtProvider, // Don't send apiKey or signal - those are added server-side }, }), @@ -366,7 +365,6 @@ export class AppTransport implements AgentTransport { model: cfg.model, reasoning: cfg.reasoning, getQueuedMessages: cfg.getQueuedMessages, - validateToolCallsAtProvider: cfg.validateToolCallsAtProvider ?? false, }; // Yield events from the upstream agentLoop iterator diff --git a/packages/agent/src/transports/ProviderTransport.ts b/packages/agent/src/transports/ProviderTransport.ts index 1435b160..c46b16c0 100644 --- a/packages/agent/src/transports/ProviderTransport.ts +++ b/packages/agent/src/transports/ProviderTransport.ts @@ -65,7 +65,6 @@ export class ProviderTransport implements AgentTransport { reasoning: cfg.reasoning, apiKey, getQueuedMessages: cfg.getQueuedMessages, - validateToolCallsAtProvider: cfg.validateToolCallsAtProvider ?? false, }; // Yield events from agentLoop diff --git a/packages/agent/src/transports/types.ts b/packages/agent/src/transports/types.ts index 43982a69..d5d4053c 100644 --- a/packages/agent/src/transports/types.ts +++ b/packages/agent/src/transports/types.ts @@ -9,7 +9,6 @@ export interface AgentRunConfig { model: Model; reasoning?: "low" | "medium" | "high"; getQueuedMessages?: () => Promise[]>; - validateToolCallsAtProvider?: boolean; } /** diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 9cd5b522..a333e15b 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,9 +2,13 @@ ## [Unreleased] +### Breaking Changes + +- Removed provider-level tool argument validation. Validation now happens in `agentLoop` via `executeToolCalls`, allowing models to retry on validation errors. For manual tool execution, use `validateToolCall(tools, toolCall)` or `validateToolArguments(tool, toolCall)`. + ### Added -- Added `validateToolCallsAtProvider` option to streaming and agent APIs to optionally skip provider-level tool-call validation (default on), allowing agent loops to surface schema errors as toolResult messages and retry. +- Added `validateToolCall(tools, toolCall)` helper that finds the tool by name and validates arguments. ## [0.13.0] - 2025-12-06 diff --git a/packages/ai/README.md b/packages/ai/README.md index acd4b78e..52d69605 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -194,8 +194,8 @@ const response = await complete(model, context); // Check for tool calls in the response for (const block of response.content) { if (block.type === 'toolCall') { - // Arguments are automatically validated against the TypeBox schema using AJV - // If validation fails, an error event is emitted + // Execute your tool with the arguments + // See "Validating Tool Arguments" section for validation const result = await executeWeatherApi(block.arguments); // Add tool result with text content @@ -253,7 +253,7 @@ for await (const event of s) { } if (event.type === 'toolcall_end') { - // Here toolCall.arguments is complete and validated + // Here toolCall.arguments is complete (but not yet validated) const toolCall = event.toolCall; console.log(`Tool completed: ${toolCall.name}`, toolCall.arguments); } @@ -267,22 +267,43 @@ for await (const event of s) { - Arrays may be incomplete - Nested objects may be partially populated - At minimum, `arguments` will be an empty object `{}`, never `undefined` -- Full validation only occurs at `toolcall_end` when arguments are complete - The Google provider does not support function call streaming. Instead, you will receive a single `toolcall_delta` event with the full arguments. -### Provider tool-call validation +### Validating Tool Arguments -By default, providers validate streamed tool calls against your tool schema and abort the stream on validation errors. Set `validateToolCallsAtProvider: false` on `stream`, `streamSimple`, `complete`, `completeSimple`, or `AgentLoopConfig` to skip provider-level validation and let downstream code (for example, `agentLoop` via `executeToolCalls` → `validateToolArguments`) surface schema errors as `toolResult` messages. This enables the model to retry after receiving a validation error. +When using `agentLoop`, tool arguments are automatically validated against your TypeBox schemas before execution. If validation fails, the error is returned to the model as a tool result, allowing it to retry. + +When implementing your own tool execution loop with `stream()` or `complete()`, use `validateToolCall` to validate arguments before passing them to your tools: ```typescript -await streamSimple(model, context, { - apiKey: 'your-key', - validateToolCallsAtProvider: false -}); -``` +import { stream, validateToolCall, Tool } from '@mariozechner/pi-ai'; -- `true` (default): Provider validates tool calls and emits an error if arguments do not match the schema -- `false`: Provider emits tool calls even when arguments are invalid; callers must validate and handle errors themselves +const tools: Tool[] = [weatherTool, calculatorTool]; +const s = stream(model, { messages, tools }); + +for await (const event of s) { + if (event.type === 'toolcall_end') { + const toolCall = event.toolCall; + + try { + // Validate arguments against the tool's schema (throws on invalid args) + const validatedArgs = validateToolCall(tools, toolCall); + const result = await executeMyTool(toolCall.name, validatedArgs); + // ... add tool result to context + } catch (error) { + // Validation failed - return error as tool result so model can retry + context.messages.push({ + role: 'toolResult', + toolCallId: toolCall.id, + toolName: toolCall.name, + content: [{ type: 'text', text: error.message }], + isError: true, + timestamp: Date.now() + }); + } + } +} +``` ### Complete Event Reference diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 874686b7..f9ed0753 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -8,3 +8,4 @@ export * from "./stream.js"; export * from "./types.js"; export * from "./utils/overflow.js"; export * from "./utils/typebox-helpers.js"; +export * from "./utils/validation.js"; diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index d3421553..ff6e60e2 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -25,7 +25,7 @@ import type { import { AssistantMessageEventStream } from "../utils/event-stream.js"; import { parseStreamingJson } from "../utils/json-parse.js"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.js"; -import { validateToolArguments } from "../utils/validation.js"; + import { transformMessages } from "./transorm-messages.js"; /** @@ -92,7 +92,6 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( options?: AnthropicOptions, ): AssistantMessageEventStream => { const stream = new AssistantMessageEventStream(); - const shouldValidateToolCalls = options?.validateToolCallsAtProvider !== false; (async () => { const output: AssistantMessage = { @@ -232,15 +231,6 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( }); } else if (block.type === "toolCall") { block.arguments = parseStreamingJson(block.partialJson); - - // Validate tool arguments if tool definition is available - if (shouldValidateToolCalls && context.tools) { - const tool = context.tools.find((t) => t.name === block.name); - if (tool) { - block.arguments = validateToolArguments(tool, block); - } - } - delete (block as any).partialJson; stream.push({ type: "toolcall_end", diff --git a/packages/ai/src/providers/google.ts b/packages/ai/src/providers/google.ts index e571aeb0..5b5a0356 100644 --- a/packages/ai/src/providers/google.ts +++ b/packages/ai/src/providers/google.ts @@ -23,7 +23,7 @@ import type { } from "../types.js"; import { AssistantMessageEventStream } from "../utils/event-stream.js"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.js"; -import { validateToolArguments } from "../utils/validation.js"; + import { transformMessages } from "./transorm-messages.js"; export interface GoogleOptions extends StreamOptions { @@ -43,7 +43,6 @@ export const streamGoogle: StreamFunction<"google-generative-ai"> = ( options?: GoogleOptions, ): AssistantMessageEventStream => { const stream = new AssistantMessageEventStream(); - const shouldValidateToolCalls = options?.validateToolCallsAtProvider !== false; (async () => { const output: AssistantMessage = { @@ -167,14 +166,6 @@ export const streamGoogle: StreamFunction<"google-generative-ai"> = ( ...(part.thoughtSignature && { thoughtSignature: part.thoughtSignature }), }; - // Validate tool arguments if tool definition is available - if (shouldValidateToolCalls && context.tools) { - const tool = context.tools.find((t) => t.name === toolCall.name); - if (tool) { - toolCall.arguments = validateToolArguments(tool, toolCall); - } - } - output.content.push(toolCall); stream.push({ type: "toolcall_start", contentIndex: blockIndex(), partial: output }); stream.push({ diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index dafacd9e..ca9f1c30 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -23,7 +23,7 @@ import type { import { AssistantMessageEventStream } from "../utils/event-stream.js"; import { parseStreamingJson } from "../utils/json-parse.js"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.js"; -import { validateToolArguments } from "../utils/validation.js"; + import { transformMessages } from "./transorm-messages.js"; export interface OpenAICompletionsOptions extends StreamOptions { @@ -37,7 +37,6 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = ( options?: OpenAICompletionsOptions, ): AssistantMessageEventStream => { const stream = new AssistantMessageEventStream(); - const shouldValidateToolCalls = options?.validateToolCallsAtProvider !== false; (async () => { const output: AssistantMessage = { @@ -85,15 +84,6 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = ( }); } else if (block.type === "toolCall") { block.arguments = JSON.parse(block.partialArgs || "{}"); - - // Validate tool arguments if tool definition is available - if (shouldValidateToolCalls && context.tools) { - const tool = context.tools.find((t) => t.name === block.name); - if (tool) { - block.arguments = validateToolArguments(tool, block); - } - } - delete block.partialArgs; stream.push({ type: "toolcall_end", diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index 9b2c4fb6..c36e5254 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -27,7 +27,7 @@ import type { import { AssistantMessageEventStream } from "../utils/event-stream.js"; import { parseStreamingJson } from "../utils/json-parse.js"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.js"; -import { validateToolArguments } from "../utils/validation.js"; + import { transformMessages } from "./transorm-messages.js"; // OpenAI Responses-specific options @@ -45,7 +45,6 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = ( options?: OpenAIResponsesOptions, ): AssistantMessageEventStream => { const stream = new AssistantMessageEventStream(); - const shouldValidateToolCalls = options?.validateToolCallsAtProvider !== false; // Start async processing (async () => { @@ -240,14 +239,6 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = ( arguments: JSON.parse(item.arguments), }; - // Validate tool arguments if tool definition is available - if (shouldValidateToolCalls && context.tools) { - const tool = context.tools.find((t) => t.name === toolCall.name); - if (tool) { - toolCall.arguments = validateToolArguments(tool, toolCall); - } - } - stream.push({ type: "toolcall_end", contentIndex: blockIndex(), toolCall, partial: output }); } } diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts index 9c5b20d7..94fb21b1 100644 --- a/packages/ai/src/stream.ts +++ b/packages/ai/src/stream.ts @@ -120,7 +120,6 @@ function mapOptionsForApi( maxTokens: options?.maxTokens || Math.min(model.maxTokens, 32000), signal: options?.signal, apiKey: apiKey || options?.apiKey, - validateToolCallsAtProvider: options?.validateToolCallsAtProvider ?? true, }; switch (model.api) { diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index b3b8a885..a7269bc8 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -37,12 +37,6 @@ export interface StreamOptions { maxTokens?: number; signal?: AbortSignal; apiKey?: string; - /** - * Controls whether providers validate streamed tool calls against the tool schema. - * Defaults to true. Set to false to skip provider-level validation and allow - * downstream consumers (e.g., agentLoop) to handle validation failures. - */ - validateToolCallsAtProvider?: boolean; } // Unified options with reasoning passed to streamSimple() and completeSimple() diff --git a/packages/ai/src/utils/validation.ts b/packages/ai/src/utils/validation.ts index 08335807..4c778880 100644 --- a/packages/ai/src/utils/validation.ts +++ b/packages/ai/src/utils/validation.ts @@ -27,6 +27,21 @@ if (!isBrowserExtension) { } } +/** + * Finds a tool by name and validates the tool call arguments against its TypeBox schema + * @param tools Array of tool definitions + * @param toolCall The tool call from the LLM + * @returns The validated arguments + * @throws Error if tool is not found or validation fails + */ +export function validateToolCall(tools: Tool[], toolCall: ToolCall): any { + const tool = tools.find((t) => t.name === toolCall.name); + if (!tool) { + throw new Error(`Tool "${toolCall.name}" not found`); + } + return validateToolArguments(tool, toolCall); +} + /** * Validates tool call arguments against the tool's TypeBox schema * @param tool The tool definition with TypeBox schema From 87a1a9ded4df489d43b26137b5a22a1d5549f11e Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 8 Dec 2025 19:00:57 +0100 Subject: [PATCH 53/54] Add OpenAICompat for openai-completions provider quirks Fixes #133 --- packages/ai/CHANGELOG.md | 2 + packages/ai/README.md | 36 +++++++++ .../ai/src/providers/openai-completions.ts | 77 +++++++++++++------ packages/ai/src/types.ts | 17 ++++ packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/README.md | 41 ++++++++++ packages/coding-agent/src/model-config.ts | 12 ++- 7 files changed, 165 insertions(+), 24 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index a333e15b..ff468b87 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -10,6 +10,8 @@ - Added `validateToolCall(tools, toolCall)` helper that finds the tool by name and validates arguments. +- **OpenAI compatibility overrides**: Added `compat` field to `Model` for `openai-completions` API, allowing explicit configuration of provider quirks (`supportsStore`, `supportsDeveloperRole`, `supportsReasoningEffort`, `maxTokensField`). Falls back to URL-based detection if not set. Useful for LiteLLM, custom proxies, and other non-standard endpoints. ([#133](https://github.com/badlogic/pi-mono/issues/133), thanks @fink-andreas for the initial idea and PR) + ## [0.13.0] - 2025-12-06 ### Breaking Changes diff --git a/packages/ai/README.md b/packages/ai/README.md index 52d69605..67a17c68 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -611,6 +611,23 @@ const ollamaModel: Model<'openai-completions'> = { maxTokens: 32000 }; +// Example: LiteLLM proxy with explicit compat settings +const litellmModel: Model<'openai-completions'> = { + id: 'gpt-4o', + name: 'GPT-4o (via LiteLLM)', + api: 'openai-completions', + provider: 'litellm', + baseUrl: 'http://localhost:4000/v1', + reasoning: false, + input: ['text', 'image'], + cost: { input: 2.5, output: 10, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 16384, + compat: { + supportsStore: false, // LiteLLM doesn't support the store field + } +}; + // Example: Custom endpoint with headers (bypassing Cloudflare bot detection) const proxyModel: Model<'anthropic-messages'> = { id: 'claude-sonnet-4', @@ -635,6 +652,25 @@ const response = await stream(ollamaModel, context, { }); ``` +### OpenAI Compatibility Settings + +The `openai-completions` API is implemented by many providers with minor differences. By default, the library auto-detects compatibility settings based on `baseUrl` for known providers (Cerebras, xAI, Mistral, Chutes, etc.). For custom proxies or unknown endpoints, you can override these settings via the `compat` field: + +```typescript +interface OpenAICompat { + supportsStore?: boolean; // Whether provider supports the `store` field (default: true) + supportsDeveloperRole?: boolean; // Whether provider supports `developer` role vs `system` (default: true) + supportsReasoningEffort?: boolean; // Whether provider supports `reasoning_effort` (default: true) + maxTokensField?: 'max_completion_tokens' | 'max_tokens'; // Which field name to use (default: max_completion_tokens) +} +``` + +If `compat` is not set, the library falls back to URL-based detection. If `compat` is partially set, unspecified fields use the detected defaults. This is useful for: + +- **LiteLLM proxies**: May not support `store` field +- **Custom inference servers**: May use non-standard field names +- **Self-hosted endpoints**: May have different feature support + ### Type Safety Models are typed by their API, ensuring type-safe options: diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index ca9f1c30..582e826f 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -12,6 +12,7 @@ import type { AssistantMessage, Context, Model, + OpenAICompat, StopReason, StreamFunction, StreamOptions, @@ -267,7 +268,8 @@ function createClient(model: Model<"openai-completions">, apiKey?: string) { } function buildParams(model: Model<"openai-completions">, context: Context, options?: OpenAICompletionsOptions) { - const messages = convertMessages(model, context); + const compat = getCompat(model); + const messages = convertMessages(model, context, compat); const params: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = { model: model.id, @@ -276,27 +278,20 @@ function buildParams(model: Model<"openai-completions">, context: Context, optio stream_options: { include_usage: true }, }; - // Cerebras/xAI/Mistral dont like the "store" field - if ( - !model.baseUrl.includes("cerebras.ai") && - !model.baseUrl.includes("api.x.ai") && - !model.baseUrl.includes("mistral.ai") && - !model.baseUrl.includes("chutes.ai") - ) { + if (compat.supportsStore) { params.store = false; } if (options?.maxTokens) { - // Mistral/Chutes uses max_tokens instead of max_completion_tokens - if (model.baseUrl.includes("mistral.ai") || model.baseUrl.includes("chutes.ai")) { - (params as any).max_tokens = options?.maxTokens; + if (compat.maxTokensField === "max_tokens") { + (params as any).max_tokens = options.maxTokens; } else { - params.max_completion_tokens = options?.maxTokens; + params.max_completion_tokens = options.maxTokens; } } if (options?.temperature !== undefined) { - params.temperature = options?.temperature; + params.temperature = options.temperature; } if (context.tools) { @@ -307,27 +302,24 @@ function buildParams(model: Model<"openai-completions">, context: Context, optio params.tool_choice = options.toolChoice; } - // Grok models don't like reasoning_effort - if (options?.reasoningEffort && model.reasoning && !model.id.toLowerCase().includes("grok")) { + if (options?.reasoningEffort && model.reasoning && compat.supportsReasoningEffort) { params.reasoning_effort = options.reasoningEffort; } return params; } -function convertMessages(model: Model<"openai-completions">, context: Context): ChatCompletionMessageParam[] { +function convertMessages( + model: Model<"openai-completions">, + context: Context, + compat: Required, +): ChatCompletionMessageParam[] { const params: ChatCompletionMessageParam[] = []; const transformedMessages = transformMessages(context.messages, model); if (context.systemPrompt) { - // Cerebras/xAi/Mistral/Chutes don't like the "developer" role - const useDeveloperRole = - model.reasoning && - !model.baseUrl.includes("cerebras.ai") && - !model.baseUrl.includes("api.x.ai") && - !model.baseUrl.includes("mistral.ai") && - !model.baseUrl.includes("chutes.ai"); + const useDeveloperRole = model.reasoning && compat.supportsDeveloperRole; const role = useDeveloperRole ? "developer" : "system"; params.push({ role: role, content: sanitizeSurrogates(context.systemPrompt) }); } @@ -482,3 +474,42 @@ function mapStopReason(reason: ChatCompletionChunk.Choice["finish_reason"]): Sto } } } + +/** + * Detect compatibility settings from baseUrl for known providers. + * Returns a fully resolved OpenAICompat object with all fields set. + */ +function detectCompatFromUrl(baseUrl: string): Required { + const isNonStandard = + baseUrl.includes("cerebras.ai") || + baseUrl.includes("api.x.ai") || + baseUrl.includes("mistral.ai") || + baseUrl.includes("chutes.ai"); + + const useMaxTokens = baseUrl.includes("mistral.ai") || baseUrl.includes("chutes.ai"); + + const isGrok = baseUrl.includes("api.x.ai"); + + return { + supportsStore: !isNonStandard, + supportsDeveloperRole: !isNonStandard, + supportsReasoningEffort: !isGrok, + maxTokensField: useMaxTokens ? "max_tokens" : "max_completion_tokens", + }; +} + +/** + * Get resolved compatibility settings for a model. + * Uses explicit model.compat if provided, otherwise auto-detects from URL. + */ +function getCompat(model: Model<"openai-completions">): Required { + const detected = detectCompatFromUrl(model.baseUrl); + if (!model.compat) return detected; + + return { + supportsStore: model.compat.supportsStore ?? detected.supportsStore, + supportsDeveloperRole: model.compat.supportsDeveloperRole ?? detected.supportsDeveloperRole, + supportsReasoningEffort: model.compat.supportsReasoningEffort ?? detected.supportsReasoningEffort, + maxTokensField: model.compat.maxTokensField ?? detected.maxTokensField, + }; +} diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index a7269bc8..0f22a3f2 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -152,6 +152,21 @@ export type AssistantMessageEvent = | { type: "done"; reason: Extract; message: AssistantMessage } | { type: "error"; reason: Extract; error: AssistantMessage }; +/** + * Compatibility settings for openai-completions API. + * Use this to override URL-based auto-detection for custom providers. + */ +export interface OpenAICompat { + /** Whether the provider supports the `store` field. Default: auto-detected from URL. */ + supportsStore?: boolean; + /** Whether the provider supports the `developer` role (vs `system`). Default: auto-detected from URL. */ + supportsDeveloperRole?: boolean; + /** Whether the provider supports `reasoning_effort`. Default: auto-detected from URL. */ + supportsReasoningEffort?: boolean; + /** Which field to use for max tokens. Default: auto-detected from URL. */ + maxTokensField?: "max_completion_tokens" | "max_tokens"; +} + // Model interface for the unified model system export interface Model { id: string; @@ -170,4 +185,6 @@ export interface Model { contextWindow: number; maxTokens: number; headers?: Record; + /** Compatibility overrides for openai-completions API. If not set, auto-detected from baseUrl. */ + compat?: TApi extends "openai-completions" ? OpenAICompat : never; } diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 355bd294..6b507fb5 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- **OpenAI compatibility overrides in models.json**: Custom models using `openai-completions` API can now specify a `compat` object to override provider quirks (`supportsStore`, `supportsDeveloperRole`, `supportsReasoningEffort`, `maxTokensField`). Useful for LiteLLM, custom proxies, and other non-standard endpoints. ([#133](https://github.com/badlogic/pi-mono/issues/133), thanks @fink-andreas for the initial idea and PR) + ## [0.13.2] - 2025-12-07 ### Changed diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 5f47d103..1e6a73c6 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -315,6 +315,47 @@ You can add custom HTTP headers to bypass Cloudflare bot detection, add authenti - **Model-level `headers`**: Additional headers for specific models (merged with provider headers) - Model headers override provider headers when keys conflict +### OpenAI Compatibility Settings + +The `openai-completions` API is implemented by many providers with minor differences (Ollama, vLLM, LiteLLM, llama.cpp, etc.). By default, compatibility settings are auto-detected from the `baseUrl`. For custom proxies or unknown endpoints, you can override these via the `compat` field on models: + +```json +{ + "providers": { + "litellm": { + "baseUrl": "http://localhost:4000/v1", + "apiKey": "LITELLM_API_KEY", + "api": "openai-completions", + "models": [ + { + "id": "gpt-4o", + "name": "GPT-4o (via LiteLLM)", + "reasoning": false, + "input": ["text", "image"], + "cost": {"input": 2.5, "output": 10, "cacheRead": 0, "cacheWrite": 0}, + "contextWindow": 128000, + "maxTokens": 16384, + "compat": { + "supportsStore": false + } + } + ] + } + } +} +``` + +Available `compat` fields (all optional, auto-detected if not set): + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `supportsStore` | boolean | auto | Whether provider supports the `store` field | +| `supportsDeveloperRole` | boolean | auto | Whether provider supports `developer` role (vs `system`) | +| `supportsReasoningEffort` | boolean | auto | Whether provider supports `reasoning_effort` parameter | +| `maxTokensField` | string | auto | Use `"max_completion_tokens"` or `"max_tokens"` | + +If `compat` is partially set, unspecified fields use auto-detected values. + ### Authorization Header Some providers require an explicit `Authorization: Bearer ` header. Set `authHeader: true` to automatically add this header using the resolved `apiKey`: diff --git a/packages/coding-agent/src/model-config.ts b/packages/coding-agent/src/model-config.ts index abbebdb1..820fab33 100644 --- a/packages/coding-agent/src/model-config.ts +++ b/packages/coding-agent/src/model-config.ts @@ -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({ @@ -201,7 +210,8 @@ function parseModels(config: ModelsConfig): Model[] { contextWindow: modelDef.contextWindow, maxTokens: modelDef.maxTokens, headers, - }); + compat: modelDef.compat, + } as Model); } } From 00370cab39ef15d216d9cbb74429f6b11ade2351 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 8 Dec 2025 21:12:54 +0100 Subject: [PATCH 54/54] Add xhigh thinking level for OpenAI codex-max models - Add 'xhigh' to ThinkingLevel type in ai and agent packages - Map xhigh to reasoning_effort: 'max' for OpenAI providers - Add thinkingXhigh color token to theme schema and built-in themes - Show xhigh option only when using codex-max models - Update CHANGELOG for both ai and coding-agent packages closes #143 --- package-lock.json | 79 ++++++++++++++----- packages/agent/src/transports/types.ts | 2 +- packages/agent/src/types.ts | 3 +- packages/ai/CHANGELOG.md | 6 ++ packages/ai/README.md | 2 +- packages/ai/package.json | 6 +- .../ai/src/providers/openai-completions.ts | 2 +- packages/ai/src/providers/openai-responses.ts | 7 +- packages/ai/src/stream.ts | 18 +++-- packages/ai/src/types.ts | 2 +- packages/ai/test/xhigh.test.ts | 69 ++++++++++++++++ packages/coding-agent/CHANGELOG.md | 8 ++ packages/coding-agent/src/main.ts | 26 +++++- packages/coding-agent/src/settings-manager.ts | 16 +++- packages/coding-agent/src/theme/dark.json | 5 +- packages/coding-agent/src/theme/light.json | 5 +- .../coding-agent/src/theme/theme-schema.json | 28 +++++++ packages/coding-agent/src/theme/theme.ts | 44 +++++++++-- packages/coding-agent/src/tui/tui-renderer.ts | 26 ++++-- 19 files changed, 300 insertions(+), 54 deletions(-) create mode 100644 packages/ai/test/xhigh.test.ts diff --git a/package-lock.json b/package-lock.json index 44632dcd..a25b573d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,12 +45,32 @@ } }, "node_modules/@anthropic-ai/sdk": { - "version": "0.61.0", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.61.0.tgz", - "integrity": "sha512-GnlOXrPxow0uoaVB3DGNh9EJBU1MyagCBCLpU+bwDVlj/oOPYIwoiasMWlykkfYcQOrDP2x/zHnRD0xN7PeZPw==", + "version": "0.71.2", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.71.2.tgz", + "integrity": "sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ==", "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, "bin": { "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" } }, "node_modules/@biomejs/biome": { @@ -3879,6 +3899,19 @@ "bignumber.js": "^9.0.0" } }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -4550,16 +4583,16 @@ } }, "node_modules/openai": { - "version": "5.21.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-5.21.0.tgz", - "integrity": "sha512-E9LuV51vgvwbahPJaZu2x4V6SWMq9g3X6Bj2/wnFiNfV7lmAxYVxPxcQNZqCWbAVMaEoers9HzIxpOp6Vvgn8w==", + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.10.0.tgz", + "integrity": "sha512-ITxOGo7rO3XRMiKA5l7tQ43iNNu+iXGFAcf2t+aWVzzqRaS0i7m1K2BhxNdaveB+5eENhO0VY1FkiZzhBk4v3A==", "license": "Apache-2.0", "bin": { "openai": "bin/cli" }, "peerDependencies": { "ws": "^8.18.0", - "zod": "^3.23.8" + "zod": "^3.25 || ^4.0" }, "peerDependenciesMeta": { "ws": { @@ -5465,6 +5498,12 @@ "tree-kill": "cli.js" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -6462,8 +6501,8 @@ "version": "0.13.2", "license": "MIT", "dependencies": { - "@mariozechner/pi-ai": "^0.13.1", - "@mariozechner/pi-tui": "^0.13.1" + "@mariozechner/pi-ai": "^0.13.2", + "@mariozechner/pi-tui": "^0.13.2" }, "devDependencies": { "@types/node": "^24.3.0", @@ -6496,13 +6535,13 @@ "version": "0.13.2", "license": "MIT", "dependencies": { - "@anthropic-ai/sdk": "^0.61.0", - "@google/genai": "^1.30.0", + "@anthropic-ai/sdk": "0.71.2", + "@google/genai": "1.31.0", "@sinclair/typebox": "^0.34.41", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "chalk": "^5.6.2", - "openai": "5.21.0", + "openai": "6.10.0", "partial-json": "^0.1.7", "zod-to-json-schema": "^3.24.6" }, @@ -6537,9 +6576,9 @@ "version": "0.13.2", "license": "MIT", "dependencies": { - "@mariozechner/pi-agent-core": "^0.13.1", - "@mariozechner/pi-ai": "^0.13.1", - "@mariozechner/pi-tui": "^0.13.1", + "@mariozechner/pi-agent-core": "^0.13.2", + "@mariozechner/pi-ai": "^0.13.2", + "@mariozechner/pi-tui": "^0.13.2", "chalk": "^5.5.0", "diff": "^8.0.2", "glob": "^11.0.3" @@ -6580,8 +6619,8 @@ "license": "MIT", "dependencies": { "@anthropic-ai/sandbox-runtime": "^0.0.16", - "@mariozechner/pi-agent-core": "^0.13.1", - "@mariozechner/pi-ai": "^0.13.1", + "@mariozechner/pi-agent-core": "^0.13.2", + "@mariozechner/pi-ai": "^0.13.2", "@sinclair/typebox": "^0.34.0", "@slack/socket-mode": "^2.0.0", "@slack/web-api": "^7.0.0", @@ -6622,7 +6661,7 @@ "version": "0.13.2", "license": "MIT", "dependencies": { - "@mariozechner/pi-agent-core": "^0.13.1", + "@mariozechner/pi-agent-core": "^0.13.2", "chalk": "^5.5.0" }, "bin": { @@ -6699,8 +6738,8 @@ "license": "MIT", "dependencies": { "@lmstudio/sdk": "^1.5.0", - "@mariozechner/pi-ai": "^0.13.1", - "@mariozechner/pi-tui": "^0.13.1", + "@mariozechner/pi-ai": "^0.13.2", + "@mariozechner/pi-tui": "^0.13.2", "docx-preview": "^0.3.7", "jszip": "^3.10.1", "lucide": "^0.544.0", diff --git a/packages/agent/src/transports/types.ts b/packages/agent/src/transports/types.ts index d5d4053c..bc86a3f3 100644 --- a/packages/agent/src/transports/types.ts +++ b/packages/agent/src/transports/types.ts @@ -7,7 +7,7 @@ export interface AgentRunConfig { systemPrompt: string; tools: AgentTool[]; model: Model; - reasoning?: "low" | "medium" | "high"; + reasoning?: "low" | "medium" | "high" | "xhigh"; getQueuedMessages?: () => Promise[]>; } diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index 33b2d032..be655f86 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -24,8 +24,9 @@ export interface Attachment { /** * Thinking/reasoning level for models that support it. + * Note: "xhigh" is only supported by OpenAI codex-max models. */ -export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high"; +export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; /** * User message with optional attachments. diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index ff468b87..f44cd76b 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -12,6 +12,12 @@ - **OpenAI compatibility overrides**: Added `compat` field to `Model` for `openai-completions` API, allowing explicit configuration of provider quirks (`supportsStore`, `supportsDeveloperRole`, `supportsReasoningEffort`, `maxTokensField`). Falls back to URL-based detection if not set. Useful for LiteLLM, custom proxies, and other non-standard endpoints. ([#133](https://github.com/badlogic/pi-mono/issues/133), thanks @fink-andreas for the initial idea and PR) +- **xhigh reasoning level**: Added `xhigh` to `ReasoningEffort` type for OpenAI codex-max models. For non-OpenAI providers (Anthropic, Google), `xhigh` is automatically mapped to `high`. ([#143](https://github.com/badlogic/pi-mono/issues/143)) + +### Changed + +- **Updated SDK versions**: OpenAI SDK 5.21.0 → 6.10.0, Anthropic SDK 0.61.0 → 0.71.2, Google GenAI SDK 1.30.0 → 1.31.0 + ## [0.13.0] - 2025-12-06 ### Breaking Changes diff --git a/packages/ai/README.md b/packages/ai/README.md index 67a17c68..c340f5d8 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -387,7 +387,7 @@ if (model.reasoning) { const response = await completeSimple(model, { messages: [{ role: 'user', content: 'Solve: 2x + 5 = 13' }] }, { - reasoning: 'medium' // 'minimal' | 'low' | 'medium' | 'high' + reasoning: 'medium' // 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' (xhigh maps to high on non-OpenAI providers) }); // Access thinking and text blocks diff --git a/packages/ai/package.json b/packages/ai/package.json index fa94b59f..80a30750 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -20,13 +20,13 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@anthropic-ai/sdk": "^0.61.0", - "@google/genai": "^1.30.0", + "@anthropic-ai/sdk": "0.71.2", + "@google/genai": "1.31.0", "@sinclair/typebox": "^0.34.41", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "chalk": "^5.6.2", - "openai": "5.21.0", + "openai": "6.10.0", "partial-json": "^0.1.7", "zod-to-json-schema": "^3.24.6" }, diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index 582e826f..1637b289 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -29,7 +29,7 @@ import { transformMessages } from "./transorm-messages.js"; export interface OpenAICompletionsOptions extends StreamOptions { toolChoice?: "auto" | "none" | "required" | { type: "function"; function: { name: string } }; - reasoningEffort?: "minimal" | "low" | "medium" | "high"; + reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh"; } export const streamOpenAICompletions: StreamFunction<"openai-completions"> = ( diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index c36e5254..b376b4f0 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -32,7 +32,7 @@ import { transformMessages } from "./transorm-messages.js"; // OpenAI Responses-specific options export interface OpenAIResponsesOptions extends StreamOptions { - reasoningEffort?: "minimal" | "low" | "medium" | "high"; + reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh"; reasoningSummary?: "auto" | "detailed" | "concise" | null; } @@ -158,7 +158,10 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = ( else if (event.type === "response.content_part.added") { if (currentItem && currentItem.type === "message") { currentItem.content = currentItem.content || []; - currentItem.content.push(event.part); + // Filter out ReasoningText, only accept output_text and refusal + if (event.part.type === "output_text" || event.part.type === "refusal") { + currentItem.content.push(event.part); + } } } else if (event.type === "response.output_text.delta") { if (currentItem && currentItem.type === "message" && currentBlock && currentBlock.type === "text") { diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts index 94fb21b1..9b61217b 100644 --- a/packages/ai/src/stream.ts +++ b/packages/ai/src/stream.ts @@ -122,6 +122,9 @@ function mapOptionsForApi( apiKey: apiKey || options?.apiKey, }; + // Helper to clamp xhigh to high for providers that don't support it + const clampReasoning = (effort: ReasoningEffort | undefined) => (effort === "xhigh" ? "high" : effort); + switch (model.api) { case "anthropic-messages": { if (!options?.reasoning) return base satisfies AnthropicOptions; @@ -136,7 +139,7 @@ function mapOptionsForApi( return { ...base, thinkingEnabled: true, - thinkingBudgetTokens: anthropicBudgets[options.reasoning], + thinkingBudgetTokens: anthropicBudgets[clampReasoning(options.reasoning)!], } satisfies AnthropicOptions; } @@ -155,7 +158,10 @@ function mapOptionsForApi( case "google-generative-ai": { if (!options?.reasoning) return base as any; - const googleBudget = getGoogleBudget(model as Model<"google-generative-ai">, options.reasoning); + const googleBudget = getGoogleBudget( + model as Model<"google-generative-ai">, + clampReasoning(options.reasoning)!, + ); return { ...base, thinking: { @@ -173,10 +179,12 @@ function mapOptionsForApi( } } -function getGoogleBudget(model: Model<"google-generative-ai">, effort: ReasoningEffort): number { +type ClampedReasoningEffort = Exclude; + +function getGoogleBudget(model: Model<"google-generative-ai">, effort: ClampedReasoningEffort): number { // See https://ai.google.dev/gemini-api/docs/thinking#set-budget if (model.id.includes("2.5-pro")) { - const budgets = { + const budgets: Record = { minimal: 128, low: 2048, medium: 8192, @@ -187,7 +195,7 @@ function getGoogleBudget(model: Model<"google-generative-ai">, effort: Reasoning if (model.id.includes("2.5-flash")) { // Covers 2.5-flash-lite as well - const budgets = { + const budgets: Record = { minimal: 128, low: 2048, medium: 8192, diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 0f22a3f2..4e68b780 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -29,7 +29,7 @@ export type OptionsForApi = ApiOptionsMap[TApi]; export type KnownProvider = "anthropic" | "google" | "openai" | "xai" | "groq" | "cerebras" | "openrouter" | "zai"; export type Provider = KnownProvider | string; -export type ReasoningEffort = "minimal" | "low" | "medium" | "high"; +export type ReasoningEffort = "minimal" | "low" | "medium" | "high" | "xhigh"; // Base options all providers share export interface StreamOptions { diff --git a/packages/ai/test/xhigh.test.ts b/packages/ai/test/xhigh.test.ts new file mode 100644 index 00000000..95646e35 --- /dev/null +++ b/packages/ai/test/xhigh.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { getModel } from "../src/models.js"; +import { stream } from "../src/stream.js"; +import type { Context, Model } from "../src/types.js"; + +function makeContext(): Context { + return { + messages: [ + { + role: "user", + content: `What is ${(Math.random() * 100) | 0} + ${(Math.random() * 100) | 0}? Think step by step.`, + timestamp: Date.now(), + }, + ], + }; +} + +describe.skipIf(!process.env.OPENAI_API_KEY)("xhigh reasoning", () => { + describe("codex-max (supports xhigh)", () => { + // Note: codex models only support the responses API, not chat completions + it("should work with openai-responses", async () => { + const model = getModel("openai", "gpt-5.1-codex-max"); + const s = stream(model, makeContext(), { reasoningEffort: "xhigh" }); + let hasThinking = false; + + for await (const event of s) { + if (event.type === "thinking_start" || event.type === "thinking_delta") { + hasThinking = true; + } + } + + const response = await s.result(); + expect(response.stopReason, `Error: ${response.errorMessage}`).toBe("stop"); + expect(response.content.some((b) => b.type === "text")).toBe(true); + expect(hasThinking || response.content.some((b) => b.type === "thinking")).toBe(true); + }); + }); + + describe("gpt-5-mini (does not support xhigh)", () => { + it("should error with openai-responses when using xhigh", async () => { + const model = getModel("openai", "gpt-5-mini"); + const s = stream(model, makeContext(), { reasoningEffort: "xhigh" }); + + for await (const _ of s) { + // drain events + } + + const response = await s.result(); + expect(response.stopReason).toBe("error"); + expect(response.errorMessage).toContain("xhigh"); + }); + + it("should error with openai-completions when using xhigh", async () => { + const model: Model<"openai-completions"> = { + ...getModel("openai", "gpt-5-mini"), + api: "openai-completions", + }; + const s = stream(model, makeContext(), { reasoningEffort: "xhigh" }); + + for await (const _ of s) { + // drain events + } + + const response = await s.result(); + expect(response.stopReason).toBe("error"); + expect(response.errorMessage).toContain("xhigh"); + }); + }); +}); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6b507fb5..0aa57f10 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,10 +2,18 @@ ## [Unreleased] +### Breaking Changes + +- **Custom themes require new color tokens**: Themes must now include `thinkingXhigh` and `bashMode` color tokens. The theme loader provides helpful error messages listing missing tokens. See built-in themes (dark.json, light.json) for reference values. + ### Added - **OpenAI compatibility overrides in models.json**: Custom models using `openai-completions` API can now specify a `compat` object to override provider quirks (`supportsStore`, `supportsDeveloperRole`, `supportsReasoningEffort`, `maxTokensField`). Useful for LiteLLM, custom proxies, and other non-standard endpoints. ([#133](https://github.com/badlogic/pi-mono/issues/133), thanks @fink-andreas for the initial idea and PR) +- **xhigh thinking level**: Added `xhigh` thinking level for OpenAI codex-max models. Cycle through thinking levels with Shift+Tab; `xhigh` appears only when using a codex-max model. ([#143](https://github.com/badlogic/pi-mono/issues/143)) + +- **Collapse changelog setting**: Add `"collapseChangelog": true` to `~/.pi/agent/settings.json` to show a condensed "Updated to vX.Y.Z" message instead of the full changelog after updates. Use `/changelog` to view the full changelog. ([#148](https://github.com/badlogic/pi-mono/issues/148)) + ## [0.13.2] - 2025-12-07 ### Changed diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 067370b2..d7cd387a 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -112,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`, ), ); } @@ -248,7 +255,7 @@ ${chalk.bold("Options:")} --models Comma-separated model patterns for quick cycling with Ctrl+P --tools Comma-separated list of tools to enable (default: read,bash,edit,write) Available: read, bash, edit, write, grep, find, ls - --thinking Set thinking level: off, minimal, low, medium, high + --thinking Set thinking level: off, minimal, low, medium, high, xhigh --export Export session file to HTML and exit --help, -h Show this help @@ -593,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( @@ -716,6 +730,7 @@ async function runInteractiveMode( settingsManager: SettingsManager, version: string, changelogMarkdown: string | null = null, + collapseChangelog = false, modelFallbackMessage: string | null = null, versionCheckPromise: Promise, scopedModels: Array<{ model: Model; thinkingLevel: ThinkingLevel }> = [], @@ -730,6 +745,7 @@ async function runInteractiveMode( settingsManager, version, changelogMarkdown, + collapseChangelog, scopedModels, fdPath, ); @@ -1385,12 +1401,14 @@ 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, versionCheckPromise, scopedModels, diff --git a/packages/coding-agent/src/settings-manager.ts b/packages/coding-agent/src/settings-manager.ts index 3b660b8f..dc8c7b6e 100644 --- a/packages/coding-agent/src/settings-manager.ts +++ b/packages/coding-agent/src/settings-manager.ts @@ -12,12 +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 { @@ -109,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(); } @@ -163,4 +164,13 @@ export class SettingsManager { this.settings.shellPath = path; this.save(); } + + getCollapseChangelog(): boolean { + return this.settings.collapseChangelog ?? false; + } + + setCollapseChangelog(collapse: boolean): void { + this.settings.collapseChangelog = collapse; + this.save(); + } } diff --git a/packages/coding-agent/src/theme/dark.json b/packages/coding-agent/src/theme/dark.json index 20d0c972..28b84c4d 100644 --- a/packages/coding-agent/src/theme/dark.json +++ b/packages/coding-agent/src/theme/dark.json @@ -65,6 +65,9 @@ "thinkingMinimal": "#6e6e6e", "thinkingLow": "#5f87af", "thinkingMedium": "#81a2be", - "thinkingHigh": "#b294bb" + "thinkingHigh": "#b294bb", + "thinkingXhigh": "#d183e8", + + "bashMode": "green" } } diff --git a/packages/coding-agent/src/theme/light.json b/packages/coding-agent/src/theme/light.json index 25482376..09405d14 100644 --- a/packages/coding-agent/src/theme/light.json +++ b/packages/coding-agent/src/theme/light.json @@ -64,6 +64,9 @@ "thinkingMinimal": "#9e9e9e", "thinkingLow": "#5f87af", "thinkingMedium": "#5f8787", - "thinkingHigh": "#875f87" + "thinkingHigh": "#875f87", + "thinkingXhigh": "#8b008b", + + "bashMode": "green" } } diff --git a/packages/coding-agent/src/theme/theme-schema.json b/packages/coding-agent/src/theme/theme-schema.json index 8507a94c..7f060d23 100644 --- a/packages/coding-agent/src/theme/theme-schema.json +++ b/packages/coding-agent/src/theme/theme-schema.json @@ -221,6 +221,34 @@ "syntaxPunctuation": { "$ref": "#/$defs/colorValue", "description": "Syntax highlighting: punctuation" + }, + "thinkingOff": { + "$ref": "#/$defs/colorValue", + "description": "Thinking level border: off" + }, + "thinkingMinimal": { + "$ref": "#/$defs/colorValue", + "description": "Thinking level border: minimal" + }, + "thinkingLow": { + "$ref": "#/$defs/colorValue", + "description": "Thinking level border: low" + }, + "thinkingMedium": { + "$ref": "#/$defs/colorValue", + "description": "Thinking level border: medium" + }, + "thinkingHigh": { + "$ref": "#/$defs/colorValue", + "description": "Thinking level border: high" + }, + "thinkingXhigh": { + "$ref": "#/$defs/colorValue", + "description": "Thinking level border: xhigh (OpenAI codex-max only)" + }, + "bashMode": { + "$ref": "#/$defs/colorValue", + "description": "Editor border color in bash mode" } }, "additionalProperties": false diff --git a/packages/coding-agent/src/theme/theme.ts b/packages/coding-agent/src/theme/theme.ts index 287eb522..8f191da7 100644 --- a/packages/coding-agent/src/theme/theme.ts +++ b/packages/coding-agent/src/theme/theme.ts @@ -66,12 +66,15 @@ 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, }), }); @@ -119,7 +122,9 @@ export type ThemeColor = | "thinkingMinimal" | "thinkingLow" | "thinkingMedium" - | "thinkingHigh"; + | "thinkingHigh" + | "thinkingXhigh" + | "bashMode"; export type ThemeBg = "userMessageBg" | "toolPendingBg" | "toolSuccessBg" | "toolErrorBg"; @@ -295,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": @@ -308,10 +313,16 @@ 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); } } + + getBashModeBorderColor(): (str: string) => string { + return (str: string) => this.fg("bashMode", str); + } } // ============================================================================ @@ -366,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; } diff --git a/packages/coding-agent/src/tui/tui-renderer.ts b/packages/coding-agent/src/tui/tui-renderer.ts index 34b4c63d..da29b973 100644 --- a/packages/coding-agent/src/tui/tui-renderer.ts +++ b/packages/coding-agent/src/tui/tui-renderer.ts @@ -71,6 +71,7 @@ export class TuiRenderer { private lastSigintTime = 0; private lastEscapeTime = 0; private changelogMarkdown: string | null = null; + private collapseChangelog = false; // Message queueing private queuedMessages: string[] = []; @@ -126,6 +127,7 @@ export class TuiRenderer { settingsManager: SettingsManager, version: string, changelogMarkdown: string | null = null, + collapseChangelog = false, scopedModels: Array<{ model: Model; thinkingLevel: ThinkingLevel }> = [], fdPath: string | null = null, ) { @@ -134,6 +136,7 @@ export class TuiRenderer { this.settingsManager = settingsManager; this.version = version; this.changelogMarkdown = changelogMarkdown; + this.collapseChangelog = collapseChangelog; this.scopedModels = scopedModels; this.ui = new TUI(new ProcessTerminal()); this.chatContainer = new Container(); @@ -304,10 +307,18 @@ export class TuiRenderer { // 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()); } @@ -1019,7 +1030,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;