Merge branch 'main' into bigrefactor

# Conflicts:
#	package-lock.json
#	packages/agent/package.json
This commit is contained in:
Mario Zechner
2026-05-07 11:17:36 +02:00
50 changed files with 1583 additions and 421 deletions

View File

@@ -2,6 +2,8 @@
## [Unreleased]
## [0.73.0] - 2026-05-04
## [0.72.1] - 2026-05-02
### Changed

View File

@@ -1,6 +1,6 @@
{
"name": "@mariozechner/pi-agent-core",
"version": "0.72.1",
"version": "0.73.0",
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
"type": "module",
"main": "./dist/index.js",
@@ -17,7 +17,7 @@
"prepublishOnly": "npm run clean && npm run build"
},
"dependencies": {
"@mariozechner/pi-ai": "^0.72.1",
"@mariozechner/pi-ai": "^0.73.0",
"ignore": "^7.0.5",
"typebox": "^1.1.24",
"yaml": "^2.8.2"

View File

@@ -2,13 +2,27 @@
## [Unreleased]
### Fixed
- Fixed OpenAI Responses reasoning text streaming for LM Studio and other compatible providers that emit `response.reasoning_text.delta` events ([#4191](https://github.com/badlogic/pi-mono/pull/4191) by [@yaanfpv](https://github.com/yaanfpv)).
- Fixed OpenAI Codex OAuth refresh failures writing directly to stderr while the TUI is active ([#4141](https://github.com/badlogic/pi-mono/issues/4141)).
## [0.73.0] - 2026-05-04
### Breaking Changes
- Switched the built-in `xiaomi` provider endpoint from Token Plan AMS (`https://token-plan-ams.xiaomimimo.com/anthropic`) to API billing (`https://api.xiaomimimo.com/anthropic`). `XIAOMI_API_KEY` now refers to the API billing key from [platform.xiaomimimo.com](https://platform.xiaomimimo.com). Users still on Token Plan must move to the appropriate `xiaomi-token-plan-*` provider and set the corresponding env var.
- Switched the built-in `xiaomi` provider endpoint from Token Plan AMS (`https://token-plan-ams.xiaomimimo.com/anthropic`) to API billing (`https://api.xiaomimimo.com/anthropic`). `XIAOMI_API_KEY` now refers to the API billing key from [platform.xiaomimimo.com](https://platform.xiaomimimo.com). Users still on Token Plan must move to the appropriate `xiaomi-token-plan-*` provider and set the corresponding env var ([#4112](https://github.com/badlogic/pi-mono/pull/4112) by [@Phoen1xCode](https://github.com/Phoen1xCode)).
### Added
- Added Xiaomi MiMo Token Plan regional providers with per-region env vars: `xiaomi-token-plan-cn` (`XIAOMI_TOKEN_PLAN_CN_API_KEY`), `xiaomi-token-plan-ams` (`XIAOMI_TOKEN_PLAN_AMS_API_KEY`), and `xiaomi-token-plan-sgp` (`XIAOMI_TOKEN_PLAN_SGP_API_KEY`).
- Added Xiaomi MiMo Token Plan regional providers with per-region env vars: `xiaomi-token-plan-cn` (`XIAOMI_TOKEN_PLAN_CN_API_KEY`), `xiaomi-token-plan-ams` (`XIAOMI_TOKEN_PLAN_AMS_API_KEY`), and `xiaomi-token-plan-sgp` (`XIAOMI_TOKEN_PLAN_SGP_API_KEY`) ([#4112](https://github.com/badlogic/pi-mono/pull/4112) by [@Phoen1xCode](https://github.com/Phoen1xCode)).
- Added `registerSessionResourceCleanup()` and `cleanupSessionResources()` so providers can register cleanup hooks for session-scoped resources.
### Fixed
- Fixed generated OpenAI-compatible model metadata for Qwen 3.5/3.6 and MiniMax M2.7 to match models.dev and OpenCode Go ([#4110](https://github.com/badlogic/pi-mono/pull/4110) by [@jsynowiec](https://github.com/jsynowiec)).
- Fixed Bedrock Converse thinking effort mapping to preserve native `xhigh` for Claude Opus 4.7.
- Fixed OpenAI Codex Responses WebSocket transport to fall back to SSE when setup fails before streaming starts, and attach transport diagnostics to the assistant message ([#4133](https://github.com/badlogic/pi-mono/issues/4133)).
## [0.72.1] - 2026-05-02

View File

@@ -1,6 +1,6 @@
{
"name": "@mariozechner/pi-ai",
"version": "0.72.1",
"version": "0.73.0",
"description": "Unified LLM API with automatic model discovery and provider configuration",
"type": "module",
"main": "./dist/index.js",

View File

@@ -857,15 +857,17 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
const kimiModels = data["kimi-for-coding"].models as Record<string, ModelsDevModel>;
const hasCanonicalModel = Object.prototype.hasOwnProperty.call(kimiModels, "kimi-for-coding");
const kimiAliases = new Set(["k2p5", "k2p6"]);
for (const [modelId, model] of Object.entries(kimiModels)) {
const m = model as ModelsDevModel;
if (m.tool_call !== true) continue;
// models.dev still exposes deprecated "k2p5" in some snapshots.
// Normalize to the canonical model id and drop duplicates when canonical exists.
if (modelId === "k2p5" && hasCanonicalModel) continue;
// models.dev may expose versioned aliases (e.g. k2p5/k2p6).
// Normalize aliases to the canonical model id and drop duplicates when canonical exists.
if (kimiAliases.has(modelId) && hasCanonicalModel) continue;
const normalizedId = modelId === "k2p5" ? "kimi-for-coding" : modelId;
const normalizedName = modelId === "k2p5" ? "Kimi For Coding" : m.name || normalizedId;
const normalizedId = kimiAliases.has(modelId) ? "kimi-for-coding" : modelId;
const normalizedName = kimiAliases.has(modelId) ? "Kimi For Coding" : m.name || normalizedId;
models.push({
id: normalizedId,

View File

@@ -34,6 +34,8 @@ export type {
OAuthProviderId,
OAuthProviderInfo,
OAuthProviderInterface,
OAuthSelectOption,
OAuthSelectPrompt,
} from "./utils/oauth/types.js";
export * from "./utils/overflow.js";
export * from "./utils/typebox-helpers.js";

View File

@@ -5819,24 +5819,6 @@ export const MODELS = {
} satisfies Model<"openai-completions">,
},
"kimi-coding": {
"k2p6": {
id: "k2p6",
name: "Kimi K2.6",
api: "anthropic-messages",
provider: "kimi-coding",
baseUrl: "https://api.kimi.com/coding",
headers: {"User-Agent":"KimiCLI/1.5"},
reasoning: true,
input: ["text", "image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 262144,
maxTokens: 32768,
} satisfies Model<"anthropic-messages">,
"kimi-for-coding": {
id: "kimi-for-coding",
name: "Kimi For Coding",
@@ -9207,8 +9189,8 @@ export const MODELS = {
reasoning: true,
input: ["text"],
cost: {
input: 0.21,
output: 0.7899999999999999,
input: 0.27,
output: 0.95,
cacheRead: 0.13,
cacheWrite: 0,
},
@@ -9279,13 +9261,13 @@ export const MODELS = {
thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"},
input: ["text"],
cost: {
input: 0.435,
output: 0.87,
cacheRead: 0.003625,
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 1048576,
maxTokens: 384000,
contextWindow: 131000,
maxTokens: 131000,
} satisfies Model<"openai-completions">,
"essentialai/rnj-1-instruct": {
id: "essentialai/rnj-1-instruct",
@@ -9318,7 +9300,7 @@ export const MODELS = {
cacheRead: 0.024999999999999998,
cacheWrite: 0.08333333333333334,
},
contextWindow: 1000000,
contextWindow: 1048576,
maxTokens: 8192,
} satisfies Model<"openai-completions">,
"google/gemini-2.0-flash-lite-001": {
@@ -11807,13 +11789,13 @@ export const MODELS = {
reasoning: true,
input: ["text"],
cost: {
input: 0.08,
output: 0.28,
input: 0.09,
output: 0.44999999999999996,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 40960,
maxTokens: 16384,
maxTokens: 20000,
} satisfies Model<"openai-completions">,
"qwen/qwen3-30b-a3b-instruct-2507": {
id: "qwen/qwen3-30b-a3b-instruct-2507",
@@ -12232,13 +12214,13 @@ export const MODELS = {
reasoning: true,
input: ["text", "image"],
cost: {
input: 0.1625,
output: 1.3,
cacheRead: 0,
input: 0.15,
output: 1,
cacheRead: 0.049999999999999996,
cacheWrite: 0,
},
contextWindow: 262144,
maxTokens: 65536,
maxTokens: 262144,
} satisfies Model<"openai-completions">,
"qwen/qwen3.5-397b-a17b": {
id: "qwen/qwen3.5-397b-a17b",
@@ -12342,6 +12324,23 @@ export const MODELS = {
contextWindow: 262144,
maxTokens: 81920,
} satisfies Model<"openai-completions">,
"qwen/qwen3.6-35b-a3b": {
id: "qwen/qwen3.6-35b-a3b",
name: "Qwen: Qwen3.6 35B A3B",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: true,
input: ["text", "image"],
cost: {
input: 0.15,
output: 1,
cacheRead: 0.049999999999999996,
cacheWrite: 0,
},
contextWindow: 262144,
maxTokens: 262144,
} satisfies Model<"openai-completions">,
"qwen/qwen3.6-flash": {
id: "qwen/qwen3.6-flash",
name: "Qwen: Qwen3.6 Flash",
@@ -14573,23 +14572,6 @@ export const MODELS = {
contextWindow: 131072,
maxTokens: 131072,
} satisfies Model<"anthropic-messages">,
"moonshotai/kimi-k2-0905": {
id: "moonshotai/kimi-k2-0905",
name: "Kimi K2 0905",
api: "anthropic-messages",
provider: "vercel-ai-gateway",
baseUrl: "https://ai-gateway.vercel.sh",
reasoning: false,
input: ["text"],
cost: {
input: 0.6,
output: 2.5,
cacheRead: 0.3,
cacheWrite: 0,
},
contextWindow: 256000,
maxTokens: 128000,
} satisfies Model<"anthropic-messages">,
"moonshotai/kimi-k2-thinking": {
id: "moonshotai/kimi-k2-thinking",
name: "Kimi K2 Thinking",
@@ -16813,7 +16795,7 @@ export const MODELS = {
} satisfies Model<"openai-completions">,
"glm-5v-turbo": {
id: "glm-5v-turbo",
name: "glm-5v-turbo",
name: "GLM-5V-Turbo",
api: "openai-completions",
provider: "zai",
baseUrl: "https://api.z.ai/api/coding/paas/v4",

View File

@@ -352,7 +352,7 @@ function buildRequestBody(
model: model.id,
store: false,
stream: true,
instructions: context.systemPrompt,
instructions: context.systemPrompt || "You are a helpful assistant.",
input: messages,
text: { verbosity: options?.textVerbosity || "low" },
include: ["reasoning.encrypted_content"],

View File

@@ -354,6 +354,16 @@ export async function processResponsesStream<TApi extends Api>(
});
}
}
} else if (event.type === "response.reasoning_text.delta") {
if (currentItem?.type === "reasoning" && currentBlock?.type === "thinking") {
currentBlock.thinking += event.delta;
stream.push({
type: "thinking_delta",
contentIndex: blockIndex(),
delta: event.delta,
partial: output,
});
}
} else if (event.type === "response.content_part.added") {
if (currentItem?.type === "message") {
currentItem.content = currentItem.content || [];
@@ -429,7 +439,9 @@ export async function processResponsesStream<TApi extends Api>(
const item = event.item;
if (item.type === "reasoning" && currentBlock?.type === "thinking") {
currentBlock.thinking = item.summary?.map((s) => s.text).join("\n\n") || "";
const summaryText = item.summary?.map((s) => s.text).join("\n\n") || "";
const contentText = item.content?.map((c) => c.text).join("\n\n") || "";
currentBlock.thinking = summaryText || contentText || currentBlock.thinking;
currentBlock.thinkingSignature = JSON.stringify(item);
stream.push({
type: "thinking_end",

View File

@@ -30,7 +30,7 @@ const SCOPE = "openid profile email offline_access";
const JWT_CLAIM_PATH = "https://api.openai.com/auth";
type TokenSuccess = { type: "success"; access: string; refresh: string; expires: number };
type TokenFailure = { type: "failed" };
type TokenFailure = { type: "failed"; message: string; status?: number };
type TokenResult = TokenSuccess | TokenFailure;
type JwtPayload = {
@@ -108,8 +108,11 @@ async function exchangeAuthorizationCode(
if (!response.ok) {
const text = await response.text().catch(() => "");
console.error("[openai-codex] code->token failed:", response.status, text);
return { type: "failed" };
return {
type: "failed",
status: response.status,
message: `OpenAI Codex token exchange failed (${response.status}): ${text || response.statusText}`,
};
}
const json = (await response.json()) as {
@@ -119,8 +122,10 @@ async function exchangeAuthorizationCode(
};
if (!json.access_token || !json.refresh_token || typeof json.expires_in !== "number") {
console.error("[openai-codex] token response missing fields:", json);
return { type: "failed" };
return {
type: "failed",
message: `OpenAI Codex token exchange response missing fields: ${JSON.stringify(json)}`,
};
}
return {
@@ -145,8 +150,11 @@ async function refreshAccessToken(refreshToken: string): Promise<TokenResult> {
if (!response.ok) {
const text = await response.text().catch(() => "");
console.error("[openai-codex] Token refresh failed:", response.status, text);
return { type: "failed" };
return {
type: "failed",
status: response.status,
message: `OpenAI Codex token refresh failed (${response.status}): ${text || response.statusText}`,
};
}
const json = (await response.json()) as {
@@ -156,8 +164,10 @@ async function refreshAccessToken(refreshToken: string): Promise<TokenResult> {
};
if (!json.access_token || !json.refresh_token || typeof json.expires_in !== "number") {
console.error("[openai-codex] Token refresh response missing fields:", json);
return { type: "failed" };
return {
type: "failed",
message: `OpenAI Codex token refresh response missing fields: ${JSON.stringify(json)}`,
};
}
return {
@@ -167,8 +177,10 @@ async function refreshAccessToken(refreshToken: string): Promise<TokenResult> {
expires: Date.now() + json.expires_in * 1000,
};
} catch (error) {
console.error("[openai-codex] Token refresh error:", error);
return { type: "failed" };
return {
type: "failed",
message: `OpenAI Codex token refresh error: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
@@ -258,12 +270,7 @@ function startLocalOAuthServer(state: string): Promise<OAuthServerInfo> {
waitForCode: () => waitForCodePromise,
});
})
.on("error", (err: NodeJS.ErrnoException) => {
console.error(
`[openai-codex] Failed to bind http://${CALLBACK_HOST}:1455 (`,
err.code,
") Falling back to manual paste.",
);
.on("error", (_err: NodeJS.ErrnoException) => {
settleWait?.(null);
resolve({
close: () => {
@@ -386,7 +393,7 @@ export async function loginOpenAICodex(options: {
const tokenResult = await exchangeAuthorizationCode(code, verifier);
if (tokenResult.type !== "success") {
throw new Error("Token exchange failed");
throw new Error(tokenResult.message);
}
const accountId = getAccountId(tokenResult.access);
@@ -411,7 +418,7 @@ export async function loginOpenAICodex(options: {
export async function refreshOpenAICodexToken(refreshToken: string): Promise<OAuthCredentials> {
const result = await refreshAccessToken(refreshToken);
if (result.type !== "success") {
throw new Error("Failed to refresh OpenAI Codex token");
throw new Error(result.message);
}
const accountId = getAccountId(result.access);

View File

@@ -23,11 +23,23 @@ export type OAuthAuthInfo = {
instructions?: string;
};
export type OAuthSelectOption = {
id: string;
label: string;
};
export type OAuthSelectPrompt = {
message: string;
options: OAuthSelectOption[];
};
export interface OAuthLoginCallbacks {
onAuth: (info: OAuthAuthInfo) => void;
onPrompt: (prompt: OAuthPrompt) => Promise<string>;
onProgress?: (message: string) => void;
onManualCodeInput?: () => Promise<string>;
/** Show an interactive selector and return the selected option id, or undefined on cancel. */
onSelect?: (prompt: OAuthSelectPrompt) => Promise<string | undefined>;
signal?: AbortSignal;
}

View File

@@ -0,0 +1,32 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { refreshOpenAICodexToken } from "../src/utils/oauth/openai-codex.js";
describe("OpenAI Codex OAuth", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("does not write token refresh failures to stderr", async () => {
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
vi.stubGlobal(
"fetch",
vi.fn(async (): Promise<Response> => {
return new Response(
JSON.stringify({
error: {
message: "Could not validate your token. Please try signing in again.",
type: "invalid_request_error",
},
}),
{ status: 401, statusText: "Unauthorized", headers: { "Content-Type": "application/json" } },
);
}),
);
await expect(refreshOpenAICodexToken("invalid-refresh-token")).rejects.toThrow(
/OpenAI Codex token refresh failed \(401\).*Could not validate your token/,
);
expect(consoleError).not.toHaveBeenCalled();
});
});

View File

@@ -2,13 +2,27 @@
## [Unreleased]
### Fixed
- Fixed `pi -p` treating prompts that start with YAML frontmatter as extension flags instead of user messages ([#4163](https://github.com/badlogic/pi-mono/issues/4163)).
- Fixed pending tool results not updating in the live TUI after toggling thinking block visibility while the tool is running ([#4167](https://github.com/badlogic/pi-mono/issues/4167)).
- Fixed `/copy` reporting success on Linux without writing the clipboard on Wayland-only compositors (Hyprland, Niri, ...) by skipping the X11-only native addon on Linux and routing through `wl-copy`/`xclip`/`xsel` instead ([#4177](https://github.com/badlogic/pi-mono/issues/4177)).
## [0.73.0] - 2026-05-04
### New Features
- **Xiaomi MiMo API billing and regional Token Plan providers** - `xiaomi` now uses API billing, with separate `xiaomi-token-plan-{cn,ams,sgp}` providers. See [docs/providers.md#api-keys](docs/providers.md#api-keys) and [README.md#providers--models](README.md#providers--models). ([#4112](https://github.com/badlogic/pi-mono/pull/4112) by [@Phoen1xCode](https://github.com/Phoen1xCode))
- **Incremental bash output streaming** - Bash tool output now appears while commands run instead of only after completion. ([#4145](https://github.com/badlogic/pi-mono/issues/4145))
- **Compact read rendering** - Interactive `read` output for Pi docs, context files, and skills is collapsed by default and shows selected line ranges.
### Breaking Changes
- Switched the built-in `xiaomi` provider from Token Plan AMS to Xiaomi's API billing endpoint, and renamed its `/login` display from "Xiaomi MiMo Token Plan" to "Xiaomi MiMo". `XIAOMI_API_KEY` now refers to the API billing key from [platform.xiaomimimo.com](https://platform.xiaomimimo.com). Users on Token Plan should switch to the appropriate `xiaomi-token-plan-*` provider and set the corresponding env var.
- Switched the built-in `xiaomi` provider from Token Plan AMS to Xiaomi's API billing endpoint, and renamed its `/login` display from "Xiaomi MiMo Token Plan" to "Xiaomi MiMo". `XIAOMI_API_KEY` now refers to the API billing key from [platform.xiaomimimo.com](https://platform.xiaomimimo.com). Users on Token Plan should switch to the appropriate `xiaomi-token-plan-*` provider and set the corresponding env var ([#4112](https://github.com/badlogic/pi-mono/pull/4112) by [@Phoen1xCode](https://github.com/Phoen1xCode)).
### Added
- Added three Xiaomi MiMo Token Plan regional providers visible in `/login`: `xiaomi-token-plan-cn` (`XIAOMI_TOKEN_PLAN_CN_API_KEY`), `xiaomi-token-plan-ams` (`XIAOMI_TOKEN_PLAN_AMS_API_KEY`), `xiaomi-token-plan-sgp` (`XIAOMI_TOKEN_PLAN_SGP_API_KEY`). Each defaults to `mimo-v2.5-pro`.
- Added three Xiaomi MiMo Token Plan regional providers visible in `/login`: `xiaomi-token-plan-cn` (`XIAOMI_TOKEN_PLAN_CN_API_KEY`), `xiaomi-token-plan-ams` (`XIAOMI_TOKEN_PLAN_AMS_API_KEY`), `xiaomi-token-plan-sgp` (`XIAOMI_TOKEN_PLAN_SGP_API_KEY`). Each defaults to `mimo-v2.5-pro` ([#4112](https://github.com/badlogic/pi-mono/pull/4112) by [@Phoen1xCode](https://github.com/Phoen1xCode)).
### Changed
@@ -16,7 +30,14 @@
### Fixed
- Fixed generated OpenAI-compatible model metadata for Qwen 3.5/3.6 and MiniMax M2.7, so those models work through the built-in provider catalog ([#4110](https://github.com/badlogic/pi-mono/pull/4110) by [@jsynowiec](https://github.com/jsynowiec)).
- Fixed Bedrock Claude Opus 4.7 `xhigh` thinking requests by preserving the provider's native effort value.
- Fixed OpenAI Codex WebSocket transport to fall back to SSE when setup fails before streaming starts, and surface transport diagnostics in the assistant message ([#4133](https://github.com/badlogic/pi-mono/issues/4133)).
- Fixed OpenAI Codex WebSocket transport keeping `--print` and JSON mode processes alive after the response by closing cached WebSocket sessions during session shutdown ([#4103](https://github.com/badlogic/pi-mono/issues/4103)).
- Fixed compact `read` tool calls to render directly and include selected line ranges in interactive output.
- Fixed interactive sessions to exit when terminal input is lost instead of continuing in a broken state.
- Fixed bash tool output to stream incrementally while commands run instead of waiting for command completion ([#4145](https://github.com/badlogic/pi-mono/issues/4145)).
- Fixed selector and autocomplete fuzzy ranking to prioritize exact matches.
## [0.72.1] - 2026-05-02

View File

@@ -1,12 +1,12 @@
{
"name": "pi-extension-custom-provider",
"version": "0.72.1",
"version": "0.73.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pi-extension-custom-provider",
"version": "0.72.1",
"version": "0.73.0",
"dependencies": {
"@anthropic-ai/sdk": "^0.52.0"
}

View File

@@ -1,7 +1,7 @@
{
"name": "pi-extension-custom-provider-anthropic",
"private": true,
"version": "0.72.1",
"version": "0.73.0",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",

View File

@@ -1,7 +1,7 @@
{
"name": "pi-extension-custom-provider-gitlab-duo",
"private": true,
"version": "0.72.1",
"version": "0.73.0",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",

View File

@@ -1,12 +1,12 @@
{
"name": "pi-extension-sandbox",
"version": "1.2.1",
"version": "1.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pi-extension-sandbox",
"version": "1.2.1",
"version": "1.3.0",
"dependencies": {
"@anthropic-ai/sandbox-runtime": "^0.0.26"
}

View File

@@ -1,7 +1,7 @@
{
"name": "pi-extension-sandbox",
"private": true,
"version": "1.2.1",
"version": "1.3.0",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",

View File

@@ -1,12 +1,12 @@
{
"name": "pi-extension-with-deps",
"version": "0.72.1",
"version": "0.73.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pi-extension-with-deps",
"version": "0.72.1",
"version": "0.73.0",
"dependencies": {
"ms": "^2.1.3"
},

View File

@@ -1,7 +1,7 @@
{
"name": "pi-extension-with-deps",
"private": true,
"version": "0.72.1",
"version": "0.73.0",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",

View File

@@ -1,6 +1,6 @@
{
"name": "@mariozechner/pi-coding-agent",
"version": "0.72.1",
"version": "0.73.0",
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
"type": "module",
"piConfig": {
@@ -38,10 +38,9 @@
"prepublishOnly": "npm run clean && npm run build"
},
"dependencies": {
"@mariozechner/jiti": "^2.6.2",
"@mariozechner/pi-agent-core": "^0.72.1",
"@mariozechner/pi-ai": "^0.72.1",
"@mariozechner/pi-tui": "^0.72.1",
"@mariozechner/pi-agent-core": "^0.73.0",
"@mariozechner/pi-ai": "^0.73.0",
"@mariozechner/pi-tui": "^0.73.0",
"@silvia-odwyer/photon-node": "^0.3.4",
"chalk": "^5.5.0",
"cli-highlight": "^2.1.11",
@@ -51,6 +50,7 @@
"glob": "^13.0.1",
"hosted-git-info": "^9.0.2",
"ignore": "^7.0.5",
"jiti": "^2.7.0",
"marked": "^15.0.12",
"minimatch": "^10.2.3",
"proper-lockfile": "^4.1.2",

View File

@@ -122,6 +122,11 @@ export function parseArgs(args: string[]): Args {
}
} else if (arg === "--print" || arg === "-p") {
result.print = true;
const next = args[i + 1];
if (next !== undefined && !next.startsWith("@") && (!next.startsWith("-") || next.startsWith("---"))) {
result.messages.push(next);
i++;
}
} else if (arg === "--export" && i + 1 < args.length) {
result.export = args[++i];
} else if ((arg === "--extension" || arg === "-e") && i + 1 < args.length) {

View File

@@ -204,6 +204,10 @@
color: var(--accent);
}
.tree-role-skill {
color: var(--customMessageLabel);
}
.tree-role-assistant {
color: var(--success);
}
@@ -383,7 +387,8 @@
}
.user-message:hover .copy-link-btn,
.assistant-message:hover .copy-link-btn {
.assistant-message:hover .copy-link-btn,
.skill-user-entry:hover .copy-link-btn {
opacity: 1;
}
@@ -786,6 +791,45 @@
font-weight: bold;
}
/* Skill invocation - matches compaction style (clickable, collapsed by default) */
.skill-invocation {
background: var(--customMessageBg);
border-radius: 4px;
padding: var(--line-height);
cursor: pointer;
}
.skill-invocation-label {
color: var(--customMessageLabel);
font-weight: bold;
}
.skill-invocation-collapsed {
color: var(--customMessageText);
}
.skill-invocation-content {
display: none;
color: var(--customMessageText);
margin-top: var(--line-height);
}
.skill-invocation.expanded .skill-invocation-collapsed {
display: none;
}
.skill-invocation.expanded .skill-invocation-content {
display: block;
}
.skill-invocation + .user-message {
margin-top: var(--line-height);
}
.skill-user-entry {
position: relative;
}
/* Branch summary */
.branch-summary {
background: var(--customMessageBg);

View File

@@ -313,6 +313,22 @@
return '';
}
/**
* Parse a skill block from message text.
* Returns null if the text doesn't contain a skill block.
* Matches the format: <skill name="..." location="...">\n...\n</skill>\n\nuser message
*/
function parseSkillBlock(text) {
const match = text.match(/^<skill name="([^"]+)" location="([^"]+)">\n([\s\S]*?)\n<\/skill>(?:\n\n([\s\S]+))?$/);
if (!match) return null;
return {
name: match[1],
location: match[2],
content: match[3],
userMessage: match[4]?.trim() || undefined,
};
}
function getSearchableText(entry, label) {
const parts = [];
if (label) parts.push(label);
@@ -613,7 +629,16 @@
case 'message': {
const msg = entry.message;
if (msg.role === 'user') {
const content = truncate(normalize(extractContent(msg.content)));
const rawContent = extractContent(msg.content);
const skillBlock = parseSkillBlock(rawContent);
if (skillBlock) {
let treeHtml = labelHtml + `<span class="tree-role-skill">skill:</span> ${escapeHtml(skillBlock.name)}`;
if (skillBlock.userMessage) {
treeHtml += ` · <span class="tree-role-user">user:</span> ${escapeHtml(truncate(normalize(skillBlock.userMessage)))}`;
}
return treeHtml;
}
const content = truncate(normalize(rawContent));
return labelHtml + `<span class="tree-role-user">user:</span> ${escapeHtml(content)}`;
}
if (msg.role === 'assistant') {
@@ -1140,8 +1165,46 @@
const msg = entry.message;
if (msg.role === 'user') {
let html = `<div class="user-message" id="${entryDomId}">${copyBtnHtml}${tsHtml}`;
const content = msg.content;
const text = typeof content === 'string' ? content :
content.filter(c => c.type === 'text').map(c => c.text).join('\n');
const skillBlock = parseSkillBlock(text);
if (skillBlock) {
// Collect images from content array
const images = Array.isArray(content) ? content.filter(c => c.type === 'image') : [];
const hasUserContent = skillBlock.userMessage || images.length > 0;
let html = `<div class="skill-user-entry" id="${entryDomId}">${copyBtnHtml}${tsHtml}`;
// Skill invocation (collapsed by default, click to expand)
html += `<div class="skill-invocation" onclick="if(window.getSelection().toString())return;this.classList.toggle('expanded')">
<div class="skill-invocation-label">[skill] ${escapeHtml(skillBlock.name)}</div>
<div class="skill-invocation-collapsed">${escapeHtml(skillBlock.name)} (click to expand)</div>
<div class="skill-invocation-content markdown-content">${safeMarkedParse(skillBlock.content)}</div>
</div>`;
// User message (separate block if present)
if (hasUserContent) {
html += '<div class="user-message">';
if (images.length > 0) {
html += '<div class="message-images">';
for (const img of images) {
html += `<img src="data:${escapeHtml(img.mimeType || 'image/png')};base64,${escapeHtml(img.data || '')}" class="message-image" />`;
}
html += '</div>';
}
if (skillBlock.userMessage) {
html += `<div class="markdown-content">${safeMarkedParse(skillBlock.userMessage)}</div>`;
}
html += '</div>';
}
html += '</div>';
return html;
}
// No skill block - normal user message
let html = `<div class="user-message" id="${entryDomId}">${copyBtnHtml}${tsHtml}`;
if (Array.isArray(content)) {
const images = content.filter(c => c.type === 'image');
@@ -1154,8 +1217,6 @@
}
}
const text = typeof content === 'string' ? content :
content.filter(c => c.type === 'text').map(c => c.text).join('\n');
if (text.trim()) {
html += `<div class="markdown-content">${safeMarkedParse(text)}</div>`;
}
@@ -1716,6 +1777,9 @@
document.querySelectorAll('.compaction').forEach(el => {
el.classList.toggle('expanded', toolOutputsExpanded);
});
document.querySelectorAll('.skill-invocation').forEach(el => {
el.classList.toggle('expanded', toolOutputsExpanded);
});
};
const attachHeaderHandlers = () => {

View File

@@ -1,7 +1,6 @@
/**
* Extension loader - loads TypeScript extension modules using jiti.
*
* Uses @mariozechner/jiti fork with virtualModules support for compiled Bun binaries.
*/
import * as fs from "node:fs";
@@ -9,12 +8,12 @@ import { createRequire } from "node:module";
import * as os from "node:os";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { createJiti } from "@mariozechner/jiti";
import * as _bundledPiAgentCore from "@mariozechner/pi-agent-core";
import * as _bundledPiAi from "@mariozechner/pi-ai";
import * as _bundledPiAiOauth from "@mariozechner/pi-ai/oauth";
import type { KeyId } from "@mariozechner/pi-tui";
import * as _bundledPiTui from "@mariozechner/pi-tui";
import { createJiti } from "jiti/static";
// Static imports of packages that extensions may use.
// These MUST be static so Bun bundles them into the compiled binary.
// The virtualModules option then makes them available to extensions.

View File

@@ -213,6 +213,13 @@ function formatValidationPath(error: TLocalizedValidationError): string {
return path || "root";
}
/** Strip `//` line comments and trailing commas from JSON, leaving string literals untouched. */
function stripJsonComments(input: string): string {
return input
.replace(/"(?:\\.|[^"\\])*"|\/\/[^\n]*/g, (m) => (m[0] === '"' ? m : ""))
.replace(/"(?:\\.|[^"\\])*"|,(\s*[}\]])/g, (m, tail) => tail ?? (m[0] === '"' ? m : ""));
}
/** Provider override config (baseUrl, compat) without request auth/headers */
interface ProviderOverride {
baseUrl?: string;
@@ -450,7 +457,7 @@ export class ModelRegistry {
try {
const content = readFileSync(modelsJsonPath, "utf-8");
const parsed = JSON.parse(content) as unknown;
const parsed = JSON.parse(stripJsonComments(content)) as unknown;
if (!validateModelsConfig.Check(parsed)) {
const errors =

View File

@@ -1,7 +1,4 @@
import { randomBytes } from "node:crypto";
import { createWriteStream, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { existsSync } from "node:fs";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { Container, Text, truncateToWidth } from "@mariozechner/pi-tui";
import { spawn } from "child_process";
@@ -18,17 +15,10 @@ import {
untrackDetachedChildPid,
} from "../../utils/shell.js";
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js";
import { OutputAccumulator } from "./output-accumulator.js";
import { getTextOutput, invalidArgText, str } from "./render-utils.js";
import { wrapToolDefinition } from "./tool-definition-wrapper.js";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateTail } from "./truncate.js";
/**
* 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`);
}
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult } from "./truncate.js";
const bashSchema = Type.Object({
command: Type.String({ description: "Bash command to execute" }),
@@ -161,6 +151,7 @@ export interface BashToolOptions {
}
const BASH_PREVIEW_LINES = 5;
const BASH_UPDATE_THROTTLE_MS = 100;
type BashRenderState = {
startedAt: number | undefined;
@@ -292,118 +283,119 @@ export function createBashToolDefinition(
) {
const resolvedCommand = commandPrefix ? `${commandPrefix}\n${command}` : command;
const spawnContext = resolveSpawnContext(resolvedCommand, cwd, spawnHook);
const output = new OutputAccumulator({ tempFilePrefix: "pi-bash" });
let updateTimer: NodeJS.Timeout | undefined;
let updateDirty = false;
let lastUpdateAt = 0;
const emitOutputUpdate = () => {
if (!onUpdate || !updateDirty) return;
updateDirty = false;
lastUpdateAt = Date.now();
const snapshot = output.snapshot({ persistIfTruncated: true });
onUpdate({
content: [{ type: "text", text: snapshot.content || "" }],
details: {
truncation: snapshot.truncation.truncated ? snapshot.truncation : undefined,
fullOutputPath: snapshot.fullOutputPath,
},
});
};
const clearUpdateTimer = () => {
if (updateTimer) {
clearTimeout(updateTimer);
updateTimer = undefined;
}
};
const scheduleOutputUpdate = () => {
if (!onUpdate) return;
updateDirty = true;
const delay = BASH_UPDATE_THROTTLE_MS - (Date.now() - lastUpdateAt);
if (delay <= 0) {
clearUpdateTimer();
emitOutputUpdate();
return;
}
updateTimer ??= setTimeout(() => {
updateTimer = undefined;
emitOutputUpdate();
}, delay);
};
if (onUpdate) {
onUpdate({ content: [], details: undefined });
}
return new Promise((resolve, reject) => {
let tempFilePath: string | undefined;
let tempFileStream: ReturnType<typeof createWriteStream> | undefined;
let totalBytes = 0;
const chunks: Buffer[] = [];
let chunksBytes = 0;
const maxChunksBytes = DEFAULT_MAX_BYTES * 2;
const ensureTempFile = () => {
if (tempFilePath) return;
tempFilePath = getTempFilePath();
tempFileStream = createWriteStream(tempFilePath);
for (const chunk of chunks) tempFileStream.write(chunk);
};
const handleData = (data: Buffer) => {
output.append(data);
scheduleOutputUpdate();
};
const handleData = (data: Buffer) => {
totalBytes += data.length;
// Start writing to a temp file once output exceeds the in-memory threshold.
if (totalBytes > DEFAULT_MAX_BYTES) {
ensureTempFile();
}
// Write to temp file if we have one.
if (tempFileStream) tempFileStream.write(data);
// Keep a rolling buffer of recent output for tail truncation.
chunks.push(data);
chunksBytes += data.length;
// Trim old chunks if the rolling buffer grows too large.
while (chunksBytes > maxChunksBytes && chunks.length > 1) {
const removed = chunks.shift()!;
chunksBytes -= removed.length;
}
// Stream partial output using the rolling tail buffer.
if (onUpdate) {
const fullBuffer = Buffer.concat(chunks);
const fullText = fullBuffer.toString("utf-8");
const truncation = truncateTail(fullText);
if (truncation.truncated) {
ensureTempFile();
}
onUpdate({
content: [{ type: "text", text: truncation.content || "" }],
details: {
truncation: truncation.truncated ? truncation : undefined,
fullOutputPath: tempFilePath,
},
});
}
};
const finishOutput = async () => {
output.finish();
clearUpdateTimer();
emitOutputUpdate();
const snapshot = output.snapshot({ persistIfTruncated: true });
await output.closeTempFile();
return snapshot;
};
ops.exec(spawnContext.command, spawnContext.cwd, {
onData: handleData,
signal,
timeout,
env: spawnContext.env,
})
.then(({ exitCode }) => {
// Combine the rolling buffer chunks.
const fullBuffer = Buffer.concat(chunks);
const fullOutput = fullBuffer.toString("utf-8");
// Apply tail truncation for the final display payload.
const truncation = truncateTail(fullOutput);
if (truncation.truncated) {
ensureTempFile();
}
// Close temp file stream before building the final result.
if (tempFileStream) tempFileStream.end();
let outputText = truncation.content || "(no output)";
let details: BashToolDetails | undefined;
if (truncation.truncated) {
// Build truncation details and an actionable notice.
details = { truncation, fullOutputPath: tempFilePath };
const startLine = truncation.totalLines - truncation.outputLines + 1;
const endLine = truncation.totalLines;
if (truncation.lastLinePartial) {
// Edge case: the last line alone is larger than the byte limit.
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 (exitCode !== 0 && exitCode !== null) {
outputText += `\n\nCommand exited with code ${exitCode}`;
reject(new Error(outputText));
} else {
resolve({ content: [{ type: "text", text: outputText }], details });
}
})
.catch((err: Error) => {
// Close temp file stream and include buffered output in the error message.
if (tempFileStream) tempFileStream.end();
const fullBuffer = Buffer.concat(chunks);
let output = fullBuffer.toString("utf-8");
if (err.message === "aborted") {
if (output) output += "\n\n";
output += "Command aborted";
reject(new Error(output));
} else if (err.message.startsWith("timeout:")) {
const timeoutSecs = err.message.split(":")[1];
if (output) output += "\n\n";
output += `Command timed out after ${timeoutSecs} seconds`;
reject(new Error(output));
} else {
reject(err);
}
const formatOutput = (snapshot: Awaited<ReturnType<typeof finishOutput>>, emptyText = "(no output)") => {
const truncation = snapshot.truncation;
let text = snapshot.content || emptyText;
let details: BashToolDetails | undefined;
if (truncation.truncated) {
details = { truncation, fullOutputPath: snapshot.fullOutputPath };
const startLine = truncation.totalLines - truncation.outputLines + 1;
const endLine = truncation.totalLines;
if (truncation.lastLinePartial) {
const lastLineSize = formatSize(output.getLastLineBytes());
text += `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${snapshot.fullOutputPath}]`;
} else if (truncation.truncatedBy === "lines") {
text += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${snapshot.fullOutputPath}]`;
} else {
text += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Full output: ${snapshot.fullOutputPath}]`;
}
}
return { text, details };
};
const appendStatus = (text: string, status: string) => `${text ? `${text}\n\n` : ""}${status}`;
try {
let exitCode: number | null;
try {
const result = await ops.exec(spawnContext.command, spawnContext.cwd, {
onData: handleData,
signal,
timeout,
env: spawnContext.env,
});
});
exitCode = result.exitCode;
} catch (err) {
const snapshot = await finishOutput();
const { text } = formatOutput(snapshot, "");
if (err instanceof Error && err.message === "aborted") {
throw new Error(appendStatus(text, "Command aborted"));
}
if (err instanceof Error && err.message.startsWith("timeout:")) {
const timeoutSecs = err.message.split(":")[1];
throw new Error(appendStatus(text, `Command timed out after ${timeoutSecs} seconds`));
}
throw err;
}
const snapshot = await finishOutput();
const { text: outputText, details } = formatOutput(snapshot);
if (exitCode !== 0 && exitCode !== null) {
throw new Error(appendStatus(outputText, `Command exited with code ${exitCode}`));
}
return { content: [{ type: "text", text: outputText }], details };
} finally {
clearUpdateTimer();
}
},
renderCall(args, _theme, context) {
const state = context.state;

View File

@@ -0,0 +1,216 @@
import { randomBytes } from "node:crypto";
import { createWriteStream, type WriteStream } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, type TruncationResult, truncateTail } from "./truncate.js";
export interface OutputAccumulatorOptions {
maxLines?: number;
maxBytes?: number;
tempFilePrefix?: string;
}
export interface OutputSnapshot {
content: string;
truncation: TruncationResult;
fullOutputPath?: string;
}
function defaultTempFilePath(prefix: string): string {
const id = randomBytes(8).toString("hex");
return join(tmpdir(), `${prefix}-${id}.log`);
}
function byteLength(text: string): number {
return Buffer.byteLength(text, "utf-8");
}
/**
* Incrementally tracks streaming output with bounded memory.
*
* Appends decode chunks with a streaming UTF-8 decoder, keeps only a decoded
* tail for display snapshots, and opens a temp file when the full output needs
* to be preserved.
*/
export class OutputAccumulator {
private readonly maxLines: number;
private readonly maxBytes: number;
private readonly maxRollingBytes: number;
private readonly tempFilePrefix: string;
private readonly decoder = new TextDecoder();
private rawChunks: Buffer[] = [];
private tailText = "";
private tailBytes = 0;
private tailStartsAtLineBoundary = true;
private totalRawBytes = 0;
private totalDecodedBytes = 0;
private totalLines = 1;
private currentLineBytes = 0;
private finished = false;
private tempFilePath: string | undefined;
private tempFileStream: WriteStream | undefined;
constructor(options: OutputAccumulatorOptions = {}) {
this.maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
this.maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
this.maxRollingBytes = Math.max(this.maxBytes * 2, 1);
this.tempFilePrefix = options.tempFilePrefix ?? "pi-output";
}
append(data: Buffer): void {
if (this.finished) {
throw new Error("Cannot append to a finished output accumulator");
}
this.totalRawBytes += data.length;
this.appendDecodedText(this.decoder.decode(data, { stream: true }));
if (this.tempFileStream || this.shouldUseTempFile()) {
this.ensureTempFile();
this.tempFileStream?.write(data);
} else if (data.length > 0) {
this.rawChunks.push(data);
}
}
finish(): void {
if (this.finished) {
return;
}
this.finished = true;
this.appendDecodedText(this.decoder.decode());
if (this.shouldUseTempFile()) {
this.ensureTempFile();
}
}
snapshot(options: { persistIfTruncated?: boolean } = {}): OutputSnapshot {
const tailTruncation = truncateTail(this.getSnapshotText(), {
maxLines: this.maxLines,
maxBytes: this.maxBytes,
});
const truncated = this.totalLines > this.maxLines || this.totalDecodedBytes > this.maxBytes;
const truncatedBy = truncated
? (tailTruncation.truncatedBy ?? (this.totalDecodedBytes > this.maxBytes ? "bytes" : "lines"))
: null;
const truncation: TruncationResult = {
...tailTruncation,
truncated,
truncatedBy,
totalLines: this.totalLines,
totalBytes: this.totalDecodedBytes,
maxLines: this.maxLines,
maxBytes: this.maxBytes,
};
if (options.persistIfTruncated && truncation.truncated) {
this.ensureTempFile();
}
return {
content: truncation.content,
truncation,
fullOutputPath: this.tempFilePath,
};
}
async closeTempFile(): Promise<void> {
if (!this.tempFileStream) {
return;
}
const stream = this.tempFileStream;
this.tempFileStream = undefined;
await new Promise<void>((resolve, reject) => {
const onError = (error: Error) => {
stream.off("finish", onFinish);
reject(error);
};
const onFinish = () => {
stream.off("error", onError);
resolve();
};
stream.once("error", onError);
stream.once("finish", onFinish);
stream.end();
});
}
getLastLineBytes(): number {
return this.currentLineBytes;
}
private appendDecodedText(text: string): void {
if (text.length === 0) {
return;
}
const bytes = byteLength(text);
this.totalDecodedBytes += bytes;
this.tailText += text;
this.tailBytes += bytes;
if (this.tailBytes > this.maxRollingBytes * 2) {
this.trimTail();
}
let newlines = 0;
let lastNewline = -1;
for (let i = text.indexOf("\n"); i !== -1; i = text.indexOf("\n", i + 1)) {
newlines++;
lastNewline = i;
}
if (newlines === 0) {
this.currentLineBytes += bytes;
} else {
this.totalLines += newlines;
this.currentLineBytes = byteLength(text.slice(lastNewline + 1));
}
}
private trimTail(): void {
const buffer = Buffer.from(this.tailText, "utf-8");
if (buffer.length <= this.maxRollingBytes) {
this.tailBytes = buffer.length;
return;
}
let start = buffer.length - this.maxRollingBytes;
while (start < buffer.length && (buffer[start] & 0xc0) === 0x80) {
start++;
}
this.tailStartsAtLineBoundary = start === 0 ? this.tailStartsAtLineBoundary : buffer[start - 1] === 0x0a;
this.tailText = buffer.subarray(start).toString("utf-8");
this.tailBytes = byteLength(this.tailText);
}
private getSnapshotText(): string {
if (this.tailStartsAtLineBoundary) {
return this.tailText;
}
const firstNewline = this.tailText.indexOf("\n");
return firstNewline === -1 ? this.tailText : this.tailText.slice(firstNewline + 1);
}
private shouldUseTempFile(): boolean {
return (
this.totalRawBytes > this.maxBytes || this.totalDecodedBytes > this.maxBytes || this.totalLines > this.maxLines
);
}
private ensureTempFile(): void {
if (this.tempFilePath) {
return;
}
this.tempFilePath = defaultTempFilePath(this.tempFilePrefix);
this.tempFileStream = createWriteStream(this.tempFilePath);
for (const chunk of this.rawChunks) {
this.tempFileStream.write(chunk);
}
this.rawChunks = [];
}
}

View File

@@ -87,7 +87,8 @@ export class LoginDialogComponent extends Container implements Focusable {
showAuth(url: string, instructions?: string): void {
this.contentContainer.clear();
this.contentContainer.addChild(new Spacer(1));
this.contentContainer.addChild(new Text(theme.fg("accent", url), 1, 0));
const linkedUrl = `\x1b]8;;${url}\x07${url}\x1b]8;;\x07`;
this.contentContainer.addChild(new Text(theme.fg("accent", linkedUrl), 1, 0));
const clickHint = process.platform === "darwin" ? "Cmd+click to open" : "Ctrl+click to open";
const hyperlink = `\x1b]8;;${url}\x07${clickHint}\x1b]8;;\x07`;

View File

@@ -15,6 +15,7 @@ import {
type Message,
type Model,
type OAuthProviderId,
type OAuthSelectPrompt,
} from "@mariozechner/pi-ai";
import type {
AutocompleteItem,
@@ -2632,6 +2633,7 @@ export class InteractiveMode {
switch (event.type) {
case "agent_start":
this.pendingTools.clear();
if (this.settingsManager.getShowTerminalProgress()) {
this.ui.terminal.setProgress(true);
}
@@ -3099,6 +3101,7 @@ export class InteractiveMode {
options: { updateFooter?: boolean; populateHistory?: boolean } = {},
): void {
this.pendingTools.clear();
const renderedPendingTools = new Map<string, ToolExecutionComponent>();
if (options.updateFooter) {
this.footer.invalidate();
@@ -3140,16 +3143,16 @@ export class InteractiveMode {
}
component.updateResult({ content: [{ type: "text", text: errorMessage }], isError: true });
} else {
this.pendingTools.set(content.id, component);
renderedPendingTools.set(content.id, component);
}
}
}
} else if (message.role === "toolResult") {
// Match tool results to pending tool components
const component = this.pendingTools.get(message.toolCallId);
const component = renderedPendingTools.get(message.toolCallId);
if (component) {
component.updateResult(message);
this.pendingTools.delete(message.toolCallId);
renderedPendingTools.delete(message.toolCallId);
}
} else {
// All other messages use standard rendering
@@ -3157,7 +3160,9 @@ export class InteractiveMode {
}
}
this.pendingTools.clear();
for (const [toolCallId, component] of renderedPendingTools) {
this.pendingTools.set(toolCallId, component);
}
this.ui.requestRender();
}
@@ -4645,6 +4650,34 @@ export class InteractiveMode {
}
}
private showOAuthLoginSelect(dialog: LoginDialogComponent, prompt: OAuthSelectPrompt): Promise<string | undefined> {
return new Promise((resolve) => {
const restoreDialog = () => {
this.editorContainer.clear();
this.editorContainer.addChild(dialog);
this.ui.setFocus(dialog);
this.ui.requestRender();
};
const labels = prompt.options.map((option) => option.label);
const selector = new ExtensionSelectorComponent(
prompt.message,
labels,
(optionLabel) => {
restoreDialog();
resolve(prompt.options.find((option) => option.label === optionLabel)?.id);
},
() => {
restoreDialog();
resolve(undefined);
},
);
this.editorContainer.clear();
this.editorContainer.addChild(selector);
this.ui.setFocus(selector);
this.ui.requestRender();
});
}
private async showLoginDialog(providerId: string, providerName: string): Promise<void> {
const providerInfo = this.session.modelRegistry.authStorage
.getOAuthProviders()
@@ -4722,6 +4755,8 @@ export class InteractiveMode {
dialog.showProgress(message);
},
onSelect: (prompt: OAuthSelectPrompt) => this.showOAuthLoginSelect(dialog, prompt),
onManualCodeInput: () => manualCodePromise,
signal: dialog.signal,

View File

@@ -35,11 +35,20 @@ function emitOsc52(text: string): boolean {
export async function copyToClipboard(text: string): Promise<void> {
let copied = false;
const p = platform();
// Prefer direct clipboard writes. Emitting OSC 52 first can make terminals
// write the same native clipboard concurrently with the addon, and very large
// OSC 52 payloads can desynchronize terminal rendering.
//
// On Linux, skip the native addon. The underlying `clipboard-rs` crate is
// X11-only and does not retain selection ownership after `set_text`
// resolves, so on Wayland-only compositors (Hyprland, Niri, ...) and even
// some X11 sessions the call resolves successfully without populating the
// clipboard. The platform tools below (wl-copy, xclip, xsel) properly
// daemonize and keep ownership.
try {
if (clipboard) {
if (clipboard && p !== "linux") {
await clipboard.setText(text);
copied = true;
}
@@ -52,7 +61,6 @@ export async function copyToClipboard(text: string): Promise<void> {
return;
}
const p = platform();
const options: NativeClipboardExecOptions = { input: text, timeout: 5000, stdio: ["pipe", "ignore", "ignore"] };
if (!copied) {

View File

@@ -43,6 +43,21 @@ describe("parseArgs", () => {
const result = parseArgs(["-p"]);
expect(result.print).toBe(true);
});
test("parses prompt after -p even when it starts with YAML frontmatter", () => {
const prompt = "---\ntitle: hello\n---\nSay hi.";
const result = parseArgs(["-p", prompt]);
expect(result.print).toBe(true);
expect(result.messages).toEqual([prompt]);
expect(result.unknownFlags.size).toBe(0);
});
test("does not consume options after -p as prompts", () => {
const result = parseArgs(["-p", "--provider", "openai", "Say hi."]);
expect(result.print).toBe(true);
expect(result.provider).toBe("openai");
expect(result.messages).toEqual(["Say hi."]);
});
});
describe("--continue flag", () => {

View File

@@ -0,0 +1,40 @@
import { readFileSync } from "fs";
import { describe, expect, it } from "vitest";
describe("export HTML skill block rendering", () => {
const templateJs = readFileSync(new URL("../src/core/export-html/template.js", import.meta.url), "utf-8");
it("strips skill wrapper XML from user message rendering", () => {
// Skill commands store a structural wrapper in the raw user message:
// <skill name="..." location="...">\n...\n</skill>\n\nactual prompt
// The export renderer must detect that wrapper and render only the user-visible prompt,
// not the Pi-generated <skill>...</skill> XML tags.
expect(templateJs).toMatch(/parseSkillBlock/);
expect(templateJs).toMatch(/skillBlock\.userMessage/);
});
it("renders skill invocation and user message as separate sibling blocks", () => {
// The skill block and user message should render as separate entry-level elements,
// matching the TUI layout where SkillInvocationMessageComponent and
// UserMessageComponent are siblings, not nested.
expect(templateJs).toMatch(/skill-invocation/);
// When a skill block has a userMessage, the user-message div must be emitted
// as a separate block after the skill-invocation div, containing the user-authored text.
// Verify the code checks hasUserContent so the user-message div is only omitted
// when the skill block has no user prompt and no images.
expect(templateJs).toMatch(/hasUserContent/);
});
it("renders skill content as markdown, not raw text", () => {
// The skill block body is markdown (from the SKILL.md file).
// It should be rendered through safeMarkedParse, not escaped as raw text.
expect(templateJs).toMatch(/safeMarkedParse\(skillBlock\.content\)/);
});
it("shows skill name and user message in the sidebar tree", () => {
// The sidebar tree should display both the skill name and the user prompt,
// not just one or the other.
expect(templateJs).toMatch(/tree-role-skill/);
});
});

View File

@@ -0,0 +1,164 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { AssistantMessage, ToolResultMessage, Usage } from "@mariozechner/pi-ai";
import { Container, Text, type TUI } from "@mariozechner/pi-tui";
import stripAnsi from "strip-ansi";
import { beforeAll, describe, expect, test, vi } from "vitest";
import type { AgentSessionEvent } from "../../../src/core/agent-session.js";
import type { SessionContext } from "../../../src/core/session-manager.js";
import type { ToolExecutionComponent } from "../../../src/modes/interactive/components/tool-execution.js";
import { InteractiveMode } from "../../../src/modes/interactive/interactive-mode.js";
import { initTheme } from "../../../src/modes/interactive/theme/theme.js";
const TOOL_CALL_ID = "tool-4167";
const TOOL_NAME = "slow_tool";
const EMPTY_USAGE: Usage = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
};
type RenderSessionContextThis = {
pendingTools: Map<string, ToolExecutionComponent>;
chatContainer: Container;
footer: { invalidate(): void };
ui: TUI;
settingsManager: {
getShowImages(): boolean;
getImageWidthCells(): number;
};
sessionManager: { getCwd(): string };
session: { retryAttempt: number };
toolOutputExpanded: boolean;
isInitialized: boolean;
updateEditorBorderColor(): void;
getRegisteredToolDefinition(toolName: string): undefined;
addMessageToChat(message: AgentMessage, options?: { populateHistory?: boolean }): void;
};
type RenderSessionContext = (
this: RenderSessionContextThis,
sessionContext: SessionContext,
options?: { updateFooter?: boolean; populateHistory?: boolean },
) => void;
type HandleEvent = (this: RenderSessionContextThis, event: AgentSessionEvent) => Promise<void>;
function createFakeInteractiveModeThis(): RenderSessionContextThis {
const chatContainer = new Container();
return {
pendingTools: new Map<string, ToolExecutionComponent>(),
chatContainer,
footer: { invalidate: vi.fn() },
ui: { requestRender: vi.fn() } as unknown as TUI,
settingsManager: {
getShowImages: () => false,
getImageWidthCells: () => 60,
},
sessionManager: { getCwd: () => process.cwd() },
session: { retryAttempt: 0 },
toolOutputExpanded: false,
isInitialized: true,
updateEditorBorderColor: vi.fn(),
getRegisteredToolDefinition: (_toolName: string) => undefined,
addMessageToChat(message: AgentMessage) {
chatContainer.addChild(new Text(message.role, 0, 0));
},
};
}
function createAssistantToolCallMessage(): AssistantMessage {
return {
role: "assistant",
content: [
{
type: "toolCall",
id: TOOL_CALL_ID,
name: TOOL_NAME,
arguments: { delayMs: 10_000 },
},
],
api: "test-api",
provider: "test-provider",
model: "test-model",
usage: EMPTY_USAGE,
stopReason: "toolUse",
timestamp: Date.now(),
};
}
function createToolResultMessage(text: string): ToolResultMessage {
return {
role: "toolResult",
toolCallId: TOOL_CALL_ID,
toolName: TOOL_NAME,
content: [{ type: "text", text }],
isError: false,
timestamp: Date.now(),
};
}
function createSessionContext(messages: AgentMessage[]): SessionContext {
return {
messages,
thinkingLevel: "off",
model: null,
};
}
function renderChat(container: Container): string {
return stripAnsi(container.render(120).join("\n"));
}
describe("InteractiveMode.renderSessionContext", () => {
beforeAll(() => {
initTheme("dark");
});
test("keeps unresolved rendered tool calls registered for live completion events", async () => {
const fakeThis = createFakeInteractiveModeThis();
const renderSessionContext = (
InteractiveMode.prototype as unknown as { renderSessionContext: RenderSessionContext }
).renderSessionContext;
const handleEvent = (InteractiveMode.prototype as unknown as { handleEvent: HandleEvent }).handleEvent;
renderSessionContext.call(fakeThis, createSessionContext([createAssistantToolCallMessage()]));
expect(fakeThis.pendingTools.has(TOOL_CALL_ID)).toBe(true);
await handleEvent.call(fakeThis, {
type: "tool_execution_end",
toolCallId: TOOL_CALL_ID,
toolName: TOOL_NAME,
result: { content: [{ type: "text", text: "FINAL_RESULT" }], details: undefined },
isError: false,
});
expect(fakeThis.pendingTools.has(TOOL_CALL_ID)).toBe(false);
expect(renderChat(fakeThis.chatContainer)).toContain("FINAL_RESULT");
});
test("does not keep completed historical tool calls registered as pending", () => {
const fakeThis = createFakeInteractiveModeThis();
const renderSessionContext = (
InteractiveMode.prototype as unknown as { renderSessionContext: RenderSessionContext }
).renderSessionContext;
renderSessionContext.call(
fakeThis,
createSessionContext([createAssistantToolCallMessage(), createToolResultMessage("HISTORICAL_RESULT")]),
);
expect(fakeThis.pendingTools.size).toBe(0);
expect(renderChat(fakeThis.chatContainer)).toContain("HISTORICAL_RESULT");
});
});

View File

@@ -3,7 +3,7 @@ import { tmpdir } from "os";
import { join } from "path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { executeBashWithOperations } from "../src/core/bash-executor.js";
import { createBashTool, createLocalBashOperations } from "../src/core/tools/bash.js";
import { type BashOperations, createBashTool, createLocalBashOperations } from "../src/core/tools/bash.js";
import { computeEditsDiff } from "../src/core/tools/edit-diff.js";
import {
createEditTool,
@@ -449,6 +449,42 @@ describe("Coding Agent Tools", () => {
);
});
it("should include full output path for truncated timeout and abort errors", async () => {
for (const testCase of [
{ error: "timeout:5", expected: "Command timed out after 5 seconds" },
{ error: "aborted", expected: "Command aborted" },
]) {
const operations: BashOperations = {
exec: async (_command, _cwd, { onData }) => {
for (let i = 1; i <= 3000; i++) {
onData(Buffer.from(`${i}\n`, "utf-8"));
}
throw new Error(testCase.error);
},
};
const bash = createBashTool(testDir, { operations });
let error: unknown;
try {
await bash.execute(`test-call-${testCase.error}`, { command: "chatty-fail" });
} catch (err) {
error = err;
}
expect(error).toBeInstanceOf(Error);
const message = (error as Error).message;
expect(message).toContain(testCase.expected);
expect(message).toMatch(/\[Showing lines \d+-\d+ of \d+\. Full output: /);
expect(message).not.toContain("Full output: undefined");
const fullOutputPath = message.match(/Full output: ([^\]\n]+)/)?.[1];
expect(fullOutputPath).toBeDefined();
expect(existsSync(fullOutputPath!)).toBe(true);
const fullOutput = readFileSync(fullOutputPath!, "utf-8");
expect(fullOutput).toContain("1\n2\n3");
expect(fullOutput).toContain("2998\n2999\n3000");
}
});
it("should throw error when cwd does not exist", async () => {
const nonexistentCwd = "/this/directory/definitely/does/not/exist/12345";
@@ -517,6 +553,42 @@ describe("Coding Agent Tools", () => {
expect(getTextOutput(result).trim()).toBe("no-prefix");
});
it("should coalesce streaming updates for chatty output", async () => {
const operations: BashOperations = {
exec: async (_command, _cwd, { onData }) => {
for (let i = 0; i < 5000; i++) {
onData(Buffer.from(`line ${i}\n`, "utf-8"));
}
return { exitCode: 0 };
},
};
const updates: Array<{ content: Array<{ type: string; text?: string }>; details?: unknown }> = [];
const bash = createBashTool(testDir, { operations });
const result = await bash.execute("test-call-chatty-updates", { command: "chatty" }, undefined, (update) =>
updates.push(update),
);
expect(updates.length).toBeLessThan(25);
expect(getTextOutput(result)).toContain("line 4999");
});
it("should decode UTF-8 characters split across output chunks", async () => {
const euro = Buffer.from("€\n", "utf-8");
const operations: BashOperations = {
exec: async (_command, _cwd, { onData }) => {
onData(euro.subarray(0, 1));
onData(euro.subarray(1));
return { exitCode: 0 };
},
};
const bash = createBashTool(testDir, { operations });
const result = await bash.execute("test-call-split-utf8", { command: "split-utf8" });
expect(getTextOutput(result).trim()).toBe("€");
});
it("should expose local bash operations for extension reuse", async () => {
const ops = createLocalBashOperations();
const chunks: Buffer[] = [];

View File

@@ -2,6 +2,16 @@
## [Unreleased]
### Fixed
- Fixed wrapped OSC 8 hyperlinks to preserve BEL terminators so OAuth login URLs remain clickable on every wrapped line.
## [0.73.0] - 2026-05-04
### Fixed
- Fixed fuzzy ranking to prioritize exact matches in selector and autocomplete results.
## [0.72.1] - 2026-05-02
## [0.72.0] - 2026-05-01

View File

@@ -1,6 +1,6 @@
{
"name": "@mariozechner/pi-tui",
"version": "0.72.1",
"version": "0.73.0",
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
"type": "module",
"main": "dist/index.js",

View File

@@ -60,6 +60,10 @@ export function fuzzyMatch(query: string, text: string): FuzzyMatch {
return { matches: false, score: 0 };
}
if (normalizedQuery === textLower) {
score -= 100;
}
return { matches: true, score };
};

View File

@@ -46,7 +46,10 @@ export function detectCapabilities(): TerminalCapabilities {
// sequences differently). Force hyperlinks off whenever we detect them, even
// when the outer terminal would otherwise support OSC 8. Image protocols are
// also unreliable under tmux/screen, so leave `images: null` for safety.
const inTmuxOrScreen = !!process.env.TMUX || term.startsWith("tmux") || term.startsWith("screen");
// cmux currently also gets this conservative fallback due to image corruption:
// https://github.com/badlogic/pi-mono/issues/4208
const inTmuxOrScreen =
!!process.env.TMUX || !!process.env.CMUX_WORKSPACE_ID || term.startsWith("tmux") || term.startsWith("screen");
if (inTmuxOrScreen) {
const trueColor = colorTerm === "truecolor" || colorTerm === "24bit";
return { images: null, trueColor, hyperlinks: false };

View File

@@ -312,6 +312,42 @@ export function extractAnsiCode(str: string, pos: number): { code: string; lengt
return null;
}
type Osc8Terminator = "\x07" | "\x1b\\";
interface ActiveHyperlink {
params: string;
url: string;
terminator: Osc8Terminator;
}
function parseOsc8Hyperlink(ansiCode: string): ActiveHyperlink | null | undefined {
if (!ansiCode.startsWith("\x1b]8;")) {
return undefined;
}
const terminator: Osc8Terminator = ansiCode.endsWith("\x07") ? "\x07" : "\x1b\\";
const body = ansiCode.slice(4, terminator === "\x07" ? -1 : -2);
const separatorIndex = body.indexOf(";");
if (separatorIndex === -1) {
return undefined;
}
const params = body.slice(0, separatorIndex);
const url = body.slice(separatorIndex + 1);
if (!url) {
return null;
}
return { params, url, terminator };
}
function formatOsc8Hyperlink(hyperlink: ActiveHyperlink): string {
return `\x1b]8;${hyperlink.params};${hyperlink.url}${hyperlink.terminator}`;
}
function formatOsc8Close(terminator: Osc8Terminator): string {
return `\x1b]8;;${terminator}`;
}
/**
* Track active ANSI SGR codes to preserve styling across line breaks.
*/
@@ -327,13 +363,16 @@ class AnsiCodeTracker {
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"
private activeHyperlink: string | null = null; // Active OSC 8 hyperlink URL, or null
private activeHyperlink: ActiveHyperlink | null = null;
process(ansiCode: string): void {
// OSC 8 hyperlink: \x1b]8;;<url>\x1b\\ (open) or \x1b]8;;\x1b\\ (close)
if (ansiCode.startsWith("\x1b]8;")) {
const m = ansiCode.match(/^\x1b\]8;[^;]*;([^\x1b\x07]*)/);
this.activeHyperlink = m?.[1] ? m[1] : null;
// OSC 8 hyperlink: \x1b]8;;<url>\x1b\\ (open) or \x1b]8;;\x1b\\ (close).
// Preserve the original terminator because some terminals only make BEL-terminated
// links clickable. OAuth login URLs use BEL, so reopening wrapped lines with ST
// made only the first physical line clickable in those terminals.
const hyperlink = parseOsc8Hyperlink(ansiCode);
if (hyperlink !== undefined) {
this.activeHyperlink = hyperlink;
return;
}
@@ -495,7 +534,7 @@ class AnsiCodeTracker {
let result = codes.length > 0 ? `\x1b[${codes.join(";")}m` : "";
if (this.activeHyperlink) {
result += `\x1b]8;;${this.activeHyperlink}\x1b\\`;
result += formatOsc8Hyperlink(this.activeHyperlink);
}
return result;
}
@@ -528,7 +567,7 @@ class AnsiCodeTracker {
result += "\x1b[24m"; // Underline off only
}
if (this.activeHyperlink) {
result += "\x1b]8;;\x1b\\"; // Close hyperlink; re-opened at line start via getActiveCodes()
result += formatOsc8Close(this.activeHyperlink.terminator); // Re-opened at line start via getActiveCodes()
}
return result;
}

View File

@@ -83,6 +83,13 @@ describe("fuzzyFilter", () => {
assert.strictEqual(result[0], "app");
});
it("prioritizes exact matches over longer prefix matches", () => {
const items = ["clone", "cl"];
const result = fuzzyFilter(items, "cl", (x: string) => x);
assert.deepStrictEqual(result, ["cl", "clone"]);
});
it("works with custom getText function", () => {
const items = [
{ name: "foo", id: 1 },

View File

@@ -191,6 +191,21 @@ describe("wrapTextWithAnsi with OSC 8 hyperlinks", () => {
}
});
it("preserves BEL terminators when wrapping OAuth-style hyperlinks", () => {
const url = `https://example.com/oauth/${"a".repeat(32)}`;
const input = `\x1b]8;;${url}\x07${url}\x1b]8;;\x07`;
const lines = wrapTextWithAnsi(input, 20);
assert.ok(lines.length > 1);
for (const line of lines) {
assert.ok(line.includes(`\x1b]8;;${url}\x07`), `Line "${line}" does not reopen the hyperlink with BEL`);
assert.ok(!line.includes(`\x1b]8;;${url}\x1b\\`), `Line "${line}" reopens the hyperlink with ST`);
}
for (const line of lines.slice(0, -1)) {
assert.ok(line.endsWith("\x1b]8;;\x07"), `Line "${line}" does not close the hyperlink with BEL`);
}
});
it("does not emit OSC 8 sequences on lines that are outside the hyperlink", () => {
const url = "https://example.com";
const input = `before \x1b]8;;${url}\x1b\\link\x1b]8;;\x1b\\ after`;

View File

@@ -2,6 +2,8 @@
## [Unreleased]
## [0.73.0] - 2026-05-04
## [0.72.1] - 2026-05-02
## [0.72.0] - 2026-05-01

View File

@@ -1,6 +1,6 @@
{
"name": "pi-web-ui-example",
"version": "0.72.1",
"version": "0.73.0",
"private": true,
"type": "module",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "@mariozechner/pi-web-ui",
"version": "0.72.1",
"version": "0.73.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.72.1",
"@mariozechner/pi-tui": "^0.72.1",
"@mariozechner/pi-ai": "^0.73.0",
"@mariozechner/pi-tui": "^0.73.0",
"typebox": "^1.1.24",
"docx-preview": "^0.3.7",
"jszip": "^3.10.1",