Merge branch 'main' into dev

# Conflicts:
#	packages/agent/CHANGELOG.md
#	packages/ai/scripts/generate-models.ts
#	packages/ai/src/providers/cloudflare-ai-gateway.ts
#	packages/ai/test/zai-coding-plan-models.test.ts
#	packages/coding-agent/src/modes/interactive/interactive-mode.ts
#	packages/tui/src/tui-alt-screen.ts
This commit is contained in:
Cristina Poncela Cubeiro 2026-08-28 13:19:43 +02:00
commit 758a0ee9b7
67 changed files with 1956 additions and 188 deletions

View file

@ -375,3 +375,11 @@ gaoyk19 pr
cad0p pr
Jaaneek pr
CaiJichang212 pr
Mallikarjun-0 pr
wutongyuonce pr
Terminator666666 pr

View file

@ -17,6 +17,10 @@
- Added assistant request recovery, durable retry waits, deferred suspension, and missing-identity suspension to `AgentHarness`.
- Added durable tool planning, context-bound sequential and parallel execution, ordered result settlement, and safe tool replay recovery to `AgentHarness`.
### Fixed
- Fixed Windows `NodeExecutionEnv` aborts crashing when `taskkill.exe` is unavailable on `PATH` ([#6596](https://github.com/earendil-works/pi/issues/6596)).
### Removed
- Removed the withdrawn manual-drive configuration, action inspection methods, action outcomes, and snapshot action field from `AgentHarness`.

View file

@ -254,11 +254,17 @@ function getShellEnv(
function killProcessTree(pid: number): void {
if (process.platform === "win32") {
try {
spawn("taskkill", ["/F", "/T", "/PID", String(pid)], {
stdio: "ignore",
detached: true,
windowsHide: true,
});
const child = spawn(
join(process.env.SystemRoot ?? "C:\\Windows", "System32", "taskkill.exe"),
["/F", "/T", "/PID", String(pid)],
{
stdio: "ignore",
detached: true,
windowsHide: true,
},
);
// A failed spawn emits "error" asynchronously; consume it to avoid crashing Node.
child.once("error", () => {});
} catch {
// Ignore errors.
}

View file

@ -538,6 +538,50 @@ describe("NodeExecutionEnv", () => {
if (!result.ok) expect(result.error).toMatchObject({ code: "aborted" });
});
it.skipIf(process.platform === "win32")("ignores asynchronous taskkill spawn errors during abort", async () => {
const root = createTempDir();
const pidFile = join(root, "shell.pid");
const controller = new AbortController();
const env = new NodeExecutionEnv({ cwd: root, shellPath: "/bin/bash" });
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
const previousSystemRoot = process.env.SystemRoot;
process.env.SystemRoot = "/definitely/missing/windows";
Object.defineProperty(process, "platform", { configurable: true, value: "win32" });
let pid: number | undefined;
try {
const execution = env.exec(
`echo $$ > ${toBashSingleQuotedArg(pidFile)}; exec sleep 60`,
undefined,
withAbortSignal(controller.signal, BACKGROUND_CONTEXT),
);
for (let attempt = 0; attempt < 100 && !existsSync(pidFile); attempt++) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
expect(existsSync(pidFile)).toBe(true);
controller.abort();
await new Promise((resolve) => setTimeout(resolve, 0));
pid = Number.parseInt(readFileSync(pidFile, "utf8"), 10);
process.kill(pid, "SIGKILL");
const result = await execution;
expect(result).toMatchObject({ ok: false, error: { code: "aborted" } });
} finally {
if (pid === undefined && existsSync(pidFile)) {
pid = Number.parseInt(readFileSync(pidFile, "utf8"), 10);
}
if (pid !== undefined && Number.isFinite(pid)) {
try {
process.kill(pid, "SIGKILL");
} catch {}
}
if (previousSystemRoot === undefined) delete process.env.SystemRoot;
else process.env.SystemRoot = previousSystemRoot;
if (platformDescriptor) Object.defineProperty(process, "platform", platformDescriptor);
}
});
it("captures large shell output to a full output file through the execution env", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });

View file

@ -2,6 +2,11 @@
## [Unreleased]
### Fixed
- Fixed OpenAI-compatible Chat Completions ignoring an explicitly requested `toolChoice` when no tools are defined.
- Fixed thinking signature serialization to run once after the signature is complete ([#8671](https://github.com/earendil-works/pi/pull/8671)).
## [0.84.3] - 2026-08-24
### Breaking Changes
@ -34,6 +39,7 @@
- Fixed Kimi OpenAI-compatible usage reporting so top-level `cached_tokens` count as cache reads instead of normal input tokens ([#8075](https://github.com/earendil-works/pi/issues/8075)).
- Fixed Google Generative AI and Vertex AI custom models ignoring `thinkingLevelMap`, which dropped extended thinking controls ([#8135](https://github.com/earendil-works/pi/issues/8135)).
- Fixed Xiaomi model catalog generation retaining shut-down MiMo V2 model names after models.dev marked them deprecated ([#8187](https://github.com/earendil-works/pi/issues/8187)).
- Fixed OpenRouter reasoning controls by deriving `off` support and available effort levels from OpenRouter's model metadata, preventing reasoning-mandatory models from receiving `effort: "none"` ([#8454](https://github.com/earendil-works/pi/issues/8454)).
## [0.84.2] - 2026-08-14

View file

@ -4,6 +4,10 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSy
import { dirname, join, resolve } from "path";
import { fileURLToPath } from "url";
import { getEffortThinkingLevelMap, type ModelsDevReasoningOption } from "./models-dev-reasoning-options.ts";
import {
getOpenRouterThinkingLevelMap,
type OpenRouterReasoningMetadata,
} from "./openrouter-reasoning-options.ts";
import {
CLOUDFLARE_AI_GATEWAY_ANTHROPIC_BASE_URL,
CLOUDFLARE_AI_GATEWAY_COMPAT_BASE_URL,
@ -127,6 +131,25 @@ interface NvidiaNimModelListItem {
id: string;
}
interface OpenRouterModelListItem {
id: string;
name: string;
supported_parameters?: string[];
architecture?: { modality?: string };
pricing?: {
prompt?: string;
completion?: string;
input_cache_read?: string;
input_cache_write?: string;
};
top_provider?: {
context_length?: number;
max_completion_tokens?: number;
};
context_length?: number;
reasoning?: OpenRouterReasoningMetadata;
}
interface AiGatewayModel {
id: string;
name?: string;
@ -1051,11 +1074,11 @@ async function fetchOpenRouterModels(): Promise<Model<any>[]> {
console.log("Fetching models from OpenRouter API...");
const response = await fetch("https://openrouter.ai/api/v1/models");
if (!response.ok) throw new Error(`OpenRouter API returned ${response.status}`);
const data = await response.json();
const data = (await response.json()) as { data?: OpenRouterModelListItem[] };
const models: Model<any>[] = [];
for (const model of data.data) {
for (const model of data.data ?? []) {
// Only include models that support tools
if (!model.supported_parameters?.includes("tools")) continue;
@ -1078,6 +1101,7 @@ async function fetchOpenRouterModels(): Promise<Model<any>[]> {
const cacheWriteCost = roundCost(parseFloat(model.pricing?.input_cache_write || "0") * 1_000_000);
const contextWindow = model.top_provider?.context_length || model.context_length || 4096;
const thinkingLevelMap = getOpenRouterThinkingLevelMap(model.reasoning);
const normalizedModel: Model<any> = {
id: modelKey,
@ -1086,6 +1110,7 @@ async function fetchOpenRouterModels(): Promise<Model<any>[]> {
baseUrl: "https://openrouter.ai/api/v1",
provider,
reasoning: model.supported_parameters?.includes("reasoning") || false,
...(thinkingLevelMap && { thinkingLevelMap }),
input,
cost: {
input: inputCost,
@ -1666,7 +1691,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
}
// Process Cloudflare AI Gateway models
const gatewayIds = new Set<string>();
const cloudflareAIGatewayModelIds = new Set<string>();
if (data["cloudflare-ai-gateway"]?.models) {
for (const [prefixedId, model] of Object.entries(data["cloudflare-ai-gateway"].models)) {
const m = model as ModelsDevModel;
@ -1701,6 +1726,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
const compat =
upstream === "anthropic" || upstream === "workers-ai" ? { sendSessionAffinityHeaders: true } : undefined;
cloudflareAIGatewayModelIds.add(id);
models.push({
id,
name: m.name || id,
@ -1719,22 +1745,22 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
maxTokens: m.limit?.output || 4096,
...(compat ? { compat } : {}),
});
gatewayIds.add(id);
recordModelsDevReasoningOptions("cloudflare-ai-gateway", id, m);
}
}
// The gateway proxies Workers AI through its OpenAI-compatible endpoint, but
// models.dev's gateway catalog only lists `workers-ai/*` entries intermittently.
// Derive them from the Workers AI catalog so the gateway's openai-completions
// models stay stable regardless of that duplication.
// The gateway proxies Workers AI through its OpenAI-compatible /compat endpoint,
// but models.dev may omit or intermittently drop those `workers-ai/*` entries
// from the AI Gateway catalog. Mirror the Workers AI catalog under the documented
// prefix so the gateway keeps its OpenAI-compatible models stable.
if (data["cloudflare-workers-ai"]?.models) {
for (const [modelId, model] of Object.entries(data["cloudflare-workers-ai"].models)) {
const m = model as ModelsDevModel;
if (m.tool_call !== true) continue;
const id = `workers-ai/${modelId}`;
if (gatewayIds.has(id)) continue;
if (cloudflareAIGatewayModelIds.has(id)) continue;
cloudflareAIGatewayModelIds.add(id);
models.push({
id,
@ -1754,7 +1780,6 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
maxTokens: m.limit?.output || 4096,
compat: { sendSessionAffinityHeaders: true },
});
gatewayIds.add(id);
recordModelsDevReasoningOptions("cloudflare-ai-gateway", id, m);
}
}
@ -2542,6 +2567,24 @@ async function generateModels() {
maxTokens: 384000,
compat: deepseekCompat,
},
{
id: "deepseek-v4-flash-vision-exp",
name: "DeepSeek V4 Flash Vision Exp",
api: "openai-completions",
baseUrl: "https://api.deepseek.com",
provider: "deepseek",
reasoning: true,
input: ["text", "image"],
cost: {
input: 0.14,
output: 0.28,
cacheRead: 0.0028,
cacheWrite: 0,
},
contextWindow: 1000000,
maxTokens: 384000,
compat: deepseekCompat,
},
{
id: "deepseek-v4-pro",
name: "DeepSeek V4 Pro",

View file

@ -0,0 +1,23 @@
import type { ThinkingLevel, ThinkingLevelMap } from "../src/types.ts";
import { getEffortThinkingLevelMap } from "./models-dev-reasoning-options.ts";
export interface OpenRouterReasoningMetadata {
mandatory?: boolean;
default_enabled?: boolean;
supported_efforts?: Array<ThinkingLevel | "none">;
default_effort?: ThinkingLevel | "none";
}
/** Convert OpenRouter's reasoning metadata into Pi model capabilities. */
export function getOpenRouterThinkingLevelMap(
reasoning: OpenRouterReasoningMetadata | undefined,
): ThinkingLevelMap | undefined {
if (!reasoning) return undefined;
if (!reasoning.supported_efforts?.length) return reasoning.mandatory === true ? { off: null } : undefined;
// OpenRouter's supported_efforts uses the same effort values as models.dev reasoning_options,
// so both sources can share the same Pi thinking-level conversion.
const map = getEffortThinkingLevelMap([{ type: "effort", values: reasoning.supported_efforts }]);
if (!map) return reasoning.mandatory === true ? { off: null } : undefined;
return { ...map, off: reasoning.mandatory === true ? null : "none" };
}

View file

@ -247,6 +247,31 @@ function parseLegacyEncryptedReasoningDetail(
}
}
function fillMissingCommonReasoningDetailFields(
target: OpenAIReasoningDetailBase,
source: OpenAIReasoningDetail,
): void {
target.id ??= source.id;
target.format ||= source.format;
target.index ??= source.index;
}
function appendOpenAIReasoningDetail(details: OpenAIReasoningDetail[], detail: OpenAIReasoningDetail): void {
const lastDetail = details[details.length - 1];
if (detail.type === "reasoning.text" && lastDetail?.type === "reasoning.text") {
lastDetail.text += detail.text;
lastDetail.signature ||= detail.signature;
fillMissingCommonReasoningDetailFields(lastDetail, detail);
return;
}
if (detail.type === "reasoning.summary" && lastDetail?.type === "reasoning.summary") {
lastDetail.summary += detail.summary;
fillMissingCommonReasoningDetailFields(lastDetail, detail);
return;
}
details.push({ ...detail });
}
const OPENAI_COMPLETIONS_REASONING_FIELDS = ["reasoning", "reasoning_content", "reasoning_text"] as const;
type OpenAICompletionsReasoningField = (typeof OPENAI_COMPLETIONS_REASONING_FIELDS)[number];
@ -304,6 +329,15 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio
timestamp: Date.now(),
};
// `reasoning_details` are replay metadata, not user-visible stream deltas.
// Keep them in memory during streaming and serialize once when the block is finalized.
let streamedReasoningDetails: OpenAIReasoningDetail[] | undefined;
const applyStreamedReasoningDetails = (block: ThinkingContent): void => {
if (streamedReasoningDetails !== undefined) {
block.thinkingSignature = JSON.stringify(streamedReasoningDetails);
}
};
try {
const apiKey = getClientApiKey(model.provider, options?.apiKey, options?.headers);
const compat = getCompat(model);
@ -394,6 +428,7 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio
partial: output,
});
} else if (block.type === "thinking") {
applyStreamedReasoningDetails(block);
stream.push({
type: "thinking_end",
contentIndex,
@ -621,12 +656,12 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio
if (Array.isArray(reasoningDetails)) {
for (const detail of reasoningDetails) {
if (!isOpenAIReasoningDetail(detail)) continue;
const block = ensureThinkingBlock("");
const preservedDetails = parseOpenAIReasoningDetails(block.thinkingSignature) ?? [];
preservedDetails.push(detail);
// Keep provider replay data in the existing signature slot. OpenRouter
// requires the complete reasoning_details sequence in its original order.
block.thinkingSignature = JSON.stringify(preservedDetails);
ensureThinkingBlock("");
streamedReasoningDetails ??= [];
// Keep provider replay data in the existing signature slot. OpenRouter streams
// reasoning_details as deltas: consecutive text/summary deltas are merged into
// logical entries, while encrypted entries remain opaque and discrete.
appendOpenAIReasoningDetail(streamedReasoningDetails, detail);
}
}
}
@ -656,6 +691,9 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio
stream.end();
} catch (error) {
for (const block of output.content) {
if (block.type === "thinking") {
applyStreamedReasoningDetails(block);
}
delete (block as { index?: number }).index;
// Streaming scratch buffers are only used during parsing; never persist them.
delete (block as { partialArgs?: string }).partialArgs;

View file

@ -6,14 +6,14 @@ import { CLOUDFLARE_AI_GATEWAY_MODELS } from "./cloudflare-ai-gateway.models.ts"
import { cloudflareAIGatewayAuth } from "./cloudflare-auth.ts";
import { cloudflareStreams } from "./cloudflare-stream.ts";
export function cloudflareAIGatewayProvider(): Provider<
"anthropic-messages" | "openai-completions" | "openai-responses"
> {
type CloudflareAIGatewayApi = "anthropic-messages" | "openai-completions" | "openai-responses";
export function cloudflareAIGatewayProvider(): Provider<CloudflareAIGatewayApi> {
// The api map is pinned to all three APIs: models.dev's gateway catalog drops and
// restores `workers-ai/*` (openai-completions) entries over time, and inference from
// `models` alone would otherwise reject the openai-completions entry whenever the
// generated catalog happens to contain none.
return createProvider<"anthropic-messages" | "openai-completions" | "openai-responses">({
return createProvider<CloudflareAIGatewayApi>({
id: "cloudflare-ai-gateway",
name: "Cloudflare AI Gateway",
auth: { apiKey: cloudflareAIGatewayAuth() },

View file

@ -312,7 +312,7 @@ export interface AnthropicAllowedFallbackModel {
// Unified options with reasoning passed to streamSimple() and completeSimple()
export interface SimpleStreamOptions extends StreamOptions {
/** Provider-neutral tool selection for simple requests. Default: "auto". */
/** Provider-neutral tool selection for simple requests. When omitted, adapters use provider-specific behavior. */
toolChoice?: ToolChoice;
reasoning?: ThinkingLevel;
/** Ask a capable provider to return a durable handle and continue the request asynchronously. */

View file

@ -3,7 +3,7 @@ import type { AddressInfo } from "node:net";
import { Type } from "typebox";
import { afterEach, describe, expect, it } from "vitest";
import { stream as streamAnthropic } from "../src/api/anthropic-messages.ts";
import { getModel, getModels, streamSimple } from "../src/compat.ts";
import { getModel, streamSimple } from "../src/compat.ts";
import { findEnvKeys, getEnvApiKey } from "../src/env-api-keys.ts";
import type { Context, Model, Tool } from "../src/types.ts";
@ -37,17 +37,6 @@ describe("Fireworks models", () => {
});
});
it("registers the Fire Pass turbo router model", () => {
const model = getModels("fireworks").find(
(candidate) => candidate.id.startsWith("accounts/fireworks/routers/") && candidate.id.endsWith("-turbo"),
);
expect(model).toBeDefined();
expect(model?.api).toBe("anthropic-messages");
expect(model?.baseUrl).toBe("https://api.fireworks.ai/inference");
expect(model?.input).toEqual(["text", "image"]);
});
it("aligns GLM 5.2 Fast with GLM 5.2's OpenAI-compatible config", () => {
const base = getModel("fireworks", "accounts/fireworks/models/glm-5p2");
const fast = getModel("fireworks", "accounts/fireworks/routers/glm-5p2-fast");

View file

@ -181,4 +181,71 @@ describe("openai-completions reasoning_details streaming", () => {
expect(payload?.reasoning_details).toEqual(expectedReasoningDetails);
expect(payload?.reasoning).toBeUndefined();
});
it("merges consecutive text and summary reasoning_details deltas before replay", async () => {
const textDelta = { type: "reasoning.text", text: "The", index: 0 };
const textDeltaWithSignature = {
type: "reasoning.text",
text: " user wants the time.",
signature: "sha256:text-signature",
format: "openai-responses-v1",
index: 0,
};
const summaryDelta = { type: "reasoning.summary", summary: "Looked", index: 0 };
const summaryDeltaWithFormat = {
type: "reasoning.summary",
summary: " up time.",
format: "openai-responses-v1",
index: 0,
};
const laterSummaryDelta = {
type: "reasoning.summary",
summary: "After encrypted block.",
format: "openai-responses-v1",
index: 0,
};
const expectedReasoningDetails = [
{
type: "reasoning.text",
text: "The user wants the time.",
index: 0,
signature: "sha256:text-signature",
format: "openai-responses-v1",
},
{
type: "reasoning.summary",
summary: "Looked up time.",
index: 0,
format: "openai-responses-v1",
},
reasoningDetail,
laterSummaryDelta,
];
mockState.chunkSets = [
[
chunk({ reasoning_details: [textDelta] }),
chunk({ reasoning_details: [textDeltaWithSignature] }),
chunk({ reasoning_details: [summaryDelta] }),
chunk({ reasoning_details: [summaryDeltaWithFormat] }),
chunk({ reasoning_details: [reasoningDetail] }),
chunk({ reasoning_details: [laterSummaryDelta] }),
toolCallChunk(),
chunk({}, "tool_calls"),
],
[chunk({ content: "ok" }), chunk({}, "stop")],
];
const assistantMessage = await runOpenAICompletionsStream();
const thinking = assistantMessage.content.find((block) => block.type === "thinking");
expect(thinking).toEqual({
type: "thinking",
thinking: "",
thinkingSignature: JSON.stringify(expectedReasoningDetails),
});
await runOpenAICompletionsStream([assistantMessage]);
expect(getAssistantPayload(mockState.payloads[1])?.reasoning_details).toEqual(expectedReasoningDetails);
});
});

View file

@ -150,6 +150,30 @@ describe("openai-completions tool_choice", () => {
expect(params.tools?.length ?? 0).toBeGreaterThan(0);
});
it("includes toolChoice when no tools are provided", async () => {
const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini")!;
const model = { ...baseModel, api: "openai-completions" } as const;
let payload: unknown;
await streamSimple(
model,
{
messages: [{ role: "user", content: "Summarize the conversation", timestamp: Date.now() }],
},
{
apiKey: "test",
toolChoice: "none",
onPayload: (params: unknown) => {
payload = params;
},
},
).result();
const params = (payload ?? mockState.lastParams) as { tool_choice?: string; tools?: unknown[] };
expect(params.tool_choice).toBe("none");
expect(params).not.toHaveProperty("tools");
});
it("omits strict when compat disables strict mode", async () => {
const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini")!;
const model = {

View file

@ -0,0 +1,109 @@
import { describe, expect, it } from "vitest";
import { getOpenRouterThinkingLevelMap } from "../scripts/openrouter-reasoning-options.ts";
import { streamSimple } from "../src/api/openai-completions.ts";
import type { Context, Model, ThinkingLevelMap } from "../src/types.ts";
const context: Context = {
messages: [{ role: "user", content: "Hello", timestamp: 0 }],
};
function openRouterModel(thinkingLevelMap?: ThinkingLevelMap): Model<"openai-completions"> {
return {
id: "stealth/ox-alpha",
name: "Ox Alpha",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://example.invalid/v1",
reasoning: true,
thinkingLevelMap,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 4096,
compat: { thinkingFormat: "openrouter" },
};
}
async function capturePayload(model: Model<"openai-completions">, reasoning?: "low") {
let payload: { reasoning?: { effort?: string } } | undefined;
await streamSimple(model, context, {
apiKey: "test",
reasoning,
onPayload: (request) => {
payload = request as { reasoning?: { effort?: string } };
throw new Error("payload captured");
},
}).result();
if (!payload) throw new Error("OpenRouter payload was not captured");
return payload;
}
describe("getOpenRouterThinkingLevelMap", () => {
it("marks mandatory reasoning and unsupported efforts unavailable", () => {
expect(
getOpenRouterThinkingLevelMap({
mandatory: true,
default_enabled: true,
supported_efforts: ["max", "high", "low"],
default_effort: "max",
}),
).toEqual({
off: null,
minimal: null,
low: "low",
medium: null,
high: "high",
xhigh: null,
max: "max",
});
});
it("still marks off unavailable when OpenRouter omits effort metadata", () => {
expect(getOpenRouterThinkingLevelMap({ mandatory: true })).toEqual({ off: null });
});
it("keeps off available while restricting optional models to supported efforts", () => {
expect(
getOpenRouterThinkingLevelMap({
mandatory: false,
default_enabled: true,
supported_efforts: ["high", "low"],
}),
).toEqual({
off: "none",
minimal: null,
low: "low",
medium: null,
high: "high",
xhigh: null,
max: null,
});
});
it("does not add metadata for optional models without effort controls", () => {
expect(getOpenRouterThinkingLevelMap({ mandatory: false })).toBeUndefined();
});
});
describe("OpenRouter mandatory reasoning payloads", () => {
const mandatoryMap = getOpenRouterThinkingLevelMap({
mandatory: true,
supported_efforts: ["max", "high", "low"],
});
it("omits reasoning when a background call does not request it", async () => {
expect(await capturePayload(openRouterModel(mandatoryMap))).not.toHaveProperty("reasoning");
});
it("still sends an explicitly selected supported effort", async () => {
expect(await capturePayload(openRouterModel(mandatoryMap), "low")).toMatchObject({
reasoning: { effort: "low" },
});
});
it("continues to explicitly disable reasoning for optional models", async () => {
expect(await capturePayload(openRouterModel())).toMatchObject({
reasoning: { effort: "none" },
});
});
});

View file

@ -41,12 +41,14 @@ it("uses API-equivalent reference costs for Coding Plan models", () => {
cacheRead: 0.24,
cacheWrite: 0,
});
expect(getBuiltinModel("zai", "glm-5.3").cost).toEqual({
input: 1.4,
output: 4.4,
cacheRead: 0.26,
cacheWrite: 0,
});
for (const provider of ["zai", "zai-coding-cn"] as const) {
expect(getBuiltinModel(provider, "glm-5.3").cost).toEqual({
input: 1.4,
output: 4.4,
cacheRead: 0.26,
cacheWrite: 0,
});
}
});
it("keeps zero costs for Coding Plan models without a matching API price", () => {

View file

@ -2,6 +2,23 @@
## [Unreleased]
### Added
- Added `ui_prompt_start` and `ui_prompt_end` extension events so host integrations can distinguish active agent work from waiting on user-facing `ctx.ui` prompts ([#5329](https://github.com/earendil-works/pi/issues/5329)).
- Added transcript usage notices for compaction and branch summaries when cache miss notices are enabled.
- Added RPC `clear_queue` to retrieve and remove queued steering and follow-up messages ([#8432](https://github.com/earendil-works/pi/issues/8432)).
- Added environment variables and advanced settings for overriding auto-detected terminal hyperlink, image, and truecolor capabilities ([#8665](https://github.com/earendil-works/pi/issues/8665)).
- Added `fullscreenCopyOnSelect` to disable automatic fullscreen selection copy; when disabled, `Ctrl+X` copies the active text selection before falling back to the last assistant message, while `/tree` still copies the selected message ([#7720](https://github.com/earendil-works/pi/issues/7720)).
### Fixed
- Fixed toggling thinking visibility clearing partial output from running Bash tools ([#8611](https://github.com/earendil-works/pi/issues/8611)).
- Fixed Windows shell aborts crashing Pi when `taskkill.exe` is unavailable on `PATH` ([#6596](https://github.com/earendil-works/pi/issues/6596)).
- Fixed resumed sessions corrupting the next appended entry when their JSONL file lacks a trailing newline ([#8345](https://github.com/earendil-works/pi/issues/8345)).
- Fixed extension messages sent with `triggerTurn: false` while the agent is running being inserted between a tool call and its result, which made providers that validate message order reject the replayed history. They are now appended once the turn's tool results are in ([#8537](https://github.com/earendil-works/pi/issues/8537)).
- Fixed compaction and branch summaries forcing `toolChoice: "none"` ([#8649](https://github.com/earendil-works/pi/issues/8649), [#8638](https://github.com/earendil-works/pi/issues/8638)).
- Fixed Google Vertex requests failing with `HttpsProxyAgent is not a constructor` when the bundled Node.js runtime uses an HTTP(S) proxy ([#8610](https://github.com/earendil-works/pi/issues/8610)).
## [0.84.3] - 2026-08-24
### New Features

View file

@ -96,7 +96,7 @@ Then just talk to pi. By default, pi gives the model four tools: `read`, `write`
## Providers & Models
For each built-in provider, pi maintains a list of tool-capable models. Configured provider catalogs refresh automatically; run `pi update --models` to force an immediate refresh. Authenticate via subscription (`/login`) or API key, then select any model from that provider via `/model` (or Ctrl+L).
For each built-in provider, pi maintains a list of tool-capable models. Configured provider catalogs refresh automatically; run `pi update --models` to force an immediate refresh. Authenticate via subscription (`/login`) or API key, then select any model from that provider via `/model` (or Ctrl+L). Press Ctrl+S in the model picker to save the highlighted model as the startup default.
**Subscriptions:**
- Anthropic Claude Pro/Max
@ -178,9 +178,10 @@ Type `/` in the editor to trigger commands. [Extensions](#extensions) can regist
|---------|-------------|
| `/login`, `/logout` | Manage provider credentials |
| [`/llama`](docs/llama-cpp.md) | Download, load, and unload llama.cpp router models |
| `/model` | Switch models |
| `/model` | Switch models; Ctrl+S in the picker saves the startup default |
| `/thinking` | Switch thinking level; Ctrl+S in the picker saves the startup default |
| `/scoped-models` | Enable/disable models for Ctrl+P cycling |
| `/settings` | Thinking level, theme, message delivery, transport |
| `/settings` | Theme, message delivery, transport, and other preferences |
| `/resume` | Pick from previous sessions |
| `/new` | Start a new session |
| `/name <name>` | Set session display name |
@ -216,7 +217,7 @@ See `/hotkeys` for the full list. Customize via `~/.pi/agent/keybindings.json`.
| Shift+Tab | Cycle thinking level |
| Ctrl+O | Collapse/expand tool output |
| Ctrl+T | Collapse/expand thinking blocks |
| Ctrl+X | Copy the last assistant message |
| Ctrl+X | Copy the last assistant message; with fullscreen copy-on-select disabled, copy the active text selection |
### Message Queue

View file

@ -394,15 +394,15 @@ interface OAuthCredentials {
## Custom Streaming API
For providers with non-standard APIs, implement `streamSimple`. Study the existing provider implementations before writing your own:
For providers with non-standard APIs, implement `streamSimple`. Study the existing API implementations before writing your own:
**Reference implementations:**
- [anthropic.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/ai/src/providers/anthropic.ts) - Anthropic Messages API
- [mistral.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/ai/src/providers/mistral.ts) - Mistral Conversations API
- [openai-completions.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/ai/src/providers/openai-completions.ts) - OpenAI Chat Completions
- [openai-responses.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/ai/src/providers/openai-responses.ts) - OpenAI Responses API
- [google.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/ai/src/providers/google.ts) - Google Generative AI
- [amazon-bedrock.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/ai/src/providers/amazon-bedrock.ts) - AWS Bedrock
- [anthropic-messages.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/ai/src/api/anthropic-messages.ts) - Anthropic Messages API
- [mistral-conversations.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/ai/src/api/mistral-conversations.ts) - Mistral Conversations API
- [openai-completions.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/ai/src/api/openai-completions.ts) - OpenAI Chat Completions
- [openai-responses.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/ai/src/api/openai-responses.ts) - OpenAI Responses API
- [google-generative-ai.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/ai/src/api/google-generative-ai.ts) - Google Generative AI
- [bedrock-converse-stream.ts](https://github.com/earendil-works/pi-mono/blob/main/packages/ai/src/api/bedrock-converse-stream.ts) - AWS Bedrock
### Stream Pattern

View file

@ -89,6 +89,9 @@ These variables are read by Pi itself:
| `PI_CACHE_RETENTION` | Set to `long` for extended provider prompt caching where supported |
| `PI_SHARE_VIEWER_URL` | Override the base URL used by `/share` |
| `PI_HARDWARE_CURSOR` | Set to `1` to show the hardware cursor; see [Terminal setup](terminal-setup.md) |
| `PI_HYPERLINKS` | Override OSC 8 hyperlink detection with `1`, `0`, or `auto` |
| `PI_IMAGE_PROTOCOL` | Override inline image detection with `kitty`, `iterm2`, `none`, or `auto` |
| `PI_TRUE_COLOR` | Override truecolor detection with `1`, `0`, or `auto` |
| `PI_TUI_ESC_TIMEOUT` | How long to wait after a lone ESC before treating it as Escape, in milliseconds; defaults to `100` over SSH and `10` otherwise. Increase if Alt-key input is misread as Escape |
| `VISUAL`, `EDITOR` | External editor fallback when `externalEditor` is unset |
| `HTTP_PROXY`, `HTTPS_PROXY` | Proxy outbound HTTP requests |

View file

@ -580,6 +580,24 @@ pi.on("agent_settled", async (_event, ctx) => {
});
```
#### ui_prompt_start / ui_prompt_end
Notification-only lifecycle events for blocking user-facing extension UI prompts. They fire around `ctx.ui.select()`, `ctx.ui.confirm()`, `ctx.ui.input()`, `ctx.ui.editor()`, and `ctx.ui.custom()` so host/status integrations can report "waiting for user" instead of just "running".
Nested or overlapping prompts are coalesced into one outer waiting span. Handlers are invoked best-effort and are not awaited before showing or closing the prompt.
```typescript
pi.on("ui_prompt_start", async (event, ctx) => {
// event.reason === "ui_prompt"
// event.kind: "select" | "confirm" | "input" | "editor" | "custom"
// event.title: prompt title when available
});
pi.on("ui_prompt_end", async (event, ctx) => {
// Pi is no longer waiting on that UI prompt span.
});
```
#### turn_start / turn_end
Fired for each turn (one LLM response + tool calls).

View file

@ -157,7 +157,7 @@ This routing remains configurable through the ordinary action bindings. For exam
| Keybinding id | Default | Description |
|--------|---------|-------------|
| `app.tools.expand` | `ctrl+o` | Collapse or expand tool output |
| `app.message.copy` | `ctrl+x` | Copy the last assistant message, or the selected message in `/tree` |
| `app.message.copy` | `ctrl+x` | Copy the selected message in `/tree`; otherwise copy the last assistant message, or the active fullscreen text selection when `fullscreenCopyOnSelect` is disabled |
| `app.message.followUp` | `alt+enter` (`ctrl+q` on Windows and WSL) | Queue follow-up message |
| `app.message.dequeue` | `alt+up` (`alt+q` on Windows and WSL) | Restore queued messages to editor |

View file

@ -129,7 +129,7 @@ The command output is sent to the model. Use `!!command` to run a command withou
### Switch models
Use `/model` or Ctrl+L to choose a model. Use Shift+Tab to cycle thinking level. Use Ctrl+P / Shift+Ctrl+P to cycle through scoped models.
Use `/model` or Ctrl+L to choose a model for the current session. Press Ctrl+S in the model picker to save the highlighted model as the startup default. Use `/thinking` to choose a thinking level for the current session, or Ctrl+S in that picker to save the startup default thinking level. Use Shift+Tab to cycle thinking level. Use Ctrl+P / Shift+Ctrl+P to cycle through scoped models.
### Continue later

View file

@ -134,6 +134,29 @@ Response:
{"type": "response", "command": "abort", "success": true}
```
#### clear_queue
Remove queued steering and follow-up messages and return their text.
```json
{"type": "clear_queue"}
```
Response:
```json
{
"type": "response",
"command": "clear_queue",
"success": true,
"data": {
"steering": ["Change direction"],
"followUp": ["Summarize when finished"]
}
}
```
To implement interactive Esc behavior, send `clear_queue` before `abort`, then restore the returned text in the client editor. `abort` continues queued messages when they remain in the session.
#### new_session
Start a fresh session. Can be cancelled by a `session_before_switch` extension event handler.

View file

@ -7,7 +7,7 @@ Pi uses JSON settings files with project settings overriding global settings.
| `~/.pi/agent/settings.json` | Global (all projects) |
| `.pi/settings.json` | Project (current directory) |
Edit directly or use `/settings` for common options.
Edit directly or use `/settings` for common options. To save startup model defaults interactively, use `/model` and press Ctrl+S on the desired model. To save the startup thinking level, use `/thinking` and press Ctrl+S.
## Project Trust
@ -27,9 +27,10 @@ Use `/trust` in interactive mode to save a project trust decision for future ses
| Setting | Type | Default | Description |
|---------|------|---------|-------------|
| `defaultProvider` | string | - | Default provider (e.g., `"anthropic"`, `"openai"`) |
| `defaultModel` | string | - | Default model ID |
| `defaultThinkingLevel` | string | - | `"off"`, `"minimal"`, `"low"`, `"medium"`, `"high"`, `"xhigh"`, `"max"` |
| `defaultProvider` | string | - | Startup provider (e.g., `"anthropic"`, `"openai"`; saved with Ctrl+S in `/model`, or edited manually) |
| `defaultModel` | string | - | Startup model ID (saved with Ctrl+S in `/model`, or edited manually) |
| `defaultThinkingLevel` | string | - | Startup thinking level (saved with Ctrl+S in `/thinking`, or edited manually): `"off"`, `"minimal"`, `"low"`, `"medium"`, `"high"`, `"xhigh"`, `"max"` |
| `modelThinkingLevels` | object | - | Per-model startup thinking levels keyed by `"provider/modelId"`; configure from `/settings` → Default thinking level per model or edit manually |
| `hideThinkingBlock` | boolean | `false` | Hide thinking blocks in output |
| `showCacheMissNotices` | boolean | `false` | Show transcript notices for significant prompt-cache misses and compaction or branch-summary usage |
| `thinkingBudgets` | object | - | Custom token budgets per thinking level. Anthropic, Google, and Bedrock use these natively. OpenAI-compatible models use them when `compat.thinkingTokenBudgetField` (or `supportsThinkingTokenBudget`) is set. |
@ -68,6 +69,7 @@ Use `/trust` in interactive mode to save a project trust decision for future ses
| `tuiMode` | string | `"regular"` | Interactive TUI mode: `"regular"` or experimental `"fullscreen"`. Changes from `/settings` apply immediately; `--tui-mode` overrides this setting at startup |
| `fullscreenExitOutput` | string | `"transcript"` | Fullscreen exit output: `"transcript"` prints the final transcript and resume hint, while `"resume-hint"` restores the previous screen and prints only the resume hint. Has no effect in regular TUI mode |
| `fullscreenScrollbar` | string | `"auto"` | Fullscreen transcript scrollbar: `"auto"` shows it temporarily while scrolling, `"always"` reserves the rightmost column and keeps it visible, and `"hidden"` hides it. Has no effect in regular TUI mode |
| `fullscreenCopyOnSelect` | boolean | `true` | Automatically copy selected text in fullscreen mode. When disabled, selections stay highlighted and `Ctrl+X` copies the active selection |
For VS Code, include `--wait` so pi resumes after the editor exits:
@ -181,6 +183,9 @@ Keep `retry.provider.maxRetries` at `0` unless provider-level retries are explic
| `terminal.showImages` | boolean | `true` | Show images in terminal (if supported) |
| `terminal.imageWidthCells` | number | `60` | Preferred inline image width in terminal cells |
| `terminal.clearOnShrink` | boolean | `false` | Clear empty rows when content shrinks (can cause flicker) |
| `terminal.hyperlinks` | boolean or `"auto"` | `"auto"` | Override OSC 8 hyperlink support (advanced, JSON-only) |
| `terminal.images` | string or boolean | `"auto"` | Override image protocol support with `"kitty"`, `"iterm2"`, `false`, or `"auto"` (advanced, JSON-only) |
| `terminal.trueColor` | boolean or `"auto"` | `"auto"` | Override truecolor support (advanced, JSON-only) |
| `images.autoResize` | boolean | `true` | Resize images to 2000x2000 max. Applies to `@file` attachments, `read`, and images returned by tools |
| `images.blockImages` | boolean | `false` | Block all images from being sent to LLM |
@ -319,6 +324,9 @@ See [packages.md](packages.md) for package management details.
"defaultProvider": "anthropic",
"defaultModel": "claude-sonnet-4-20250514",
"defaultThinkingLevel": "medium",
"modelThinkingLevels": {
"anthropic/claude-sonnet-4-20250514": "high"
},
"theme": "dark",
"compaction": {
"enabled": true,

View file

@ -2,6 +2,18 @@
Pi uses the [Kitty keyboard protocol](https://sw.kovidgoyal.net/kitty/keyboard-protocol/) for reliable modifier key detection. Most modern terminals support this protocol, but some require configuration.
## Capability Overrides
Pi auto-detects OSC 8 hyperlinks, inline image protocols, and truecolor. If detection fails behind a terminal proxy or multiplexer, use these advanced overrides:
| Capability | Environment variable | JSON setting |
|------------|----------------------|--------------|
| OSC 8 hyperlinks | `PI_HYPERLINKS=1\|0\|auto` | `terminal.hyperlinks: true\|false\|"auto"` |
| Inline images | `PI_IMAGE_PROTOCOL=kitty\|iterm2\|none\|auto` | `terminal.images: "kitty"\|"iterm2"\|false\|"auto"` |
| Truecolor | `PI_TRUE_COLOR=1\|0\|auto` | `terminal.trueColor: true\|false\|"auto"` |
Settings take precedence over environment variables; unset or `auto` preserves detection. Only force capabilities supported by the complete terminal path, since unsupported escape sequences can corrupt rendering.
## Kitty
Works out of the box.

View file

@ -22,7 +22,7 @@ The editor can be replaced temporarily by built-in UI such as `/settings` or by
| File reference | Type `@` to fuzzy-search project files |
| Path completion | Press Tab to complete paths |
| Multi-line input | Shift+Enter, or Ctrl+Enter on Windows Terminal |
| Copy response | Ctrl+X copies the last assistant message; in `/tree`, it copies the selected message |
| Copy response | Ctrl+X copies the selected message in `/tree`; otherwise it copies the last assistant message, or the active fullscreen text selection when `fullscreenCopyOnSelect` is disabled |
| Images | Paste with Ctrl+V, Alt+V on Windows, or drag into the terminal |
| Shell command | `!command` runs and sends output to the model |
| Hidden shell command | `!!command` runs without sending output to the model |
@ -38,9 +38,10 @@ Type `/` in the editor to open command completion. Extensions can register custo
|---------|-------------|
| `/login`, `/logout` | Manage OAuth or API-key credentials |
| [`/llama`](llama-cpp.md) | Download, load, and unload llama.cpp router models |
| `/model` | Switch models |
| `/model` | Switch models; Ctrl+S in the picker saves the startup default |
| `/thinking` | Switch thinking level; Ctrl+S in the picker saves the startup default |
| `/scoped-models` | Enable/disable models for Ctrl+P cycling |
| `/settings` | Thinking level, theme, message delivery, transport |
| `/settings` | Theme, message delivery, transport, and other preferences |
| `/resume` | Pick from previous sessions |
| `/new` | Start a new session |
| `/name <name>` | Set session display name |

View file

@ -1,4 +1,10 @@
import { ProcessTerminal, setKeybindings, type TUI, TuiMainScreen } from "@earendil-works/pi-tui";
import {
ProcessTerminal,
setCapabilityOverrides,
setKeybindings,
type TUI,
TuiMainScreen,
} from "@earendil-works/pi-tui";
import { existsSync } from "fs";
import { APP_NAME, CONFIG_DIR_NAME, ENV_AGENT_DIR, getAgentDir, getSettingsPath, PACKAGE_NAME } from "../config.ts";
import { areExperimentalFeaturesEnabled } from "../core/experimental.ts";
@ -75,6 +81,7 @@ async function loadStartupThemes(settingsManager: SettingsManager): Promise<Them
}
export async function createStartupTui(settingsManager: SettingsManager): Promise<TUI> {
setCapabilityOverrides(settingsManager.getTerminalCapabilityOverrides());
setRegisteredThemes(await loadStartupThemes(settingsManager));
const terminalTheme = detectTerminalBackgroundFromEnv().theme;
initTheme(resolveThemeSetting(settingsManager.getThemeSetting(), terminalTheme) ?? terminalTheme);

View file

@ -327,6 +327,8 @@ export class AgentSession {
private _followUpMessages: string[] = [];
/** Messages queued to be included with the next user prompt as context ("asides"). */
private _pendingNextTurnMessages: CustomMessage[] = [];
/** Context-only custom messages queued during a run, flushed once the current turn's tool results are in. */
private _pendingCustomMessages: CustomMessage[] = [];
// Compaction state
private _compactionAbortController: AbortController | undefined = undefined;
@ -689,6 +691,15 @@ export class AgentSession {
}
}
}
// A turn ends after its assistant message and every tool result has been appended,
// so this is the first point in the run where a context-only custom message can be
// inserted without landing between a tool call and its result. Flushing after the
// extension and listener dispatch above also picks up messages that turn_end
// handlers queued.
if (event.type === "turn_end") {
this._flushPendingCustomMessages();
}
};
private _willRetryAfterAgentEnd(event: Extract<AgentEvent, { type: "agent_end" }>): boolean {
@ -1081,6 +1092,7 @@ export class AgentSession {
} finally {
this._systemPromptOverride = undefined;
this._flushPendingBashMessages();
this._flushPendingCustomMessages();
await this._emitAgentSettled();
}
}
@ -1190,8 +1202,9 @@ export class AgentSession {
return;
}
// Flush any pending bash messages before the new prompt
// Flush any pending bash and custom messages before the new prompt
this._flushPendingBashMessages();
this._flushPendingCustomMessages();
// Validate model
if (!this.model) {
@ -1436,8 +1449,9 @@ export class AgentSession {
/**
* Send a custom message to the session. Creates a CustomMessageEntry.
*
* Handles three cases:
* Handles four cases:
* - Streaming: queues message, processed when loop pulls from queue
* - Streaming + triggerTurn false: appended to state/session once the current turn ends
* - Not streaming + triggerTurn: appends to state/session, starts new turn
* - Not streaming + no trigger: appends to state/session, no turn
*
@ -1468,16 +1482,40 @@ export class AgentSession {
}
} else if (options?.triggerTurn) {
await this._runAgentPrompt(appMessage);
} else if (this.isStreaming) {
// Appending now would put the message between an assistant tool call and its
// result, which providers that validate message order reject on replay. Defer
// to the end of the turn. Nothing is emitted yet: message events must not
// describe messages the session tree does not contain.
this._pendingCustomMessages.push(appMessage);
} else {
this.agent.state.messages.push(appMessage);
this.sessionManager.appendCustomMessageEntry(
message.customType,
message.content,
message.display,
message.details,
);
this._emit({ type: "message_start", message: appMessage });
this._emit({ type: "message_end", message: appMessage });
this._appendCustomMessage(appMessage);
}
}
private _appendCustomMessage(appMessage: CustomMessage): void {
this.agent.state.messages.push(appMessage);
this.sessionManager.appendCustomMessageEntry(
appMessage.customType,
appMessage.content,
appMessage.display,
appMessage.details,
);
this._emit({ type: "message_start", message: appMessage });
this._emit({ type: "message_end", message: appMessage });
}
/**
* Append custom messages queued while the agent was running.
* Called once the current turn's tool results are in agent state and session history.
*/
private _flushPendingCustomMessages(): void {
if (this._pendingCustomMessages.length === 0) return;
const pending = this._pendingCustomMessages;
this._pendingCustomMessages = [];
for (const appMessage of pending) {
this._appendCustomMessage(appMessage);
}
}
@ -1606,6 +1644,7 @@ export class AgentSession {
this.sessionManager.appendModelChange(model.provider, model.id);
if (options.persist) {
this.settingsManager.setDefaultModelAndProvider(model.provider, model.id);
this._addPersistedDefaultToNonEmptyScope(model);
}
// Apply thinking level for the new model.
@ -1616,6 +1655,20 @@ export class AgentSession {
await this._emitModelSelect(model, previousModel, "set");
}
private _addPersistedDefaultToNonEmptyScope(model: Model<any>): void {
if (this._scopedModels.length === 0) return;
if (this._scopedModels.some((scoped) => modelsAreEqual(scoped.model, model))) return;
this._scopedModels = [...this._scopedModels, { model }];
const enabledModels = this.settingsManager.getEnabledModels();
if (!enabledModels?.length) return;
const modelReference = `${model.provider}/${model.id}`;
if (enabledModels.some((pattern) => pattern.toLowerCase() === modelReference.toLowerCase())) return;
this.settingsManager.setEnabledModels([...enabledModels, modelReference]);
}
/**
* Cycle to next/previous model.
* Uses scoped models (from --models flag) if available, otherwise all available models.
@ -1658,6 +1711,7 @@ export class AgentSession {
this.sessionManager.appendModelChange(next.model.provider, next.model.id);
if (options.persist) {
this.settingsManager.setDefaultModelAndProvider(next.model.provider, next.model.id);
this._addPersistedDefaultToNonEmptyScope(next.model);
}
// Apply thinking level for the new model.
@ -1692,6 +1746,7 @@ export class AgentSession {
this.sessionManager.appendModelChange(nextModel.provider, nextModel.id);
if (options.persist) {
this.settingsManager.setDefaultModelAndProvider(nextModel.provider, nextModel.id);
this._addPersistedDefaultToNonEmptyScope(nextModel);
}
// Apply thinking level for the new model.

View file

@ -590,7 +590,6 @@ export async function completeSummarization(
...options,
cacheRetention: "none",
sessionId: options.sessionId ?? uuidv7(),
toolChoice: "none",
};
const produce = async (): Promise<AssistantMessage> =>
streamFn

View file

@ -166,6 +166,9 @@ export type {
TreePreparation,
TurnEndEvent,
TurnStartEvent,
UIPromptEndEvent,
UIPromptKind,
UIPromptStartEvent,
// Events - User Bash
UserBashEvent,
UserBashEventResult,

View file

@ -62,6 +62,7 @@ import type {
ToolCallEventResult,
ToolResultEvent,
ToolResultEventResult,
UIPromptKind,
UserBashEvent,
UserBashEventResult,
} from "./types.ts";
@ -295,6 +296,8 @@ export class ExtensionRunner {
private shortcutDiagnostics: ResourceDiagnostic[] = [];
private commandDiagnostics: ResourceDiagnostic[] = [];
private staleMessage: string | undefined;
private uiPromptDepth = 0;
private activeUIPrompt: { kind: UIPromptKind; title?: string } | undefined;
constructor(
extensions: Extension[],
@ -431,10 +434,57 @@ export class ExtensionRunner {
}
setUIContext(uiContext?: ExtensionUIContext, mode: ExtensionMode = "print"): void {
this.uiContext = uiContext ?? noOpUIContext;
this.uiContext = uiContext ? this.wrapUIPromptContext(uiContext) : noOpUIContext;
this.mode = mode;
}
private wrapUIPromptContext(ui: ExtensionUIContext): ExtensionUIContext {
return {
...ui,
select: (title, options, opts) => this.withUIPrompt("select", title, () => ui.select(title, options, opts)),
confirm: (title, message, opts) => this.withUIPrompt("confirm", title, () => ui.confirm(title, message, opts)),
input: (title, placeholder, opts) =>
this.withUIPrompt("input", title, () => ui.input(title, placeholder, opts)),
editor: (title, prefill) => this.withUIPrompt("editor", title, () => ui.editor(title, prefill)),
custom: (factory, options) => this.withUIPrompt("custom", undefined, () => ui.custom(factory, options)),
};
}
private withUIPrompt<T>(kind: UIPromptKind, title: string | undefined, run: () => Promise<T>): Promise<T> {
const outerPrompt = this.uiPromptDepth++ === 0;
if (outerPrompt) {
this.activeUIPrompt = { kind, title };
this.emitUIPromptEvent({ type: "ui_prompt_start", reason: "ui_prompt", kind, ...(title ? { title } : {}) });
}
const finish = () => {
if (--this.uiPromptDepth > 0) return;
this.uiPromptDepth = 0;
const prompt = this.activeUIPrompt ?? { kind, title };
this.activeUIPrompt = undefined;
this.emitUIPromptEvent({
type: "ui_prompt_end",
reason: "ui_prompt",
kind: prompt.kind,
...(prompt.title ? { title: prompt.title } : {}),
});
};
try {
return run().finally(finish);
} catch (err) {
finish();
throw err;
}
}
private emitUIPromptEvent(event: Extract<RunnerEmitEvent, { type: "ui_prompt_start" | "ui_prompt_end" }>): void {
queueMicrotask(() => {
void this.emit(event);
});
}
getUIContext(): ExtensionUIContext {
return this.uiContext;
}

View file

@ -742,6 +742,24 @@ export interface AgentSettledEvent {
type: "agent_settled";
}
export type UIPromptKind = "select" | "confirm" | "input" | "editor" | "custom";
/** Fired when Pi starts waiting on a blocking user-facing extension UI prompt. */
export interface UIPromptStartEvent {
type: "ui_prompt_start";
reason: "ui_prompt";
kind: UIPromptKind;
title?: string;
}
/** Fired when Pi is no longer waiting on a blocking user-facing extension UI prompt. */
export interface UIPromptEndEvent {
type: "ui_prompt_end";
reason: "ui_prompt";
kind: UIPromptKind;
title?: string;
}
/** Fired at the start of each turn */
export interface TurnStartEvent {
type: "turn_start";
@ -1077,6 +1095,8 @@ export type ExtensionEvent =
| AgentStartEvent
| AgentEndEvent
| AgentSettledEvent
| UIPromptStartEvent
| UIPromptEndEvent
| TurnStartEvent
| TurnEndEvent
| MessageStartEvent
@ -1263,6 +1283,8 @@ export interface ExtensionAPI {
on(event: "agent_start", handler: ExtensionHandler<AgentStartEvent>): void;
on(event: "agent_end", handler: ExtensionHandler<AgentEndEvent>): void;
on(event: "agent_settled", handler: ExtensionHandler<AgentSettledEvent>): void;
on(event: "ui_prompt_start", handler: ExtensionHandler<UIPromptStartEvent>): void;
on(event: "ui_prompt_end", handler: ExtensionHandler<UIPromptEndEvent>): void;
on(event: "turn_start", handler: ExtensionHandler<TurnStartEvent>): void;
on(event: "turn_end", handler: ExtensionHandler<TurnEndEvent>): void;
on(event: "message_start", handler: ExtensionHandler<MessageStartEvent>): void;

View file

@ -516,11 +516,11 @@ export function loadEntriesFromFile(filePath: string): FileEntry[] {
if (!existsSync(resolvedFilePath)) return [];
const entries: FileEntry[] = [];
let pending = "";
const fd = openSync(resolvedFilePath, "r");
try {
const decoder = new StringDecoder("utf8");
const buffer = Buffer.allocUnsafe(SESSION_READ_BUFFER_SIZE);
let pending = "";
while (true) {
const bytesRead = readSync(fd, buffer, 0, buffer.length, null);
@ -545,13 +545,14 @@ export function loadEntriesFromFile(filePath: string): FileEntry[] {
closeSync(fd);
}
// Validate session header
// Validate session header before repairing the file.
if (entries.length === 0) return entries;
const header = entries[0];
if (header.type !== "session" || typeof (header as { id?: unknown }).id !== "string") {
return [];
}
if (pending) appendFileSync(resolvedFilePath, "\n");
return entries;
}

View file

@ -1,6 +1,6 @@
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import type { Transport } from "@earendil-works/pi-ai";
import type { TuiMode as RendererTuiMode, ScrollViewScrollbar } from "@earendil-works/pi-tui";
import type { TuiMode as RendererTuiMode, ScrollViewScrollbar, TerminalCapabilities } from "@earendil-works/pi-tui";
import { randomUUID } from "crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import { dirname, join } from "path";
@ -42,6 +42,9 @@ export interface TerminalSettings {
imageWidthCells?: number; // default: 60 (preferred inline image width in terminal cells)
clearOnShrink?: boolean; // default: false (clear empty rows when content shrinks)
showTerminalProgress?: boolean; // default: false (OSC 9;4 terminal progress indicators)
hyperlinks?: boolean | "auto";
images?: "kitty" | "iterm2" | "auto" | false;
trueColor?: boolean | "auto";
}
export interface ImageSettings {
@ -139,6 +142,7 @@ export interface Settings {
tuiMode?: TuiMode; // default: "regular"
fullscreenExitOutput?: FullscreenExitOutput; // default: "transcript"; no effect in regular TUI mode
fullscreenScrollbar?: ScrollViewScrollbar; // default: "auto"; no effect in regular TUI mode
fullscreenCopyOnSelect?: boolean; // default: true; no effect in regular TUI mode
}
function isMergeableObject(value: unknown): value is Record<string, unknown> {
@ -1125,6 +1129,16 @@ export class SettingsManager {
return this.settings.thinkingBudgets;
}
getTerminalCapabilityOverrides(): Partial<TerminalCapabilities> {
const terminal = this.settings.terminal;
const images = terminal?.images;
return {
...(images === "kitty" || images === "iterm2" ? { images } : images === false ? { images: null } : {}),
...(typeof terminal?.trueColor === "boolean" ? { trueColor: terminal.trueColor } : {}),
...(typeof terminal?.hyperlinks === "boolean" ? { hyperlinks: terminal.hyperlinks } : {}),
};
}
getShowImages(): boolean {
return this.settings.terminal?.showImages ?? true;
}
@ -1216,6 +1230,16 @@ export class SettingsManager {
this.save();
}
getFullscreenCopyOnSelect(): boolean {
return this.settings.fullscreenCopyOnSelect ?? true;
}
setFullscreenCopyOnSelect(enabled: boolean): void {
this.globalSettings.fullscreenCopyOnSelect = enabled;
this.markModified("fullscreenCopyOnSelect");
this.save();
}
getImageAutoResize(): boolean {
return this.settings.images?.autoResize ?? true;
}

View file

@ -144,6 +144,9 @@ export type {
ToolResultEvent,
TurnEndEvent,
TurnStartEvent,
UIPromptEndEvent,
UIPromptKind,
UIPromptStartEvent,
UserBashEvent,
UserBashEventResult,
WidgetPlacement,
@ -415,5 +418,6 @@ export { copyToClipboard } from "./utils/clipboard.ts";
export { parseFrontmatter, stripFrontmatter } from "./utils/frontmatter.ts";
export { convertToPng } from "./utils/image-convert.ts";
export { formatDimensionNote, type ResizedImage, resizeImage } from "./utils/image-resize.ts";
export { detectSupportedImageMimeTypeFromFile } from "./utils/mime.ts";
// Shell utilities
export { getPowerShellConfig, getShellConfig } from "./utils/shell.ts";

View file

@ -7,6 +7,7 @@
import { createInterface } from "node:readline";
import { type ImageContent, modelsAreEqual } from "@earendil-works/pi-ai";
import { setCapabilityOverrides } from "@earendil-works/pi-tui";
import chalk from "chalk";
import { type Args, type Mode, normalizeSessionName, parseArgs, printHelp } from "./cli/args.ts";
import {
@ -964,6 +965,7 @@ export async function main(args: string[], options?: MainOptions) {
time("createAgentSessionRuntime");
const { services, session, modelFallbackMessage } = runtime;
const { settingsManager, modelRuntime, resourceLoader } = services;
setCapabilityOverrides(settingsManager.getTerminalCapabilityOverrides());
applyHttpProxySettings(settingsManager.getGlobalSettings().httpProxy);
configureHttpDispatcher(settingsManager.getHttpIdleTimeoutMs());

View file

@ -84,6 +84,7 @@ export interface SettingsConfig {
tuiMode: TuiMode;
fullscreenExitOutput: FullscreenExitOutput;
fullscreenScrollbar: ScrollViewScrollbar;
fullscreenCopyOnSelect: boolean;
warnings: WarningSettings;
}
@ -120,6 +121,7 @@ export interface SettingsCallbacks {
onTuiModeChange: (mode: TuiMode) => void;
onFullscreenExitOutputChange: (output: FullscreenExitOutput) => void;
onFullscreenScrollbarChange: (mode: ScrollViewScrollbar) => void;
onFullscreenCopyOnSelectChange: (enabled: boolean) => void;
onWarningsChange: (warnings: WarningSettings) => void;
onCancel: () => void;
}
@ -689,6 +691,13 @@ export class SettingsSelectorComponent extends Container {
currentValue: config.fullscreenScrollbar,
values: ["auto", "always", "hidden"],
},
{
id: "fullscreen-copy-on-select",
label: "Fullscreen copy on select",
description: "Automatically copy selected text in fullscreen mode; disable to copy selections with Ctrl+X",
currentValue: config.fullscreenCopyOnSelect ? "true" : "false",
values: ["true", "false"],
},
{
id: "theme",
label: "Theme",
@ -910,6 +919,9 @@ export class SettingsSelectorComponent extends Container {
case "fullscreen-scrollbar":
callbacks.onFullscreenScrollbarChange(newValue as ScrollViewScrollbar);
break;
case "fullscreen-copy-on-select":
callbacks.onFullscreenCopyOnSelectChange(newValue === "true");
break;
case "theme":
callbacks.onThemeChange(newValue);
break;

View file

@ -33,6 +33,7 @@ import {
Markdown,
matchesKey,
Spacer,
setCapabilityOverrides,
setKeybindings,
Text,
TruncatedText,
@ -503,6 +504,7 @@ export class InteractiveMode {
constructor(runtimeHost: AgentSessionRuntime, options: InteractiveModeOptions = {}) {
this.runtimeHost = runtimeHost;
setCapabilityOverrides(this.settingsManager.getTerminalCapabilityOverrides());
const tuiMode = options.tuiMode ?? this.settingsManager.getTuiMode();
this.options = { ...options, tuiMode };
this.autoTrustOnReloadCwd = options.autoTrustOnReloadCwd;
@ -519,6 +521,7 @@ export class InteractiveMode {
showHardwareCursor: this.settingsManager.getShowHardwareCursor(),
logDirectory: getAgentDir(),
onRightClickPaste: this.onRightClickPaste,
fullscreenCopyOnSelect: this.settingsManager.getFullscreenCopyOnSelect(),
});
this.ui = createInteractiveTuiReference(() => this.renderer);
this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink());
@ -803,6 +806,7 @@ export class InteractiveMode {
logDirectory: getAgentDir(),
terminal,
onRightClickPaste: this.onRightClickPaste,
fullscreenCopyOnSelect: this.settingsManager.getFullscreenCopyOnSelect(),
});
nextUi.setClearOnShrink(clearOnShrink);
nextUi.onDebug = onDebug;
@ -1916,8 +1920,12 @@ export class InteractiveMode {
}
private applyRuntimeSettings(): void {
setCapabilityOverrides(this.settingsManager.getTerminalCapabilityOverrides());
configureHttpDispatcher(this.settingsManager.getHttpIdleTimeoutMs());
this.applyFullscreenScrollbarSetting();
if (this.renderer instanceof TuiAltScreen) {
this.renderer.setCopyOnSelect(this.settingsManager.getFullscreenCopyOnSelect());
}
this.footer.setSession(this.session);
this.footer.setAutoCompactEnabled(this.session.autoCompactionEnabled);
this.footerDataProvider.setCwd(this.sessionManager.getCwd());
@ -2829,7 +2837,10 @@ export class InteractiveMode {
this.defaultEditor.onAction("app.tools.expand", () => this.toggleToolOutputExpansion());
this.defaultEditor.onAction("app.thinking.toggle", () => this.toggleThinkingBlockVisibility());
this.defaultEditor.onAction("app.editor.external", () => void this.handleOpenExternalEditor());
this.defaultEditor.onAction("app.message.copy", () => void this.handleCopyCommand({ flashConfirmation: true }));
this.defaultEditor.onAction(
"app.message.copy",
() => void this.handleCopyCommand({ flashConfirmation: true, preferSelection: true }),
);
this.defaultEditor.onAction("app.message.followUp", () => this.handleFollowUp());
this.defaultEditor.onAction("app.message.dequeue", () => this.handleDequeue());
this.defaultEditor.onAction("app.session.new", () => this.handleClearCommand());
@ -4133,21 +4144,20 @@ export class InteractiveMode {
this.showStatus(`Tool output: ${expanded ? "expanded" : "collapsed"}`);
}
/** Update rendered assistant messages without rebuilding live tool components. */
private updateThinkingBlockVisibility(): void {
for (const child of this.chatContainer.children) {
if (child instanceof AssistantMessageComponent) {
child.setHideThinkingBlock(this.hideThinkingBlock);
}
}
this.ui.requestRender();
}
private toggleThinkingBlockVisibility(): void {
this.hideThinkingBlock = !this.hideThinkingBlock;
this.settingsManager.setHideThinkingBlock(this.hideThinkingBlock);
// Rebuild chat from session messages
this.chatContainer.clear();
this.rebuildChatFromMessages();
// If streaming, re-add the streaming component with updated visibility and re-render
if (this.streamingComponent && this.streamingMessage) {
this.streamingComponent.setHideThinkingBlock(this.hideThinkingBlock);
this.streamingComponent.updateContent(this.streamingMessage);
this.chatContainer.addChild(this.streamingComponent);
}
this.updateThinkingBlockVisibility();
this.showStatus(`Thinking blocks: ${this.hideThinkingBlock ? "hidden" : "visible"}`);
}
@ -4504,6 +4514,7 @@ export class InteractiveMode {
tuiMode: this.ui.mode,
fullscreenExitOutput: this.settingsManager.getFullscreenExitOutput(),
fullscreenScrollbar: this.settingsManager.getFullscreenScrollbar(),
fullscreenCopyOnSelect: this.settingsManager.getFullscreenCopyOnSelect(),
warnings: this.settingsManager.getWarnings(),
},
{
@ -4581,13 +4592,7 @@ export class InteractiveMode {
onHideThinkingBlockChange: (hidden) => {
this.hideThinkingBlock = hidden;
this.settingsManager.setHideThinkingBlock(hidden);
for (const child of this.chatContainer.children) {
if (child instanceof AssistantMessageComponent) {
child.setHideThinkingBlock(hidden);
}
}
this.chatContainer.clear();
this.rebuildChatFromMessages();
this.updateThinkingBlockVisibility();
},
onMermaidRenderingModeChange: (mode) => {
this.settingsManager.setMermaidRenderingMode(mode);
@ -4682,6 +4687,10 @@ export class InteractiveMode {
this.settingsManager.setFullscreenScrollbar(mode);
this.applyFullscreenScrollbarSetting();
},
onFullscreenCopyOnSelectChange: (enabled) => {
this.settingsManager.setFullscreenCopyOnSelect(enabled);
if (this.renderer instanceof TuiAltScreen) this.renderer.setCopyOnSelect(enabled);
},
onWarningsChange: (warnings) => {
this.settingsManager.setWarnings(warnings);
},
@ -4898,6 +4907,7 @@ export class InteractiveMode {
const selectModel = async (model: Model<any>, persist: boolean) => {
try {
await this.session.setModel(model, { persist });
this.updateAvailableProviderCount();
this.footer.invalidate();
this.updateEditorBorderColor();
done();
@ -5906,8 +5916,8 @@ export class InteractiveMode {
activeHeader.setExpanded(this.toolOutputExpanded);
}
setRegisteredThemes(this.session.resourceLoader.getThemes().themes);
await this.themeController.applyFromSettings();
this.applyRuntimeSettings();
await this.themeController.applyFromSettings();
this.setupAutocompleteProvider();
const runner = this.session.extensionRunner;
this.setupExtensionShortcuts(runner);
@ -6037,7 +6047,19 @@ export class InteractiveMode {
});
}
private async handleCopyCommand(options: { flashConfirmation?: boolean } = {}): Promise<void> {
private async handleCopyCommand(
options: { flashConfirmation?: boolean; preferSelection?: boolean } = {},
): Promise<void> {
if (
options.preferSelection &&
this.ui instanceof TuiAltScreen &&
!this.ui.getCopyOnSelect() &&
this.ui.hasActiveSelection()
) {
await this.ui.copyActiveSelectionToClipboard();
return;
}
const text = this.session.getLastAssistantText();
if (!text) {
this.showError("No agent messages to copy yet.");

View file

@ -10,6 +10,7 @@ export interface InteractiveTuiOptions {
readonly logDirectory: string;
readonly terminal?: Terminal;
readonly onRightClickPaste?: () => void;
readonly fullscreenCopyOnSelect?: boolean;
}
/** Composition root shared by coding-agent presentations. */
@ -25,6 +26,7 @@ export function createInteractiveTui(options: InteractiveTuiOptions): TuiMainScr
searchCurrentMatchStyle: (text) => theme.bold(theme.inverse(styleSearchMatch(text))),
openUrl: openBrowser,
onRightClickPaste: options.onRightClickPaste,
copyOnSelect: options.fullscreenCopyOnSelect,
copySelection: async (text) => {
try {
await copyToClipboard(text);

View file

@ -220,6 +220,14 @@ export class RpcClient {
await this.send({ type: "abort" });
}
/**
* Clear queued steering and follow-up messages, returning their text.
*/
async clearQueue(): Promise<{ steering: string[]; followUp: string[] }> {
const response = await this.send({ type: "clear_queue" });
return this.getData(response);
}
/**
* Start a new session, optionally with parent tracking.
* @param parentSession - Optional parent session path for lineage tracking

View file

@ -430,6 +430,10 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise<neve
return success(id, "abort");
}
case "clear_queue": {
return success(id, "clear_queue", session.clearQueue());
}
case "new_session": {
const options = command.parentSession ? { parentSession: command.parentSession } : undefined;
const result = await runtimeHost.newSession(options);

View file

@ -23,6 +23,7 @@ export type RpcCommand =
| { id?: string; type: "steer"; message: string; images?: ImageContent[] }
| { id?: string; type: "follow_up"; message: string; images?: ImageContent[] }
| { id?: string; type: "abort" }
| { id?: string; type: "clear_queue" }
| { id?: string; type: "new_session"; parentSession?: string }
// State
@ -118,6 +119,13 @@ export type RpcResponse =
| { id?: string; type: "response"; command: "steer"; success: true }
| { id?: string; type: "response"; command: "follow_up"; success: true }
| { id?: string; type: "response"; command: "abort"; success: true }
| {
id?: string;
type: "response";
command: "clear_queue";
success: true;
data: { steering: string[]; followUp: string[] };
}
| { id?: string; type: "response"; command: "new_session"; success: true; data: { cancelled: boolean } }
// State

View file

@ -1,5 +1,5 @@
import { existsSync } from "node:fs";
import { delimiter } from "node:path";
import { delimiter, join } from "node:path";
import { spawn, spawnSync } from "child_process";
import { getBinDir } from "../config.ts";
@ -215,15 +215,21 @@ export function killTrackedDetachedChildren(): void {
*/
export function killProcessTree(pid: number): void {
if (process.platform === "win32") {
// Use taskkill on Windows to kill process tree
// Use the trusted System32 executable so cleanup does not depend on PATH.
try {
spawn("taskkill", ["/F", "/T", "/PID", String(pid)], {
stdio: "ignore",
detached: true,
windowsHide: true,
});
const child = spawn(
join(process.env.SystemRoot ?? "C:\\Windows", "System32", "taskkill.exe"),
["/F", "/T", "/PID", String(pid)],
{
stdio: "ignore",
detached: true,
windowsHide: true,
},
);
// A failed spawn emits "error" asynchronously; consume it to avoid crashing Node.
child.once("error", () => {});
} catch {
// Ignore errors if taskkill fails
// Ignore errors if taskkill fails.
}
} else {
// Use SIGKILL on Unix/Linux/Mac

View file

@ -44,7 +44,7 @@ function response(content: AssistantMessage["content"]): AssistantMessage {
}
describe("branch summarization", () => {
it("disables tools for branch summaries", async () => {
it("does not override tool choice for branch summaries", async () => {
let requestOptions: SimpleStreamOptions | undefined;
const streamFn: StreamFn = (_model, _context, options) => {
requestOptions = options;
@ -61,7 +61,7 @@ describe("branch summarization", () => {
streamFn,
});
expect(requestOptions?.toolChoice).toBe("none");
expect(requestOptions?.toolChoice).toBeUndefined();
});
it("rejects tool calls from branch summaries", async () => {

View file

@ -109,13 +109,12 @@ describe("generateSummary reasoning options", () => {
const requestOptions = completeSimpleMock.mock.calls.map((call) => call[2]);
expect(requestOptions).toHaveLength(2);
expect(requestOptions.every((options) => options?.cacheRetention === "none")).toBe(true);
expect(requestOptions.every((options) => options?.toolChoice === "none")).toBe(true);
const sessionIds = requestOptions.map((options) => options?.sessionId);
expect(sessionIds[0]).not.toBe(sessionIds[1]);
});
it("honors a caller-supplied routing session without prompt caching", async () => {
it("honors caller-supplied routing session and tool choice without prompt caching", async () => {
await completeSummarization(
createModel(false),
{ systemPrompt: "Summarize", messages: [] },
@ -125,7 +124,7 @@ describe("generateSummary reasoning options", () => {
expect(completeSimpleMock.mock.calls[0][2]).toMatchObject({
sessionId: "current-routing-session",
cacheRetention: "none",
toolChoice: "none",
toolChoice: "auto",
});
});

View file

@ -87,6 +87,7 @@ describe("createInteractiveTui", () => {
renderer.setFocus(component);
type SwitchContext = {
runtimeHost: { session: { settingsManager: { getFullscreenCopyOnSelect: () => boolean } } };
renderer: ReturnType<typeof createInteractiveTui>;
ui: TUI;
fullscreenLayoutRoot: Component;
@ -95,6 +96,7 @@ describe("createInteractiveTui", () => {
extensionTerminalInputSubscriptions: Set<never>;
};
const context = Object.assign(Object.create(InteractiveMode.prototype), {
runtimeHost: { session: { settingsManager: { getFullscreenCopyOnSelect: () => true } } },
renderer,
ui: undefined as unknown as TUI,
fullscreenLayoutRoot: component,
@ -156,7 +158,7 @@ type CopyCommandContext = {
showError: (message: string) => void;
};
type CopyCommandOptions = { flashConfirmation?: boolean };
type CopyCommandOptions = { flashConfirmation?: boolean; preferSelection?: boolean };
type CopyCommandPrototype = {
handleCopyCommand(this: CopyCommandContext, options?: CopyCommandOptions): Promise<void>;
@ -170,6 +172,91 @@ describe("InteractiveMode copy confirmation", () => {
clipboardMocks.copyToClipboard.mockResolvedValue(undefined);
});
it("copies an active fullscreen selection when copy-on-select is disabled", async () => {
const terminal = new RecordingTerminal(40, 4);
const ui = createInteractiveTui({
tuiMode: "fullscreen",
showHardwareCursor: false,
logDirectory: "/tmp",
terminal,
fullscreenCopyOnSelect: false,
});
const getLastAssistantText = vi.fn(() => "assistant response");
const showStatus = vi.fn();
const showError = vi.fn();
const context: CopyCommandContext = {
session: { getLastAssistantText },
ui,
showStatus,
showError,
};
ui.addChild(new Text("alpha\nbeta\ngamma\ndelta", 0, 0));
ui.start();
try {
await terminal.waitForRender();
terminal.sendInput("\x1b[<0;1;1M");
terminal.sendInput("\x1b[<32;4;2M");
terminal.sendInput("\x1b[<0;4;2m");
await terminal.waitForRender();
clipboardMocks.copyToClipboard.mockClear();
await copyCommandPrototype.handleCopyCommand.call(context, { flashConfirmation: true, preferSelection: true });
await terminal.waitForRender();
expect(clipboardMocks.copyToClipboard).toHaveBeenCalledOnce();
expect(clipboardMocks.copyToClipboard).toHaveBeenCalledWith("alpha\nbeta");
expect(getLastAssistantText).not.toHaveBeenCalled();
expect(showStatus).not.toHaveBeenCalled();
expect(showError).not.toHaveBeenCalled();
expect(terminal.getViewport().some((line) => line.includes("Copied!"))).toBe(true);
} finally {
ui.stop();
}
});
it("copies the last assistant message with an active fullscreen selection when copy-on-select is enabled", async () => {
const terminal = new RecordingTerminal(40, 4);
const ui = createInteractiveTui({
tuiMode: "fullscreen",
showHardwareCursor: false,
logDirectory: "/tmp",
terminal,
});
const getLastAssistantText = vi.fn(() => "assistant response");
const showStatus = vi.fn();
const showError = vi.fn();
const context: CopyCommandContext = {
session: { getLastAssistantText },
ui,
showStatus,
showError,
};
ui.addChild(new Text("alpha\nbeta\ngamma\ndelta", 0, 0));
ui.start();
try {
await terminal.waitForRender();
terminal.sendInput("\x1b[<0;1;1M");
terminal.sendInput("\x1b[<32;4;2M");
terminal.sendInput("\x1b[<0;4;2m");
await terminal.waitForRender();
clipboardMocks.copyToClipboard.mockClear();
await copyCommandPrototype.handleCopyCommand.call(context, { flashConfirmation: true, preferSelection: true });
await terminal.waitForRender();
expect(clipboardMocks.copyToClipboard).toHaveBeenCalledOnce();
expect(clipboardMocks.copyToClipboard).toHaveBeenCalledWith("assistant response");
expect(getLastAssistantText).toHaveBeenCalledOnce();
expect(showStatus).not.toHaveBeenCalled();
expect(showError).not.toHaveBeenCalled();
expect(terminal.getViewport().some((line) => line.includes("Copied!"))).toBe(true);
} finally {
ui.stop();
}
});
it("flashes Copied! for the copy shortcut in fullscreen mode", async () => {
const terminal = new RecordingTerminal(40, 4);
const ui = createInteractiveTui({
@ -190,7 +277,7 @@ describe("InteractiveMode copy confirmation", () => {
ui.start();
try {
await terminal.waitForRender();
await copyCommandPrototype.handleCopyCommand.call(context, { flashConfirmation: true });
await copyCommandPrototype.handleCopyCommand.call(context, { flashConfirmation: true, preferSelection: true });
await terminal.waitForRender();
expect(clipboardMocks.copyToClipboard).toHaveBeenCalledWith("assistant response");
@ -218,7 +305,7 @@ describe("InteractiveMode copy confirmation", () => {
showError,
};
await copyCommandPrototype.handleCopyCommand.call(context, { flashConfirmation: true });
await copyCommandPrototype.handleCopyCommand.call(context, { flashConfirmation: true, preferSelection: true });
expect(showStatus).toHaveBeenCalledWith("Copied last agent message to clipboard");
expect(showError).not.toHaveBeenCalled();

View file

@ -1,6 +1,13 @@
import { mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Agent } from "@earendil-works/pi-agent-core";
import type { Model } from "@earendil-works/pi-ai";
import { getModel, streamSimple } from "@earendil-works/pi-ai/compat";
import { getBuiltinModels, getBuiltinProviders } from "@earendil-works/pi-ai/providers/all";
import { describe, expect, test, vi } from "vitest";
import { afterEach, describe, expect, test, vi } from "vitest";
import { AgentSession } from "../src/core/agent-session.ts";
import { AuthStorage } from "../src/core/auth-storage.ts";
import {
defaultModelPerProvider,
findInitialModel,
@ -9,6 +16,10 @@ import {
resolveModelScope,
resolveModelScopeWithDiagnostics,
} from "../src/core/model-resolver.ts";
import { SessionManager } from "../src/core/session-manager.ts";
import { SettingsManager } from "../src/core/settings-manager.ts";
import { createModelRegistry, getModelRuntime } from "./model-runtime-test-utils.ts";
import { createTestResourceLoader } from "./utilities.ts";
// Mock models for testing
const mockModels: Model<"anthropic-messages">[] = [
@ -814,4 +825,92 @@ describe("default model selection", () => {
expect(result.model?.provider).toBe("spark-two");
expect(result.model?.id).toBe("deepseek-v4-flash");
});
describe("persisted default model scoping", () => {
const tempDirs: string[] = [];
const sonnet = getModel("anthropic", "claude-sonnet-4-5")!;
const opus = getModel("anthropic", "claude-opus-4-8")!;
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
async function createSession(options: { scoped: boolean; persistedScope?: string[] }) {
const tempDir = join(tmpdir(), `pi-default-scope-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(tempDir, { recursive: true });
tempDirs.push(tempDir);
const settingsManager = SettingsManager.create(tempDir, tempDir);
if (options.persistedScope) {
settingsManager.setEnabledModels(options.persistedScope);
}
const authStorage = AuthStorage.inMemory({ anthropic: { type: "api_key", key: "test-key" } });
const modelRuntime = getModelRuntime(await createModelRegistry(authStorage, join(tempDir, "models.json")));
const agent = new Agent({
initialState: {
model: sonnet,
systemPrompt: "test",
tools: [],
},
streamFn: streamSimple,
});
const session = new AgentSession({
agent,
sessionManager: SessionManager.inMemory(tempDir),
settingsManager,
cwd: tempDir,
modelRuntime,
resourceLoader: createTestResourceLoader(),
scopedModels: options.scoped ? [{ model: sonnet }] : [],
});
return { session, settingsManager };
}
test("adds a persisted default to an existing scoped model list", async () => {
const { session, settingsManager } = await createSession({
scoped: true,
persistedScope: [`${sonnet.provider}/${sonnet.id}`],
});
await session.setModel(opus, { persist: true });
expect(settingsManager.getDefaultProvider()).toBe(opus.provider);
expect(settingsManager.getDefaultModel()).toBe(opus.id);
expect(session.scopedModels.map((scoped) => `${scoped.model.provider}/${scoped.model.id}`)).toEqual([
`${sonnet.provider}/${sonnet.id}`,
`${opus.provider}/${opus.id}`,
]);
expect(settingsManager.getEnabledModels()).toEqual([
`${sonnet.provider}/${sonnet.id}`,
`${opus.provider}/${opus.id}`,
]);
});
test("does not create a scoped model list when all models are available", async () => {
const { session, settingsManager } = await createSession({ scoped: false });
await session.setModel(opus, { persist: true });
expect(session.scopedModels).toEqual([]);
expect(settingsManager.getEnabledModels()).toBeUndefined();
});
test("keeps session-only model changes out of scope", async () => {
const { session, settingsManager } = await createSession({
scoped: true,
persistedScope: [`${sonnet.provider}/${sonnet.id}`],
});
await session.setModel(opus, { persist: false });
expect(session.scopedModels.map((scoped) => `${scoped.model.provider}/${scoped.model.id}`)).toEqual([
`${sonnet.provider}/${sonnet.id}`,
]);
expect(settingsManager.getEnabledModels()).toEqual([`${sonnet.provider}/${sonnet.id}`]);
});
});
});

View file

@ -0,0 +1,29 @@
import { describe, expect, it, vi } from "vitest";
import { RpcClient } from "../src/modes/rpc/rpc-client.ts";
type RpcClientPrivate = {
send: (command: { type: string }) => Promise<unknown>;
getData: <T>(response: unknown) => T;
};
describe("RpcClient clearQueue", () => {
it("sends the clear_queue RPC command", async () => {
const client = new RpcClient();
const privateClient = client as unknown as RpcClientPrivate;
const send = vi.fn(async () => ({
type: "response",
command: "clear_queue",
success: true,
data: { steering: ["Change direction"], followUp: ["Summarize when finished"] },
}));
privateClient.send = send;
privateClient.getData = <T>(response: unknown): T => {
return (response as { data: T }).data;
};
const result = await client.clearQueue();
expect(send).toHaveBeenCalledWith({ type: "clear_queue" });
expect(result).toEqual({ steering: ["Change direction"], followUp: ["Summarize when finished"] });
});
});

View file

@ -285,4 +285,58 @@ describe("RPC prompt response semantics", () => {
await cleanup();
}
});
it("returns and clears queued steering and follow-up messages", async () => {
const { lineHandler, cleanup } = await startRpcMode({ withAuth: true, responseDelayMs: 500 });
try {
lineHandler(JSON.stringify({ id: "clear-start", type: "prompt", message: "Start" }));
await vi.waitFor(() => {
expect(getPromptResponses(rpcIo.outputLines, "clear-start")).toHaveLength(1);
});
lineHandler(
JSON.stringify({
id: "clear-steering",
type: "prompt",
message: "Change direction",
streamingBehavior: "steer",
}),
);
await vi.waitFor(() => {
expect(getPromptResponses(rpcIo.outputLines, "clear-steering")).toHaveLength(1);
});
lineHandler(
JSON.stringify({
id: "clear-follow-up",
type: "prompt",
message: "Summarize when finished",
streamingBehavior: "followUp",
}),
);
await vi.waitFor(() => {
expect(getPromptResponses(rpcIo.outputLines, "clear-follow-up")).toHaveLength(1);
});
lineHandler(JSON.stringify({ id: "clear", type: "clear_queue" }));
await vi.waitFor(() => {
expect(parseOutputLines(rpcIo.outputLines)).toContainEqual({
id: "clear",
type: "response",
command: "clear_queue",
success: true,
data: {
steering: ["Change direction"],
followUp: ["Summarize when finished"],
},
});
});
await sleep(600);
expect(parseOutputLines(rpcIo.outputLines).filter((record) => record.type === "agent_start")).toHaveLength(1);
} finally {
await cleanup();
}
});
});

View file

@ -80,6 +80,36 @@ describe("loadEntriesFromFile", () => {
expect(entries).toHaveLength(2);
});
it("adds a newline after an unterminated valid record", () => {
const file = join(tempDir, "unterminated.jsonl");
const content =
'{"type":"session","id":"abc","timestamp":"2025-01-01T00:00:00Z","cwd":"/tmp"}\n' +
'{"type":"message","id":"1","parentId":null,"timestamp":"2025-01-01T00:00:01Z","message":{"role":"user","content":"hi","timestamp":1}}';
writeFileSync(file, content);
expect(loadEntriesFromFile(file)).toHaveLength(2);
expect(readFileSync(file, "utf8")).toBe(`${content}\n`);
});
it("adds a newline after an unterminated malformed final fragment", () => {
const file = join(tempDir, "malformed-tail.jsonl");
const content =
'{"type":"session","id":"abc","timestamp":"2025-01-01T00:00:00Z","cwd":"/tmp"}\n' + '{"type":"message"';
writeFileSync(file, content);
expect(loadEntriesFromFile(file)).toHaveLength(1);
expect(readFileSync(file, "utf8")).toBe(`${content}\n`);
});
it("does not modify an unterminated non-session file", () => {
const file = join(tempDir, "invalid.jsonl");
const content = '{"type":"message","id":"1"}';
writeFileSync(file, content);
expect(loadEntriesFromFile(file)).toEqual([]);
expect(readFileSync(file, "utf8")).toBe(content);
});
it.each([
["leading blank lines", "\n \n", "leading-blank"],
["leading malformed lines", "not json\n{broken json\n", "leading-malformed"],

View file

@ -3,7 +3,7 @@ import { homedir } from "os";
import { join } from "path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { DEFAULT_HTTP_IDLE_TIMEOUT_MS } from "../src/core/http-dispatcher.ts";
import { SettingsManager } from "../src/core/settings-manager.ts";
import { type Settings, SettingsManager } from "../src/core/settings-manager.ts";
describe("SettingsManager", () => {
const testDir = join(process.cwd(), "test-settings-tmp");
@ -335,6 +335,25 @@ describe("SettingsManager", () => {
});
});
describe("terminal capability overrides", () => {
it("maps explicit values and omits auto values", () => {
const getOverrides = (terminal: NonNullable<Settings["terminal"]>) =>
SettingsManager.inMemory({ terminal }).getTerminalCapabilityOverrides();
expect(getOverrides({ images: false, trueColor: false, hyperlinks: false })).toEqual({
images: null,
trueColor: false,
hyperlinks: false,
});
expect(getOverrides({ images: "kitty", trueColor: true, hyperlinks: true })).toEqual({
images: "kitty",
trueColor: true,
hyperlinks: true,
});
expect(getOverrides({ images: "auto", trueColor: "auto", hyperlinks: "auto" })).toEqual({});
});
});
describe("httpIdleTimeoutMs", () => {
it("should default to 5 minutes", () => {
const manager = SettingsManager.create(projectDir, agentDir);
@ -436,13 +455,16 @@ describe("SettingsManager", () => {
const manager = SettingsManager.create(projectDir, agentDir);
expect(manager.getFullscreenExitOutput()).toBe("transcript");
expect(manager.getFullscreenScrollbar()).toBe("auto");
expect(manager.getFullscreenCopyOnSelect()).toBe(true);
manager.setFullscreenExitOutput("resume-hint");
manager.setFullscreenScrollbar("hidden");
manager.setFullscreenCopyOnSelect(false);
await manager.flush();
const savedSettings = JSON.parse(readFileSync(join(agentDir, "settings.json"), "utf-8"));
expect(savedSettings.fullscreenExitOutput).toBe("resume-hint");
expect(savedSettings.fullscreenScrollbar).toBe("hidden");
expect(savedSettings.fullscreenCopyOnSelect).toBe(false);
writeFileSync(
join(agentDir, "settings.json"),
@ -451,6 +473,7 @@ describe("SettingsManager", () => {
const reloadedManager = SettingsManager.create(projectDir, agentDir);
expect(reloadedManager.getFullscreenExitOutput()).toBe("transcript");
expect(reloadedManager.getFullscreenScrollbar()).toBe("auto");
expect(reloadedManager.getFullscreenCopyOnSelect()).toBe(true);
});
describe("outputPad", () => {

View file

@ -17,9 +17,11 @@ describe("SettingsSelectorComponent", () => {
it("cycles through fullscreen settings", () => {
const onExitOutputChange = vi.fn();
const onScrollbarChange = vi.fn();
const onCopyOnSelectChange = vi.fn();
const config = {
fullscreenExitOutput: "transcript",
fullscreenScrollbar: "auto",
fullscreenCopyOnSelect: true,
warnings: {},
defaultModel: "not set",
availableDefaultModels: [],
@ -30,6 +32,7 @@ describe("SettingsSelectorComponent", () => {
const callbacks = {
onFullscreenExitOutputChange: onExitOutputChange,
onFullscreenScrollbarChange: onScrollbarChange,
onFullscreenCopyOnSelectChange: onCopyOnSelectChange,
} as unknown as SettingsCallbacks;
const cycle = (label: string, count: number) => {
@ -42,5 +45,7 @@ describe("SettingsSelectorComponent", () => {
expect(onExitOutputChange.mock.calls.flat()).toEqual(["resume-hint", "transcript"]);
cycle("Fullscreen scrollbar", 3);
expect(onScrollbarChange.mock.calls.flat()).toEqual(["always", "hidden", "auto"]);
cycle("Fullscreen copy on select", 2);
expect(onCopyOnSelectChange.mock.calls.flat()).toEqual([false, true]);
});
});

View file

@ -0,0 +1,52 @@
import type { ChildProcess } from "node:child_process";
import { EventEmitter } from "node:events";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() }));
vi.mock("child_process", async (importOriginal) => {
const actual = await importOriginal<typeof import("child_process")>();
return { ...actual, spawn: spawnMock };
});
import { killProcessTree } from "../../../src/utils/shell.ts";
function withWindowsPlatform(test: () => void): void {
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
try {
Object.defineProperty(process, "platform", { configurable: true, value: "win32" });
test();
} finally {
if (platformDescriptor) Object.defineProperty(process, "platform", platformDescriptor);
}
}
afterEach(() => {
spawnMock.mockReset();
});
describe("issue #6596 taskkill spawn failures", () => {
it("uses System32 taskkill and consumes its asynchronous spawn error", () => {
const child = new EventEmitter() as ChildProcess;
const previousSystemRoot = process.env.SystemRoot;
process.env.SystemRoot = "C:\\CustomWindows";
spawnMock.mockReturnValue(child);
try {
withWindowsPlatform(() => {
killProcessTree(1234);
});
} finally {
if (previousSystemRoot === undefined) delete process.env.SystemRoot;
else process.env.SystemRoot = previousSystemRoot;
}
expect(spawnMock).toHaveBeenCalledWith(
join("C:\\CustomWindows", "System32", "taskkill.exe"),
["/F", "/T", "/PID", "1234"],
{ detached: true, stdio: "ignore", windowsHide: true },
);
expect(() => child.emit("error", new Error("spawn taskkill ENOENT"))).not.toThrow();
});
});

View file

@ -0,0 +1,144 @@
import type { AgentMessage, AgentTool } from "@earendil-works/pi-agent-core";
import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai";
import { Type } from "typebox";
import { afterEach, describe, expect, it } from "vitest";
import { convertToLlm } from "../../../src/core/messages.ts";
import { createHarness, type Harness } from "../harness.ts";
function roles(messages: AgentMessage[]): string[] {
return messages.map((message) => message.role);
}
describe("#8537 custom messages injected during tool execution", () => {
const harnesses: Harness[] = [];
afterEach(() => {
while (harnesses.length > 0) {
harnesses.pop()?.cleanup();
}
});
it("appends the message after the turn's tool results instead of between call and result", async () => {
let notify: (() => Promise<void>) | undefined;
const slowTool: AgentTool = {
name: "wait",
label: "Wait",
description: "Wait for a background task",
parameters: Type.Object({}),
execute: async () => {
// A background task (e.g. a subagent reply) notifies the session while the
// tool is still running.
await notify?.();
return { content: [{ type: "text", text: "tool done" }], details: {} };
},
};
const harness = await createHarness({ tools: [slowTool] });
harnesses.push(harness);
notify = () =>
harness.session.sendCustomMessage(
{ customType: "subagent-reply", content: "subagent replied", display: true },
{ triggerTurn: false },
);
harness.setResponses([
fauxAssistantMessage([fauxToolCall("wait", {})], { stopReason: "toolUse" }),
fauxAssistantMessage("done"),
]);
await harness.session.prompt("hi");
expect(roles(harness.session.messages)).toEqual(["user", "assistant", "toolResult", "custom", "assistant"]);
});
it("keeps session entries and message events in the same order as agent state", async () => {
let notify: (() => Promise<void>) | undefined;
const slowTool: AgentTool = {
name: "wait",
label: "Wait",
description: "Wait for a background task",
parameters: Type.Object({}),
execute: async () => {
await notify?.();
return { content: [{ type: "text", text: "tool done" }], details: {} };
},
};
const harness = await createHarness({ tools: [slowTool] });
harnesses.push(harness);
notify = () =>
harness.session.sendCustomMessage(
{ customType: "subagent-reply", content: "subagent replied", display: true },
{ triggerTurn: false },
);
harness.setResponses([
fauxAssistantMessage([fauxToolCall("wait", {})], { stopReason: "toolUse" }),
fauxAssistantMessage("done"),
]);
await harness.session.prompt("hi");
const entryKinds = harness.sessionManager
.getBranch()
.flatMap((entry) =>
entry.type === "message" ? [entry.message.role] : entry.type === "custom_message" ? ["custom"] : [],
);
expect(entryKinds).toEqual(["user", "assistant", "toolResult", "custom", "assistant"]);
// message events must never describe a message the session tree does not contain yet
const messageStarts = harness.events.flatMap((event) =>
event.type === "message_start" ? [event.message.role] : [],
);
expect(messageStarts).toEqual(["user", "assistant", "toolResult", "custom", "assistant"]);
});
it("produces an llm history where every tool result follows its tool call", async () => {
let notify: (() => Promise<void>) | undefined;
const slowTool: AgentTool = {
name: "wait",
label: "Wait",
description: "Wait for a background task",
parameters: Type.Object({}),
execute: async () => {
await notify?.();
return { content: [{ type: "text", text: "tool done" }], details: {} };
},
};
const harness = await createHarness({ tools: [slowTool] });
harnesses.push(harness);
notify = () =>
harness.session.sendCustomMessage(
{ customType: "subagent-reply", content: "subagent replied", display: true },
{ triggerTurn: false },
);
harness.setResponses([
fauxAssistantMessage([fauxToolCall("wait", {})], { stopReason: "toolUse" }),
fauxAssistantMessage("done"),
fauxAssistantMessage("second turn"),
]);
await harness.session.prompt("hi");
await harness.session.prompt("and now?");
const llmMessages = convertToLlm(harness.session.messages);
const openToolCallIds = new Set<string>();
for (const message of llmMessages) {
if (message.role === "assistant") {
openToolCallIds.clear();
for (const block of message.content) {
if (block.type === "toolCall") openToolCallIds.add(block.id);
}
continue;
}
if (message.role === "toolResult") {
expect(openToolCallIds.has(message.toolCallId)).toBe(true);
openToolCallIds.delete(message.toolCallId);
continue;
}
openToolCallIds.clear();
}
});
});

View file

@ -0,0 +1,68 @@
import { Container, type TUI } from "@earendil-works/pi-tui";
import { beforeAll, describe, expect, test, vi } from "vitest";
import { ToolExecutionComponent } from "../../../src/modes/interactive/components/tool-execution.ts";
import { InteractiveMode } from "../../../src/modes/interactive/interactive-mode.ts";
import { initTheme } from "../../../src/modes/interactive/theme/theme.ts";
import { stripAnsi } from "../../../src/utils/ansi.ts";
type UpdateThinkingBlockVisibility = (this: { chatContainer: Container; ui: TUI }) => void;
type ToggleThinkingBlockVisibility = (this: {
hideThinkingBlock: boolean;
settingsManager: { setHideThinkingBlock(hidden: boolean): void };
updateThinkingBlockVisibility(): void;
showStatus(message: string): void;
}) => void;
function renderChat(container: Container): string {
return stripAnsi(container.render(120).join("\n"));
}
describe("thinking visibility while a bash tool is running (#8611)", () => {
beforeAll(() => {
initTheme("dark");
});
test("preserves partial bash output", () => {
const ui = { requestRender: vi.fn() } as unknown as TUI;
const chatContainer = new Container();
const component = new ToolExecutionComponent(
"bash",
"tool-8611",
{ command: "echo first; sleep 10" },
{ showImages: false },
undefined,
ui,
process.cwd(),
);
component.markExecutionStarted();
component.updateResult({ content: [{ type: "text", text: "first" }], isError: false }, true);
chatContainer.addChild(component);
const updateThinkingBlockVisibility = Reflect.get(
InteractiveMode.prototype,
"updateThinkingBlockVisibility",
) as UpdateThinkingBlockVisibility;
const toggleThinkingBlockVisibility = Reflect.get(
InteractiveMode.prototype,
"toggleThinkingBlockVisibility",
) as ToggleThinkingBlockVisibility;
const fakeThis = {
hideThinkingBlock: false,
settingsManager: { setHideThinkingBlock: vi.fn() },
chatContainer,
ui,
updateThinkingBlockVisibility() {
updateThinkingBlockVisibility.call(this);
},
showStatus: vi.fn(),
};
expect(renderChat(chatContainer)).toContain("first");
toggleThinkingBlockVisibility.call(fakeThis);
expect(fakeThis.settingsManager.setHideThinkingBlock).toHaveBeenCalledWith(true);
expect(chatContainer.children).toContain(component);
expect(renderChat(chatContainer)).toContain("first");
});
});

View file

@ -2,6 +2,17 @@
## [Unreleased]
### Added
- Added environment and programmatic overrides for OSC 8 hyperlinks, inline image protocols, and truecolor terminal capabilities ([#8665](https://github.com/earendil-works/pi/issues/8665)).
- Added a `TuiAltScreen` `copyOnSelect` option plus helpers to detect and copy the active fullscreen text selection programmatically ([#7720](https://github.com/earendil-works/pi/issues/7720)).
### Fixed
- Fixed main-screen rendering crashing when image-heavy output exceeded V8's string length limit ([#8028](https://github.com/earendil-works/pi/issues/8028)).
- Fixed autocomplete ordering for nested results ([#8669](https://github.com/earendil-works/pi/pull/8669)).
- Fixed fullscreen double-click word selection splitting paths and kebab-case tokens on `/` and `-` ([#7746](https://github.com/earendil-works/pi/issues/7746)).
## [0.84.3] - 2026-08-24
### Fixed

View file

@ -705,7 +705,7 @@ if (matchesKey(data, Key.enter)) {
2. **Width Changed or Change Above Viewport**: Clear screen and fully re-render
3. **Normal Update**: Move the cursor to the first changed line, clear to the end, and render changed lines
`TuiAltScreen` owns a terminal-height viewport. Without an explicit layout root it preserves the legacy single-document scrolling behavior. With `setLayoutRoot()`, `VStack`, `HStack`, and nested `ScrollView` components can reserve fixed regions and independently scroll constrained regions. It updates changed viewport rows in place, follows streaming output while at the bottom, and preserves a manually selected scroll position while content grows. Mouse-wheel and configurable keyboard navigation scroll without modifying terminal scrollback, including jumps between OSC 133 semantic prompt markers. Clicking an OSC 8 hyperlink opens it with the configured URL handler. Dragging with the primary mouse button selects text and copies it to the clipboard with OSC 52; holding the drag at a scroll view's top or bottom edge auto-scrolls and extends the selection into off-screen content. Kitty images support vertical viewport cropping; iTerm2 inline images fall back to text because the iTerm2 protocol cannot delete or crop placements during viewport repainting.
`TuiAltScreen` owns a terminal-height viewport. Without an explicit layout root it preserves the legacy single-document scrolling behavior. With `setLayoutRoot()`, `VStack`, `HStack`, and nested `ScrollView` components can reserve fixed regions and independently scroll constrained regions. It updates changed viewport rows in place, follows streaming output while at the bottom, and preserves a manually selected scroll position while content grows. Mouse-wheel and configurable keyboard navigation scroll without modifying terminal scrollback, including jumps between OSC 133 semantic prompt markers. Clicking an OSC 8 hyperlink opens it with the configured URL handler. Dragging with the primary mouse button selects text and, unless `TuiAltScreenOptions.copyOnSelect` is `false`, copies it to the clipboard with OSC 52; holding the drag at a scroll view's top or bottom edge auto-scrolls and extends the selection into off-screen content. Kitty images support vertical viewport cropping; iTerm2 inline images fall back to text because the iTerm2 protocol cannot delete or crop placements during viewport repainting.
Both renderers wrap updates in **synchronized output** (`\x1b[?2026h` ... `\x1b[?2026l`) for atomic, flicker-free rendering.

View file

@ -127,6 +127,7 @@ async function walkDirectoryWithFd(
query: string,
maxResults: number,
signal: AbortSignal,
maxDepth?: number,
): Promise<Array<{ path: string; isDirectory: boolean }>> {
const args = [
"--base-directory",
@ -147,6 +148,10 @@ async function walkDirectoryWithFd(
".git/**",
];
if (maxDepth !== undefined) {
args.push("--max-depth", String(maxDepth));
}
if (toDisplayPath(query).includes("/")) {
args.push("--full-path");
}
@ -716,6 +721,18 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
return score;
}
private async getBaseDirSuggestions(
baseDir: string,
query: string,
signal: AbortSignal,
): Promise<Array<{ path: string; isDirectory: boolean }>> {
if (!this.fdPath || signal.aborted) {
return [];
}
return await walkDirectoryWithFd(baseDir, this.fdPath, query, 100, signal, 1);
}
// Fuzzy file search using fd (fast, respects .gitignore)
private async getFuzzyFileSuggestions(
query: string,
@ -729,7 +746,17 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
const scopedQuery = this.resolveScopedFuzzyQuery(query);
const fdBaseDir = scopedQuery?.baseDir ?? this.basePath;
const fdQuery = scopedQuery?.query ?? query;
const entries = await walkDirectoryWithFd(fdBaseDir, this.fdPath, fdQuery, 100, options.signal);
const baseDirEntries = await this.getBaseDirSuggestions(fdBaseDir, fdQuery, options.signal);
const recursiveEntries = await walkDirectoryWithFd(fdBaseDir, this.fdPath, fdQuery, 100, options.signal);
const seenPaths = new Set(baseDirEntries.map((entry) => entry.path));
const entries = [
...baseDirEntries,
...recursiveEntries.filter((entry) => {
if (seenPaths.has(entry.path)) return false;
seenPaths.add(entry.path);
return true;
}),
];
if (options.signal.aborted) {
return [];
}
@ -741,7 +768,20 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
}))
.filter((entry) => entry.score > 0);
scoredEntries.sort((a, b) => b.score - a.score);
scoredEntries.sort((a, b) => {
const scoreDiff = b.score - a.score;
if (scoreDiff !== 0) return scoreDiff;
const aDepth = toDisplayPath(a.path).split("/").filter(Boolean).length;
const bDepth = toDisplayPath(b.path).split("/").filter(Boolean).length;
const depthDiff = aDepth - bDepth;
if (depthDiff !== 0) return depthDiff;
const lengthDiff = a.path.length - b.path.length;
if (lengthDiff !== 0) return lengthDiff;
return a.path.localeCompare(b.path);
});
const topEntries = scoredEntries.slice(0, 20);
const suggestions: AutocompleteItem[] = [];

View file

@ -111,6 +111,7 @@ export {
renderImage,
resetCapabilitiesCache,
setCapabilities,
setCapabilityOverrides,
setCellDimensions,
type TerminalCapabilities,
} from "./terminal-image.ts";

View file

@ -32,6 +32,7 @@ export interface ImageRenderOptions {
}
let cachedCapabilities: TerminalCapabilities | null = null;
let capabilityOverrides: Partial<TerminalCapabilities> = {};
// Default cell dimensions - updated by TUI when terminal responds to query
let cellDimensions: CellDimensions = { widthPx: 9, heightPx: 18 };
@ -65,7 +66,7 @@ function probeTmuxHyperlinks(): boolean {
}
}
export function detectCapabilities(tmuxForwardsHyperlink: () => boolean = probeTmuxHyperlinks): TerminalCapabilities {
function detectCapabilitiesFromEnvironment(tmuxForwardsHyperlink: () => boolean): TerminalCapabilities {
const termProgram = process.env.TERM_PROGRAM?.toLowerCase() || "";
const terminalEmulator = process.env.TERMINAL_EMULATOR?.toLowerCase() || "";
const term = process.env.TERM?.toLowerCase() || "";
@ -135,9 +136,38 @@ export function detectCapabilities(tmuxForwardsHyperlink: () => boolean = probeT
return { images: null, trueColor: hasTrueColorHint, hyperlinks: false };
}
function parseBooleanCapabilityOverride(value: string | undefined): boolean | undefined {
return value === "1" ? true : value === "0" ? false : undefined;
}
export function detectCapabilities(tmuxForwardsHyperlink: () => boolean = probeTmuxHyperlinks): TerminalCapabilities {
const hyperlinks = parseBooleanCapabilityOverride(process.env.PI_HYPERLINKS);
const detected = detectCapabilitiesFromEnvironment(
hyperlinks === undefined ? tmuxForwardsHyperlink : () => hyperlinks,
);
const imageProtocol = process.env.PI_IMAGE_PROTOCOL?.toLowerCase();
const images =
imageProtocol === "kitty" || imageProtocol === "iterm2"
? imageProtocol
: imageProtocol === "none" || imageProtocol === "0"
? null
: undefined;
const trueColor = parseBooleanCapabilityOverride(process.env.PI_TRUE_COLOR);
return {
...detected,
...(images !== undefined ? { images } : {}),
...(trueColor !== undefined ? { trueColor } : {}),
...(hyperlinks !== undefined ? { hyperlinks } : {}),
};
}
export function getCapabilities(): TerminalCapabilities {
if (!cachedCapabilities) {
cachedCapabilities = detectCapabilities();
const hyperlinks = capabilityOverrides.hyperlinks;
cachedCapabilities = {
...detectCapabilities(hyperlinks === undefined ? undefined : () => hyperlinks),
...capabilityOverrides,
};
}
return cachedCapabilities;
}
@ -146,6 +176,19 @@ export function resetCapabilitiesCache(): void {
cachedCapabilities = null;
}
/** Override selected auto-detected capabilities. */
export function setCapabilityOverrides(overrides: Partial<TerminalCapabilities>): void {
if (
capabilityOverrides.images === overrides.images &&
capabilityOverrides.trueColor === overrides.trueColor &&
capabilityOverrides.hyperlinks === overrides.hyperlinks
) {
return;
}
capabilityOverrides = { ...overrides };
cachedCapabilities = null;
}
/** Override the cached capabilities. Useful in tests to exercise both code paths. */
export function setCapabilities(caps: TerminalCapabilities): void {
cachedCapabilities = caps;

View file

@ -75,6 +75,9 @@ const MAX_CACHED_OFFSCREEN_KITTY_IMAGES = 16;
const MAX_CACHED_OFFSCREEN_KITTY_TRANSMISSION_BYTES = 32 * 1024 * 1024;
const MAX_CACHED_OFFSCREEN_KITTY_DECODED_BYTES = 64 * 1024 * 1024;
const DOUBLE_CLICK_INTERVAL_MS = 500;
// Regular mode delegates double-click selection to the terminal emulator. Fullscreen owns mouse selection,
// so mirror common terminal word-selection behavior by keeping paths and kebab-case tokens whole.
const TERMINAL_WORD_SELECTION_JOINERS = new Set(["/", "-"]);
const wordSegmenter = getWordSegmenter();
interface CachedKittyImage {
@ -163,6 +166,8 @@ export interface TuiAltScreenOptions {
openUrl?: (url: string) => void;
/** Handle an unmodified secondary-button press for clipboard paste. Currently enabled on Windows only. */
onRightClickPaste?: () => void;
/** Automatically copy selected text to the clipboard on mouse release (default: true). */
copyOnSelect?: boolean;
/**
* Copy selected text to the system clipboard. Return `true` on success; the caller flashes
* an error otherwise. When omitted, the selection is copied via an OSC 52 write.
@ -218,6 +223,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
private readonly searchCurrentMatchStyle: (text: string) => string;
private readonly openUrl?: (url: string) => void;
private readonly onRightClickPaste?: () => void;
private copyOnSelect: boolean;
private readonly copySelection?: (text: string) => Promise<boolean>;
constructor(
@ -242,6 +248,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
this.searchCurrentMatchStyle = options.searchCurrentMatchStyle ?? ((text) => `\x1b[1;7m${text}\x1b[22;27m`);
this.openUrl = options.openUrl;
this.onRightClickPaste = options.onRightClickPaste;
this.copyOnSelect = options.copyOnSelect ?? true;
this.copySelection = options.copySelection;
this.addInputListener((data) => this.handleViewportInput(data));
}
@ -254,6 +261,26 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
return this.getPrimaryScrollView().isFollowingEnd;
}
getCopyOnSelect(): boolean {
return this.copyOnSelect;
}
setCopyOnSelect(enabled: boolean): void {
this.copyOnSelect = enabled;
}
/** Whether the fullscreen viewport has a non-empty active text selection. */
hasActiveSelection(): boolean {
return this.getActiveSelectionText() !== undefined;
}
/** Copy the active fullscreen text selection, if any, using the configured selection clipboard path. */
async copyActiveSelectionToClipboard(): Promise<boolean> {
const text = this.getActiveSelectionText();
if (!text) return false;
return this.copyTextToClipboard(text);
}
setLayoutRoot(component: Component | undefined): void {
if (this.layoutRoot === component) return;
this.layoutRoot = component;
@ -1039,18 +1066,39 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
private getWordSelection(point: SelectionPoint): SelectionRange | undefined {
const line = stripTerminalSequences(this.getSelectionSourceLine(point));
const segments: Array<{ start: number; end: number; selectable: boolean; joiner: boolean }> = [];
let start = 0;
for (const segment of wordSegmenter.segment(line)) {
const end = start + visibleWidth(segment.segment);
if (point.col >= start && point.col < end) {
return {
start: { ...point, col: start },
end: { ...point, col: end, boundary: true },
};
}
const joiner = TERMINAL_WORD_SELECTION_JOINERS.has(segment.segment);
segments.push({ start, end, selectable: segment.isWordLike === true || joiner, joiner });
start = end;
}
return undefined;
const clickedSegmentIndex = segments.findIndex(
(segment) => point.col >= segment.start && point.col < segment.end,
);
if (clickedSegmentIndex < 0) return undefined;
const canJoin = (
left: { selectable: boolean; joiner: boolean },
right: { selectable: boolean; joiner: boolean },
): boolean => left.selectable && right.selectable && (left.joiner || right.joiner);
let selectionStart = segments[clickedSegmentIndex].start;
let selectionEnd = segments[clickedSegmentIndex].end;
for (let index = clickedSegmentIndex; index > 0 && canJoin(segments[index - 1], segments[index]); index--) {
selectionStart = segments[index - 1].start;
}
for (
let index = clickedSegmentIndex;
index < segments.length - 1 && canJoin(segments[index], segments[index + 1]);
index++
) {
selectionEnd = segments[index + 1].end;
}
return {
start: { ...point, col: selectionStart },
end: { ...point, col: selectionEnd, boundary: true },
};
}
private getLineSelection(point: SelectionPoint): SelectionRange {
@ -1203,7 +1251,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
return;
}
}
void this.copySelectionToClipboard();
if (this.copyOnSelect) void this.copySelectionToClipboard();
this.requestRender();
return;
}
@ -1279,14 +1327,14 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
return { start: Math.max(minColumn, start), end: Math.min(maxColumn, end) };
}
private async copySelectionToClipboard(): Promise<void> {
private getActiveSelectionText(): string | undefined {
const selection = this.getSelectionBounds();
if (!selection) return;
if (!selection) return undefined;
let sourceLines: readonly string[] = this.previousScreen;
if (selection.start.scrollView) {
if (!this.currentLayout) return;
if (!this.currentLayout) return undefined;
const box = getScrollViewBox(this.currentLayout, selection.start.scrollView);
if (!box?.scrollContentLines) return;
if (!box?.scrollContentLines) return undefined;
sourceLines = box.scrollContentLines;
}
const lines: string[] = [];
@ -1300,7 +1348,16 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
);
}
const text = lines.join("\n");
if (text.length === 0) return;
return text.length === 0 ? undefined : text;
}
private async copySelectionToClipboard(): Promise<boolean> {
const text = this.getActiveSelectionText();
if (!text) return false;
return this.copyTextToClipboard(text);
}
private async copyTextToClipboard(text: string): Promise<boolean> {
// Prefer an injected clipboard implementation (native clipboard + platform tools with a
// verified success path) when the host app provides one. A bare OSC 52 write can show
// "Copied!" while leaving the system clipboard untouched (e.g. macOS Terminal.app, tmux
@ -1308,10 +1365,11 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
if (this.copySelection) {
const ok = await this.copySelection(text);
this.flash(ok ? "Copied!" : "Copy failed");
return;
return ok;
}
this.terminal.write(`\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`);
this.flash("Copied!");
return true;
}
private applySearchTextHighlight(text: string, current: boolean): string {

View file

@ -5,6 +5,72 @@ import { type TUI, TuiBase, type TuiStopOptions } from "./tui.ts";
import { visibleWidth } from "./utils.ts";
const KITTY_SEQUENCE_PREFIX = "\x1b_G";
const MAX_RENDER_WRITE_CHARS = 1024 * 1024;
/**
* Streams terminal output in 1 MiB chunks so a full render never forms one string large enough to exceed V8's limit.
*
* `append()` fills the current chunk and flushes it when full. Oversized input is split at chunk boundaries, preserving
* surrogate pairs so each write remains valid UTF-16. Callers append synchronized-output begin/end sequences themselves;
* the final `flush()` writes any remainder, including the end sequence.
*/
class BoundedTerminalWriter {
private buffer = "";
private writtenChars = 0;
private readonly write: (data: string) => void;
constructor(write: (data: string) => void) {
this.write = write;
}
/**
* Append terminal data, flushing full chunks as needed. Callers must call `flush()` after the final append.
* @param value Terminal data to write in order; oversized values are split without splitting surrogate pairs.
*/
append(value: string): void {
let offset = 0;
while (offset < value.length) {
const capacity = MAX_RENDER_WRITE_CHARS - this.buffer.length;
if (capacity === 0) {
this.flush();
continue;
}
let end = Math.min(value.length, offset + capacity);
if (
end < value.length &&
value.charCodeAt(end - 1) >= 0xd800 &&
value.charCodeAt(end - 1) <= 0xdbff &&
value.charCodeAt(end) >= 0xdc00 &&
value.charCodeAt(end) <= 0xdfff
) {
end--;
}
if (end === offset) {
this.flush();
continue;
}
this.buffer += value.slice(offset, end);
offset = end;
if (this.buffer.length === MAX_RENDER_WRITE_CHARS) {
this.flush();
}
}
}
/** Write the current chunk, if any, and retain only its character count for debug output. */
flush(): void {
if (!this.buffer) return;
this.write(this.buffer);
this.writtenChars += this.buffer.length;
this.buffer = "";
}
get length(): number {
return this.writtenChars + this.buffer.length;
}
}
interface KittyImageHeader {
ids: number[];
@ -209,30 +275,31 @@ export class TuiMainScreen extends TuiBase implements TUI {
// Helper to clear scrollback and viewport and render all new lines
const fullRender = (clear: boolean): void => {
this.fullRedrawCount += 1;
let buffer = "\x1b[?2026h"; // Begin synchronized output
const output = new BoundedTerminalWriter((data) => this.terminal.write(data));
output.append("\x1b[?2026h"); // Begin synchronized output
if (clear) {
buffer += this.deleteKittyImages(this.previousKittyImageIds);
buffer += "\x1b[2J\x1b[H\x1b[3J"; // Clear screen, home, then clear scrollback
output.append(this.deleteKittyImages(this.previousKittyImageIds));
output.append("\x1b[2J\x1b[H\x1b[3J"); // Clear screen, home, then clear scrollback
}
for (let i = 0; i < newLines.length; i++) {
if (i > 0) buffer += "\r\n";
if (i > 0) output.append("\r\n");
const line = newLines[i];
const isImage = isImageLine(line);
const imageReservedRows = isImage ? this.getKittyImageReservedRows(newLines, i) : 1;
if (imageReservedRows > 1 && imageReservedRows <= height) {
for (let row = 1; row < imageReservedRows; row++) {
buffer += "\r\n";
output.append("\r\n");
}
buffer += `\x1b[${imageReservedRows - 1}A`;
buffer += line;
buffer += `\x1b[${imageReservedRows - 1}B`;
output.append(`\x1b[${imageReservedRows - 1}A`);
output.append(line);
output.append(`\x1b[${imageReservedRows - 1}B`);
i += imageReservedRows - 1;
continue;
}
buffer += line;
output.append(line);
}
buffer += "\x1b[?2026l"; // End synchronized output
this.terminal.write(buffer);
output.append("\x1b[?2026l"); // End synchronized output
output.flush();
this.cursorRow = Math.max(0, newLines.length - 1);
this.hardwareCursorRow = this.cursorRow;
// Reset max lines when clearing, otherwise track growth
@ -331,8 +398,9 @@ export class TuiMainScreen extends TuiBase implements TUI {
// All changes are in deleted lines (nothing to render, just clear)
if (firstChanged >= newLines.length) {
if (this.previousLines.length > newLines.length) {
let buffer = "\x1b[?2026h";
buffer += this.deleteChangedKittyImages(firstChanged, lastChanged);
const output = new BoundedTerminalWriter((data) => this.terminal.write(data));
output.append("\x1b[?2026h");
output.append(this.deleteChangedKittyImages(firstChanged, lastChanged));
// Move to end of new content (clamp to 0 for empty content)
const targetRow = Math.max(0, newLines.length - 1);
if (targetRow < prevViewportTop) {
@ -341,9 +409,9 @@ export class TuiMainScreen extends TuiBase implements TUI {
return;
}
const lineDiff = computeLineDiff(targetRow);
if (lineDiff > 0) buffer += `\x1b[${lineDiff}B`;
else if (lineDiff < 0) buffer += `\x1b[${-lineDiff}A`;
buffer += "\r";
if (lineDiff > 0) output.append(`\x1b[${lineDiff}B`);
else if (lineDiff < 0) output.append(`\x1b[${-lineDiff}A`);
output.append("\r");
// Clear extra lines without scrolling
const extraLines = this.previousLines.length - newLines.length;
if (extraLines > height) {
@ -353,18 +421,18 @@ export class TuiMainScreen extends TuiBase implements TUI {
}
const clearStartOffset = newLines.length === 0 ? 0 : 1;
if (extraLines > 0 && clearStartOffset > 0) {
buffer += `\x1b[${clearStartOffset}B`;
output.append(`\x1b[${clearStartOffset}B`);
}
for (let i = 0; i < extraLines; i++) {
buffer += "\r\x1b[2K";
if (i < extraLines - 1) buffer += "\x1b[1B";
output.append("\r\x1b[2K");
if (i < extraLines - 1) output.append("\x1b[1B");
}
const moveBack = Math.max(0, extraLines - 1 + clearStartOffset);
if (moveBack > 0) {
buffer += `\x1b[${moveBack}A`;
output.append(`\x1b[${moveBack}A`);
}
buffer += "\x1b[?2026l";
this.terminal.write(buffer);
output.append("\x1b[?2026l");
output.flush();
this.cursorRow = targetRow;
this.hardwareCursorRow = targetRow;
}
@ -386,19 +454,20 @@ export class TuiMainScreen extends TuiBase implements TUI {
}
// Render from first changed line to end
// Build buffer with all updates wrapped in synchronized output
let buffer = "\x1b[?2026h"; // Begin synchronized output
buffer += this.deleteChangedKittyImages(firstChanged, lastChanged);
// Keep updates wrapped in synchronized output while writing bounded chunks.
const output = new BoundedTerminalWriter((data) => this.terminal.write(data));
output.append("\x1b[?2026h"); // Begin synchronized output
output.append(this.deleteChangedKittyImages(firstChanged, lastChanged));
const prevViewportBottom = prevViewportTop + height - 1;
const moveTargetRow = appendStart ? firstChanged - 1 : firstChanged;
if (moveTargetRow > prevViewportBottom) {
const currentScreenRow = Math.max(0, Math.min(height - 1, hardwareCursorRow - prevViewportTop));
const moveToBottom = height - 1 - currentScreenRow;
if (moveToBottom > 0) {
buffer += `\x1b[${moveToBottom}B`;
output.append(`\x1b[${moveToBottom}B`);
}
const scroll = moveTargetRow - prevViewportBottom;
buffer += "\r\n".repeat(scroll);
output.append("\r\n".repeat(scroll));
prevViewportTop += scroll;
viewportTop += scroll;
hardwareCursorRow = moveTargetRow;
@ -407,18 +476,18 @@ export class TuiMainScreen extends TuiBase implements TUI {
// Move cursor to first changed line (use hardwareCursorRow for actual position)
const lineDiff = computeLineDiff(moveTargetRow);
if (lineDiff > 0) {
buffer += `\x1b[${lineDiff}B`; // Move down
output.append(`\x1b[${lineDiff}B`); // Move down
} else if (lineDiff < 0) {
buffer += `\x1b[${-lineDiff}A`; // Move up
output.append(`\x1b[${-lineDiff}A`); // Move up
}
buffer += appendStart ? "\r\n" : "\r"; // Move to column 0
output.append(appendStart ? "\r\n" : "\r"); // Move to column 0
// Only render changed lines (firstChanged to lastChanged), not all lines to end
// This reduces flicker when only a single line changes (e.g., spinner animation)
const renderEnd = Math.min(lastChanged, newLines.length - 1);
for (let i = firstChanged; i <= renderEnd; i++) {
if (i > firstChanged) buffer += "\r\n";
if (i > firstChanged) output.append("\r\n");
const line = newLines[i];
const isImage = isImageLine(line);
const imageReservedRows = isImage ? this.getKittyImageReservedRows(newLines, i, renderEnd) : 1;
@ -432,18 +501,18 @@ export class TuiMainScreen extends TuiBase implements TUI {
return;
}
buffer += "\x1b[2K";
output.append("\x1b[2K");
for (let row = 1; row < imageReservedRows; row++) {
buffer += "\r\n\x1b[2K";
output.append("\r\n\x1b[2K");
}
buffer += `\x1b[${imageReservedRows - 1}A`;
buffer += line;
buffer += `\x1b[${imageReservedRows - 1}B`;
output.append(`\x1b[${imageReservedRows - 1}A`);
output.append(line);
output.append(`\x1b[${imageReservedRows - 1}B`);
i += imageReservedRows - 1;
continue;
}
buffer += "\x1b[2K"; // Clear current line
output.append("\x1b[2K"); // Clear current line
if (!isImage && visibleWidth(line) > width) {
// Log all lines to crash file for debugging
const crashLogPath = path.join(this.logDirectory, "pi-crash.log");
@ -472,7 +541,7 @@ export class TuiMainScreen extends TuiBase implements TUI {
].join("\n");
throw new Error(errorMsg);
}
buffer += line;
output.append(line);
}
// Track where cursor ended up after rendering
@ -483,18 +552,18 @@ export class TuiMainScreen extends TuiBase implements TUI {
// Move to end of new content first if we stopped before it
if (renderEnd < newLines.length - 1) {
const moveDown = newLines.length - 1 - renderEnd;
buffer += `\x1b[${moveDown}B`;
output.append(`\x1b[${moveDown}B`);
finalCursorRow = newLines.length - 1;
}
const extraLines = this.previousLines.length - newLines.length;
for (let i = newLines.length; i < this.previousLines.length; i++) {
buffer += "\r\n\x1b[2K";
output.append("\r\n\x1b[2K");
}
// Move cursor back to end of new content
buffer += `\x1b[${extraLines}A`;
output.append(`\x1b[${extraLines}A`);
}
buffer += "\x1b[?2026l"; // End synchronized output
output.append("\x1b[?2026l"); // End synchronized output
if (process.env.PI_TUI_DEBUG === "1") {
const debugDir = "/tmp/tui";
@ -520,13 +589,12 @@ export class TuiMainScreen extends TuiBase implements TUI {
JSON.stringify(this.previousLines, null, 2),
"",
"=== buffer ===",
JSON.stringify(buffer),
`[${output.length} chars written in bounded chunks]`,
].join("\n");
fs.writeFileSync(debugPath, debugData);
}
// Write entire buffer at once
this.terminal.write(buffer);
output.flush();
// Track cursor position for next render
// cursorRow tracks end of content (for viewport calculation)

View file

@ -264,6 +264,42 @@ describe("CombinedAutocompleteProvider", () => {
assert.ok(!values?.includes("@../outside/nested/deeper/zzz.ts"));
});
test("ranks shallower same-score @ matches before deeper matches", async () => {
setupFolder(baseDir, {
dirs: ["scope/aaa/venv/lib/python3.12/site-packages/pkg/core/profile", "scope/projects"],
});
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
const line = "@scope/pro";
const result = await getSuggestions(provider, [line], 0, line.length);
const values = result?.items.map((item) => item.value) ?? [];
assert.strictEqual(values[0], "@scope/projects/");
assert.ok(values.includes("@scope/aaa/venv/lib/python3.12/site-packages/pkg/core/profile/"));
});
test("includes scoped direct children when recursive @ matches are flooded", async () => {
const floodedDirs = Array.from(
{ length: 250 },
(_, index) =>
`scope/a${String(index + 1).padStart(3, "0")}/venv/lib/python3.12/site-packages/pkg/core/profile`,
);
setupFolder(baseDir, {
dirs: ["scope/projects", ...floodedDirs],
});
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
const line = "@scope/pro";
const result = await getSuggestions(provider, [line], 0, line.length);
const values = result?.items.map((item) => item.value) ?? [];
assert.strictEqual(values[0], "@scope/projects/");
assert.ok(
values.some((value) => value.includes("/profile/")),
"Should keep deep fuzzy matches after direct children",
);
});
test("quotes paths with spaces for @ suggestions", async () => {
setupFolder(baseDir, {
dirs: ["my folder"],

View file

@ -15,6 +15,7 @@ import {
detectCapabilities,
encodeITerm2,
encodeKitty,
getCapabilities,
getKittyImageMetadata,
getKittyImagePlacement,
hyperlink,
@ -24,6 +25,7 @@ import {
renderImage,
resetCapabilitiesCache,
setCapabilities,
setCapabilityOverrides,
setCellDimensions,
} from "../src/terminal-image.ts";
import { visibleWidth } from "../src/utils.ts";
@ -42,9 +44,12 @@ const ENV_KEYS = [
"CMUX_WORKSPACE_ID",
"WARP_SESSION_ID",
"WARP_TERMINAL_SESSION_UUID",
"PI_HYPERLINKS",
"PI_IMAGE_PROTOCOL",
"PI_TRUE_COLOR",
] as const;
function withEnv(overrides: Record<string, string | undefined>, fn: () => void): void {
function withEnv<T>(overrides: Record<string, string | undefined>, fn: () => T): T {
const saved: Record<string, string | undefined> = {};
for (const key of ENV_KEYS) {
saved[key] = process.env[key];
@ -55,7 +60,7 @@ function withEnv(overrides: Record<string, string | undefined>, fn: () => void):
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
fn();
return fn();
} finally {
for (const key of ENV_KEYS) {
if (saved[key] === undefined) delete process.env[key];
@ -219,6 +224,63 @@ describe("detectCapabilities", () => {
});
});
it("applies environment overrides", () => {
assert.deepStrictEqual(
withEnv({ PI_HYPERLINKS: "1", PI_IMAGE_PROTOCOL: "kitty", PI_TRUE_COLOR: "1" }, () => detectCapabilities()),
{ images: "kitty", trueColor: true, hyperlinks: true },
);
assert.deepStrictEqual(
withEnv({ TERM_PROGRAM: "iterm.app", PI_HYPERLINKS: "0", PI_IMAGE_PROTOCOL: "none", PI_TRUE_COLOR: "0" }, () =>
detectCapabilities(),
),
{ images: null, trueColor: false, hyperlinks: false },
);
});
it("preserves auto-detection for auto environment overrides", () => {
assert.deepStrictEqual(
withEnv(
{
TERM_PROGRAM: "ghostty",
PI_HYPERLINKS: "auto",
PI_IMAGE_PROTOCOL: "auto",
PI_TRUE_COLOR: "auto",
},
() => detectCapabilities(),
),
{ images: "kitty", trueColor: true, hyperlinks: true },
);
});
it("applies and clears programmatic overrides", () => {
withEnv({ PI_HYPERLINKS: "1", PI_IMAGE_PROTOCOL: "kitty", PI_TRUE_COLOR: "1" }, () => {
setCapabilityOverrides({ images: null, trueColor: false, hyperlinks: false });
try {
assert.deepStrictEqual(getCapabilities(), { images: null, trueColor: false, hyperlinks: false });
setCapabilityOverrides({});
assert.deepStrictEqual(getCapabilities(), { images: "kitty", trueColor: true, hyperlinks: true });
} finally {
setCapabilityOverrides({});
resetCapabilitiesCache();
}
});
});
it("bypasses the tmux probe when hyperlinks are overridden", () => {
let probed = false;
const caps = withEnv(
{ TMUX: "/tmp/tmux-1000/default,1234,0", PI_HYPERLINKS: "1", PI_IMAGE_PROTOCOL: "kitty" },
() =>
detectCapabilities(() => {
probed = true;
return false;
}),
);
assert.strictEqual(probed, false);
assert.strictEqual(caps.hyperlinks, true);
assert.strictEqual(caps.images, "kitty");
});
it("enables hyperlinks under tmux when the client forwards them", () => {
withEnv({ TMUX: "/tmp/tmux-1000/default,1234,0", TERM_PROGRAM: "ghostty" }, () => {
const caps = detectCapabilities(() => true);

View file

@ -1040,6 +1040,67 @@ describe("TuiAltScreen", () => {
tui.stop();
});
it("leaves selections visible without copying when copyOnSelect is disabled", async () => {
const terminal = new RecordingTerminal(20, 4);
const copied: string[] = [];
const tui = new TuiAltScreen(terminal, undefined, undefined, {
copyOnSelect: false,
copySelection: async (text) => {
copied.push(text);
return true;
},
});
tui.addChild(new Text("alpha\nbeta\ngamma\ndelta", 0, 0));
tui.start();
await terminal.waitForRender();
terminal.sendInput("\x1b[<0;1;1M");
terminal.sendInput("\x1b[<32;4;2M");
terminal.sendInput("\x1b[<0;4;2m");
await terminal.waitForRender();
assert.deepStrictEqual(copied, []);
assert.strictEqual(tui.hasActiveSelection(), true);
assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b[7m")));
assert.ok(terminal.getViewport().every((line) => !line.includes("Copied!")));
tui.stop();
});
it("copies an active selection programmatically", async () => {
const terminal = new RecordingTerminal(20, 4);
const copied: string[] = [];
const tui = new TuiAltScreen(terminal, undefined, undefined, {
copySelection: async (text) => {
copied.push(text);
return true;
},
});
tui.addChild(new Text("alpha\nbeta\ngamma\ndelta", 0, 0));
tui.start();
await terminal.waitForRender();
assert.strictEqual(tui.hasActiveSelection(), false);
assert.strictEqual(await tui.copyActiveSelectionToClipboard(), false);
terminal.sendInput("\x1b[<0;1;1M");
terminal.sendInput("\x1b[<32;4;2M");
terminal.sendInput("\x1b[<0;4;2m");
await terminal.waitForRender();
assert.deepStrictEqual(copied, ["alpha\nbeta"]);
assert.strictEqual(tui.hasActiveSelection(), true);
copied.length = 0;
assert.strictEqual(await tui.copyActiveSelectionToClipboard(), true);
await terminal.waitForRender();
assert.deepStrictEqual(copied, ["alpha\nbeta"]);
assert.ok(terminal.getViewport().some((line) => line.includes("Copied!")));
tui.stop();
});
it("flashes an error when the injected copySelection handler fails", async () => {
const terminal = new RecordingTerminal(20, 4);
const tui = new TuiAltScreen(terminal, undefined, undefined, {
@ -1079,6 +1140,35 @@ describe("TuiAltScreen", () => {
tui.stop();
});
it("coalesces slash and hyphen separated segments for double-click word selection", async () => {
for (const { line, needle } of [
{ line: "extensions/starline/fixed-editor/compositor.ts", needle: "starline" },
{ line: "earendil-works/pi-tui", needle: "works" },
]) {
const copied: string[] = [];
const terminal = new RecordingTerminal(80, 1);
const tui = new TuiAltScreen(terminal, undefined, undefined, {
copySelection: async (text) => {
copied.push(text);
return true;
},
});
tui.addChild(new Text(line, 0, 0));
tui.start();
await terminal.waitForRender();
const oneBasedClickColumn = line.indexOf(needle) + 1;
terminal.sendInput(`\x1b[<0;${oneBasedClickColumn};1M`);
terminal.sendInput(`\x1b[<0;${oneBasedClickColumn};1m`);
terminal.sendInput(`\x1b[<0;${oneBasedClickColumn};1M`);
terminal.sendInput(`\x1b[<0;${oneBasedClickColumn};1m`);
await terminal.waitForRender();
assert.deepStrictEqual(copied, [line]);
tui.stop();
}
});
it("highlights a complete whitespace segment during a word drag", async () => {
const terminal = new RecordingTerminal(20, 1);
const tui = new TuiAltScreen(terminal);

View file

@ -5,6 +5,7 @@ import { join } from "node:path";
import { describe, it } from "node:test";
import type { Terminal as XtermTerminalType } from "@xterm/headless";
import { Image } from "../src/components/image.ts";
import type { Terminal } from "../src/terminal.ts";
import {
deleteKittyImage,
encodeKitty,
@ -37,6 +38,30 @@ class InputComponent extends TestComponent {
}
}
const MAX_RENDER_WRITE_CHARS = 1024 * 1024;
class BoundedWriteTerminal implements Terminal {
readonly writes: string[] = [];
columns = 80;
rows = 24;
readonly kittyProtocolActive = false;
start(_onInput: (data: string) => void, _onResize: () => void): void {}
stop(): void {}
async drainInput(_maxMs?: number, _idleMs?: number): Promise<void> {}
write(data: string): void {
this.writes.push(data);
}
moveBy(_lines: number): void {}
hideCursor(): void {}
showCursor(): void {}
clearLine(): void {}
clearFromCursor(): void {}
clearScreen(): void {}
setTitle(_title: string): void {}
setProgress(_active: boolean): void {}
}
class LoggingVirtualTerminal extends VirtualTerminal {
private writes: string[] = [];
@ -136,6 +161,51 @@ describe("TUI debug logging", () => {
});
});
describe("TUI bounded render output", () => {
it("splits a large full render without changing its output", () => {
const terminal = new BoundedWriteTerminal();
const tui = new TuiMainScreen(terminal);
const component = new TestComponent();
const kittyLine = `\x1b_Ga=T,f=100;${"A".repeat(1_200_000)}\x1b\\`;
component.lines = [kittyLine, kittyLine];
tui.addChild(component);
tui.renderNow();
assert.ok(terminal.writes.length > 2, "large output should be split across terminal writes");
assert.ok(
terminal.writes.every((write) => write.length <= MAX_RENDER_WRITE_CHARS),
"each terminal write should stay below the configured limit",
);
assert.strictEqual(
terminal.writes.join(""),
`\x1b[?2026h${kittyLine}\r\n${kittyLine}\x1b[?2026l`,
"chunking must preserve the synchronized render output",
);
});
it("splits large differential updates without a full redraw", () => {
const terminal = new BoundedWriteTerminal();
const tui = new TuiMainScreen(terminal);
const component = new TestComponent();
tui.addChild(component);
component.lines = ["before"];
tui.renderNow();
terminal.writes.length = 0;
const kittyLine = `\x1b_Ga=T,f=100;${"A".repeat(1_200_000)}\x1b\\`;
component.lines = ["before", kittyLine, kittyLine];
tui.renderNow();
assert.ok(terminal.writes.length > 2, "large output should be split across terminal writes");
assert.ok(terminal.writes.every((write) => write.length <= MAX_RENDER_WRITE_CHARS));
const output = terminal.writes.join("");
assert.ok(output.startsWith("\x1b[?2026h"));
assert.ok(output.endsWith("\x1b[?2026l"));
assert.ok(!output.includes("\x1b[2J"), "the update should stay on the differential render path");
});
});
describe("TUI Kitty image cleanup", () => {
it("clears reserved Kitty image rows before drawing appended image placements", async () => {
setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true });

View file

@ -49,6 +49,30 @@ export function createJiti(...args) {
},
};
const httpsProxyAgentNamedExportPlugin = {
name: "https-proxy-agent-named-export",
setup(build) {
build.onResolve({ filter: /^https-proxy-agent$/ }, (args) => {
if (args.kind !== "dynamic-import") return undefined;
return {
namespace: "https-proxy-agent-named-export",
path: args.path,
};
});
build.onLoad(
{
filter: /^https-proxy-agent$/,
namespace: "https-proxy-agent-named-export",
},
() => ({
contents: 'export { HttpsProxyAgent } from "https-proxy-agent";',
loader: "js",
resolveDir: repoRoot,
}),
);
},
};
function commonBuildOptions() {
return {
absWorkingDir: repoRoot,
@ -67,7 +91,7 @@ function commonBuildOptions() {
// package replaces it with a synchronous lazy require so jiti loads only
// when importing an extension; Babel remains deferred until a cache miss
// needs transformation.
plugins: [lazyJitiPlugin],
plugins: [lazyJitiPlugin, httpsProxyAgentNamedExportPlugin],
sourcemap: false,
target: "node22.19",
// Do not apply the monorepo's source-oriented path aliases while bundling