From b7170b86a609028618cd2efe9b31f3f2b347ad4d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 25 Aug 2026 08:13:02 +0000 Subject: [PATCH 01/31] chore: approve contributors from issue #8388 --- .github/APPROVED_CONTRIBUTORS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index fb8384f18..5b42a4e20 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -375,3 +375,5 @@ gaoyk19 pr cad0p pr Jaaneek pr + +CaiJichang212 pr From c5de2cc67f04d2e700617f9452a22a4242aaa1a4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 25 Aug 2026 08:17:15 +0000 Subject: [PATCH 02/31] chore: approve contributors from issue #8409 --- .github/APPROVED_CONTRIBUTORS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index 5b42a4e20..f8a343b03 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -377,3 +377,5 @@ cad0p pr Jaaneek pr CaiJichang212 pr + +Mallikarjun-0 pr From e8c632ef64f99b0d2ce7cc7c215aecb5c66b0016 Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:03:13 +0200 Subject: [PATCH 03/31] fix(ai): cloudflare gateway type, include workers Fixes https://github.com/earendil-works/pi/actions/runs/32825925694/job/97733792662?pr=8605 build error --- packages/ai/scripts/generate-models.ts | 37 +++++++++++++++++++ .../ai/src/providers/cloudflare-ai-gateway.ts | 8 ++-- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index d91c99916..9bd3396ce 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -1666,6 +1666,7 @@ async function loadModelsDevData(): Promise[]> { } // Process Cloudflare AI Gateway models + const cloudflareAIGatewayModelIds = new Set(); if (data["cloudflare-ai-gateway"]?.models) { for (const [prefixedId, model] of Object.entries(data["cloudflare-ai-gateway"].models)) { const m = model as ModelsDevModel; @@ -1700,6 +1701,7 @@ async function loadModelsDevData(): Promise[]> { const compat = upstream === "anthropic" || upstream === "workers-ai" ? { sendSessionAffinityHeaders: true } : undefined; + cloudflareAIGatewayModelIds.add(id); models.push({ id, name: m.name || id, @@ -1722,6 +1724,41 @@ async function loadModelsDevData(): Promise[]> { } } + // models.dev may omit Workers AI passthroughs from the AI Gateway provider + // list even though the gateway /compat endpoint supports routing to them. + // Mirror the Workers AI catalog under the documented workers-ai/ prefix so + // the gateway keeps its OpenAI-compatible /compat 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 (cloudflareAIGatewayModelIds.has(id)) continue; + cloudflareAIGatewayModelIds.add(id); + + models.push({ + id, + name: m.name || id, + api: "openai-completions", + provider: "cloudflare-ai-gateway", + baseUrl: CLOUDFLARE_AI_GATEWAY_COMPAT_BASE_URL, + reasoning: m.reasoning === true, + input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"], + cost: { + input: m.cost?.input || 0, + output: m.cost?.output || 0, + cacheRead: m.cost?.cache_read || 0, + cacheWrite: m.cost?.cache_write || 0, + }, + contextWindow: m.limit?.context || 4096, + maxTokens: m.limit?.output || 4096, + compat: { sendSessionAffinityHeaders: true }, + }); + recordModelsDevReasoningOptions("cloudflare-ai-gateway", id, m); + } + } + // Process xAi models if (data.xai?.models) { for (const [modelId, model] of Object.entries(data.xai.models)) { diff --git a/packages/ai/src/providers/cloudflare-ai-gateway.ts b/packages/ai/src/providers/cloudflare-ai-gateway.ts index 50c10569f..7c1cc3b78 100644 --- a/packages/ai/src/providers/cloudflare-ai-gateway.ts +++ b/packages/ai/src/providers/cloudflare-ai-gateway.ts @@ -6,10 +6,10 @@ 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" -> { - return createProvider({ +type CloudflareAIGatewayApi = "anthropic-messages" | "openai-completions" | "openai-responses"; + +export function cloudflareAIGatewayProvider(): Provider { + return createProvider({ id: "cloudflare-ai-gateway", name: "Cloudflare AI Gateway", auth: { apiKey: cloudflareAIGatewayAuth() }, From 5cd6a2a502a723398326ef0bfaa62a83dd90065f Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:12:15 +0200 Subject: [PATCH 04/31] fix(ai-test): update glm 5.3 price --- packages/ai/test/zai-coding-plan-models.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/ai/test/zai-coding-plan-models.test.ts b/packages/ai/test/zai-coding-plan-models.test.ts index 5ed6658dc..2d0ef25b3 100644 --- a/packages/ai/test/zai-coding-plan-models.test.ts +++ b/packages/ai/test/zai-coding-plan-models.test.ts @@ -41,6 +41,14 @@ it("uses API-equivalent reference costs for Coding Plan models", () => { cacheRead: 0.24, 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", () => { @@ -48,6 +56,5 @@ it("keeps zero costs for Coding Plan models without a matching API price", () => for (const provider of ["zai", "zai-coding-cn"] as const) { expect(getBuiltinModel(provider, "glm-5.2-highspeed").cost).toEqual(zeroCost); - expect(getBuiltinModel(provider, "glm-5.3").cost).toEqual(zeroCost); } }); From c5ad7c1b0f7623bbfdf64dd4967fa6e99c15c01a Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:19:31 +0200 Subject: [PATCH 05/31] fix(ai): concatenate openai completions reasoning deltas (#8605) --- packages/ai/src/api/openai-completions.ts | 32 ++++++++- ...enai-completions-reasoning-details.test.ts | 67 +++++++++++++++++++ 2 files changed, 96 insertions(+), 3 deletions(-) diff --git a/packages/ai/src/api/openai-completions.ts b/packages/ai/src/api/openai-completions.ts index 08b181f23..04326ca2f 100644 --- a/packages/ai/src/api/openai-completions.ts +++ b/packages/ai/src/api/openai-completions.ts @@ -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]; @@ -623,9 +648,10 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio 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. + appendOpenAIReasoningDetail(preservedDetails, detail); + // 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. block.thinkingSignature = JSON.stringify(preservedDetails); } } diff --git a/packages/ai/test/openai-completions-reasoning-details.test.ts b/packages/ai/test/openai-completions-reasoning-details.test.ts index 723a51b79..a76c3b1dd 100644 --- a/packages/ai/test/openai-completions-reasoning-details.test.ts +++ b/packages/ai/test/openai-completions-reasoning-details.test.ts @@ -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); + }); }); From de82e536738f10fda8e53404d2968241f3fcd96a Mon Sep 17 00:00:00 2001 From: Alexey Zaytsev Date: Tue, 25 Aug 2026 06:39:16 -0300 Subject: [PATCH 06/31] feat(coding-agent): export image MIME detector (#8600) --- packages/coding-agent/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 210b84010..52d8f9b06 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -415,5 +415,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"; From cacb5917f9712d7e80807f540b7dd70885422bf6 Mon Sep 17 00:00:00 2001 From: PiedPiper911 <32931126+PiedPiper911@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:43:36 +0800 Subject: [PATCH 07/31] fix: export ToolExecution*Event types from public API (#6847) Add missing ToolExecutionEndEvent, ToolExecutionStartEvent, and ToolExecutionUpdateEvent type exports to packages/coding-agent/src/index.ts so they are available to consumers of the package. Fixes #6687 From 45207864febb4658c294d075e761533a9b2f79f2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 25 Aug 2026 09:47:58 +0000 Subject: [PATCH 08/31] chore: approve contributors from issue #8574 --- .github/APPROVED_CONTRIBUTORS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index f8a343b03..aa90960e2 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -379,3 +379,5 @@ Jaaneek pr CaiJichang212 pr Mallikarjun-0 pr + +wutongyuonce pr From c1729a0f7532d553cf590da3958ad0c18162f770 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 25 Aug 2026 09:54:56 +0000 Subject: [PATCH 09/31] chore: approve contributors from issue #8594 --- .github/APPROVED_CONTRIBUTORS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index aa90960e2..37dc4fee8 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -381,3 +381,5 @@ CaiJichang212 pr Mallikarjun-0 pr wutongyuonce pr + +Terminator666666 pr From 650e7a6126d4f45a7bd4457b198fcdcc6fcf98c1 Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Tue, 25 Aug 2026 12:11:21 +0200 Subject: [PATCH 10/31] fix(ai): derive OpenRouter reasoning controls (#8614) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #8454 Co-authored-by: Petr Houška --- packages/ai/CHANGELOG.md | 1 + packages/ai/scripts/generate-models.ts | 29 ++++- .../scripts/openrouter-reasoning-options.ts | 23 ++++ .../test/openrouter-reasoning-options.test.ts | 109 ++++++++++++++++++ 4 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 packages/ai/scripts/openrouter-reasoning-options.ts create mode 100644 packages/ai/test/openrouter-reasoning-options.test.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 6cc3e74da..2a035edf3 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -34,6 +34,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 diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 9bd3396ce..51b1f95af 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -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[]> { 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[] = []; - 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[]> { 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 = { id: modelKey, @@ -1086,6 +1110,7 @@ async function fetchOpenRouterModels(): Promise[]> { baseUrl: "https://openrouter.ai/api/v1", provider, reasoning: model.supported_parameters?.includes("reasoning") || false, + ...(thinkingLevelMap && { thinkingLevelMap }), input, cost: { input: inputCost, diff --git a/packages/ai/scripts/openrouter-reasoning-options.ts b/packages/ai/scripts/openrouter-reasoning-options.ts new file mode 100644 index 000000000..6fc4a5c6c --- /dev/null +++ b/packages/ai/scripts/openrouter-reasoning-options.ts @@ -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; + 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" }; +} diff --git a/packages/ai/test/openrouter-reasoning-options.test.ts b/packages/ai/test/openrouter-reasoning-options.test.ts new file mode 100644 index 000000000..20eb8ba7a --- /dev/null +++ b/packages/ai/test/openrouter-reasoning-options.test.ts @@ -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" }, + }); + }); +}); From 240eb29c41b2f5c6e7fcab80bc2b090d871ccaad Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 25 Aug 2026 12:13:50 +0200 Subject: [PATCH 11/31] fix(coding-agent): append run-time custom messages after the turn's tool results Extension messages sent with triggerTurn: false while the agent was running were pushed into agent state and the session tree immediately, landing between an assistant tool call and its result. Providers that validate message order reject the replayed history. Queue them during the run and append at turn_end, mirroring recordBashResult. closes #8537 --- packages/coding-agent/CHANGELOG.md | 4 + .../coding-agent/src/core/agent-session.ts | 60 ++++++-- ...ustom-message-tool-result-ordering.test.ts | 144 ++++++++++++++++++ 3 files changed, 197 insertions(+), 11 deletions(-) create mode 100644 packages/coding-agent/test/suite/regressions/8537-custom-message-tool-result-ordering.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d3885a628..491aa86b4 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- 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)). + ## [0.84.3] - 2026-08-24 ### New Features diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 488f5e0ae..60e278d34 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -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): 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); } } diff --git a/packages/coding-agent/test/suite/regressions/8537-custom-message-tool-result-ordering.test.ts b/packages/coding-agent/test/suite/regressions/8537-custom-message-tool-result-ordering.test.ts new file mode 100644 index 000000000..161f10da6 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/8537-custom-message-tool-result-ordering.test.ts @@ -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) | 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) | 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) | 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(); + 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(); + } + }); +}); From fe37e9f9b5fb2e7bd9ff504e678f08d115375230 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Tue, 25 Aug 2026 12:26:06 +0200 Subject: [PATCH 12/31] fix(ai): omit tool_choice without tools closes #8607 --- packages/ai/CHANGELOG.md | 4 ++++ packages/ai/src/api/openai-completions.ts | 2 +- .../openai-completions-tool-choice.test.ts | 24 +++++++++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 2a035edf3..2570bac1d 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed OpenAI-compatible Chat Completions requests sending `tool_choice` without tools, which gateways can reject during compaction ([#8607](https://github.com/earendil-works/pi/issues/8607)). + ## [0.84.3] - 2026-08-24 ### Breaking Changes diff --git a/packages/ai/src/api/openai-completions.ts b/packages/ai/src/api/openai-completions.ts index 04326ca2f..0529db628 100644 --- a/packages/ai/src/api/openai-completions.ts +++ b/packages/ai/src/api/openai-completions.ts @@ -835,7 +835,7 @@ function buildParams( applyAnthropicCacheControl(messages, params.tools, cacheControl); } - if (options?.toolChoice) { + if (options?.toolChoice && params.tools?.length) { params.tool_choice = options.toolChoice; } diff --git a/packages/ai/test/openai-completions-tool-choice.test.ts b/packages/ai/test/openai-completions-tool-choice.test.ts index c453f3d54..35be54538 100644 --- a/packages/ai/test/openai-completions-tool-choice.test.ts +++ b/packages/ai/test/openai-completions-tool-choice.test.ts @@ -150,6 +150,30 @@ describe("openai-completions tool_choice", () => { expect(params.tools?.length ?? 0).toBeGreaterThan(0); }); + it("omits 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).not.toHaveProperty("tool_choice"); + 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 = { From 1bb61986cd6b12d0e62dda3af9bc73b2ab63d34c Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:31:42 +0200 Subject: [PATCH 13/31] docs(coding-agent): update provider links to api --- packages/coding-agent/docs/custom-provider.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/coding-agent/docs/custom-provider.md b/packages/coding-agent/docs/custom-provider.md index cdf823eb0..7c96e58a4 100644 --- a/packages/coding-agent/docs/custom-provider.md +++ b/packages/coding-agent/docs/custom-provider.md @@ -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 From 587be985a093cc74ff24a6e7a8f0f4d84fa1e7b2 Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:57:20 +0200 Subject: [PATCH 14/31] feat(ai): add deepseek-v4-flash-vision-exp --- packages/ai/scripts/generate-models.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 51b1f95af..d3a3a3055 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -2567,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", From a79b3733421ead0dcea3cbe32247ea2464400dcb Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Tue, 25 Aug 2026 15:19:49 +0200 Subject: [PATCH 15/31] feat(coding-agent): expose RPC queue clearing closes #8432 --- packages/coding-agent/CHANGELOG.md | 4 ++ packages/coding-agent/docs/rpc.md | 23 ++++++++ .../coding-agent/src/modes/rpc/rpc-client.ts | 8 +++ .../coding-agent/src/modes/rpc/rpc-mode.ts | 4 ++ .../coding-agent/src/modes/rpc/rpc-types.ts | 8 +++ .../test/rpc-client-clear-queue.test.ts | 29 ++++++++++ .../rpc-prompt-response-semantics.test.ts | 54 +++++++++++++++++++ 7 files changed, 130 insertions(+) create mode 100644 packages/coding-agent/test/rpc-client-clear-queue.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 491aa86b4..381fb7e8a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added RPC `clear_queue` to retrieve and remove queued steering and follow-up messages ([#8432](https://github.com/earendil-works/pi/issues/8432)). + ### Fixed - 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)). diff --git a/packages/coding-agent/docs/rpc.md b/packages/coding-agent/docs/rpc.md index f92c12e8e..52dbf884f 100644 --- a/packages/coding-agent/docs/rpc.md +++ b/packages/coding-agent/docs/rpc.md @@ -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. diff --git a/packages/coding-agent/src/modes/rpc/rpc-client.ts b/packages/coding-agent/src/modes/rpc/rpc-client.ts index a4cc4b208..8b8b8cc38 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-client.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-client.ts @@ -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 diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 1b5d8a656..fc8083bed 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -430,6 +430,10 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise Promise; + getData: (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 = (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"] }); + }); +}); diff --git a/packages/coding-agent/test/rpc-prompt-response-semantics.test.ts b/packages/coding-agent/test/rpc-prompt-response-semantics.test.ts index 698773d0d..bdc47b4a5 100644 --- a/packages/coding-agent/test/rpc-prompt-response-semantics.test.ts +++ b/packages/coding-agent/test/rpc-prompt-response-semantics.test.ts @@ -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(); + } + }); }); From 8fa7eebd235355522c8104166b4f1f959b4e2f10 Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:49:28 +0200 Subject: [PATCH 16/31] fix: persist default to scoped if non-empty --- .../coding-agent/src/core/agent-session.ts | 17 +++ .../src/modes/interactive/interactive-mode.ts | 1 + .../coding-agent/test/model-resolver.test.ts | 101 +++++++++++++++++- 3 files changed, 118 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 60e278d34..ea69b84ef 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -1644,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. @@ -1654,6 +1655,20 @@ export class AgentSession { await this._emitModelSelect(model, previousModel, "set"); } + private _addPersistedDefaultToNonEmptyScope(model: Model): 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. @@ -1696,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. @@ -1730,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. diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index acf96ae2f..e9d888a6c 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -4960,6 +4960,7 @@ export class InteractiveMode { const selectModel = async (model: Model, persist: boolean) => { try { await this.session.setModel(model, { persist }); + this.updateAvailableProviderCount(); this.footer.invalidate(); this.updateEditorBorderColor(); done(); diff --git a/packages/coding-agent/test/model-resolver.test.ts b/packages/coding-agent/test/model-resolver.test.ts index 6488b7ae8..8f37b7054 100644 --- a/packages/coding-agent/test/model-resolver.test.ts +++ b/packages/coding-agent/test/model-resolver.test.ts @@ -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}`]); + }); + }); }); From 7af2d27dced8f1f2aff3a678f2e7bc748693dfef Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Wed, 26 Aug 2026 10:33:10 +0200 Subject: [PATCH 17/31] fix(coding-agent): prevent Windows taskkill spawn crashes closes #6596 --- packages/agent/CHANGELOG.md | 4 ++ packages/agent/src/harness/env/nodejs.ts | 16 ++++-- .../agent/test/harness/nodejs-env.test.ts | 42 +++++++++++++++ packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/utils/shell.ts | 22 +++++--- .../regressions/6596-taskkill-enoent.test.ts | 52 +++++++++++++++++++ 6 files changed, 124 insertions(+), 13 deletions(-) create mode 100644 packages/coding-agent/test/suite/regressions/6596-taskkill-enoent.test.ts diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index c057c3228..de33d4554 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed Windows `NodeExecutionEnv` aborts crashing when `taskkill.exe` is unavailable on `PATH` ([#6596](https://github.com/earendil-works/pi/issues/6596)). + ## [0.84.3] - 2026-08-24 ### Fixed diff --git a/packages/agent/src/harness/env/nodejs.ts b/packages/agent/src/harness/env/nodejs.ts index 1225efb92..09bff9bec 100644 --- a/packages/agent/src/harness/env/nodejs.ts +++ b/packages/agent/src/harness/env/nodejs.ts @@ -253,11 +253,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. } diff --git a/packages/agent/test/harness/nodejs-env.test.ts b/packages/agent/test/harness/nodejs-env.test.ts index becaa8fc3..4595ea30f 100644 --- a/packages/agent/test/harness/nodejs-env.test.ts +++ b/packages/agent/test/harness/nodejs-env.test.ts @@ -496,6 +496,48 @@ 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`, { + abortSignal: controller.signal, + }); + 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 }); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 381fb7e8a..5cc12fd66 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed +- Fixed Windows shell aborts crashing Pi when `taskkill.exe` is unavailable on `PATH` ([#6596](https://github.com/earendil-works/pi/issues/6596)). - 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)). ## [0.84.3] - 2026-08-24 diff --git a/packages/coding-agent/src/utils/shell.ts b/packages/coding-agent/src/utils/shell.ts index fae75267e..c352a3482 100644 --- a/packages/coding-agent/src/utils/shell.ts +++ b/packages/coding-agent/src/utils/shell.ts @@ -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 diff --git a/packages/coding-agent/test/suite/regressions/6596-taskkill-enoent.test.ts b/packages/coding-agent/test/suite/regressions/6596-taskkill-enoent.test.ts new file mode 100644 index 000000000..c5e47f98e --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/6596-taskkill-enoent.test.ts @@ -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(); + 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(); + }); +}); From 6b36eb5927c495f8a6f1421acf588f67d1722844 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Wed, 26 Aug 2026 11:10:28 +0200 Subject: [PATCH 18/31] fix(coding-agent): remove explicit tool choice from compaction calls Fixes #8649\n\nFixes #8638 --- packages/ai/CHANGELOG.md | 2 +- packages/ai/src/api/openai-completions.ts | 2 +- packages/ai/src/types.ts | 2 +- packages/ai/test/openai-completions-tool-choice.test.ts | 4 ++-- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/core/compaction/compaction.ts | 1 - packages/coding-agent/test/branch-summarization.test.ts | 4 ++-- .../coding-agent/test/compaction-summary-reasoning.test.ts | 5 ++--- 8 files changed, 10 insertions(+), 11 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 2570bac1d..790c432c9 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -4,7 +4,7 @@ ### Fixed -- Fixed OpenAI-compatible Chat Completions requests sending `tool_choice` without tools, which gateways can reject during compaction ([#8607](https://github.com/earendil-works/pi/issues/8607)). +- Fixed OpenAI-compatible Chat Completions ignoring an explicitly requested `toolChoice` when no tools are defined. ## [0.84.3] - 2026-08-24 diff --git a/packages/ai/src/api/openai-completions.ts b/packages/ai/src/api/openai-completions.ts index 0529db628..04326ca2f 100644 --- a/packages/ai/src/api/openai-completions.ts +++ b/packages/ai/src/api/openai-completions.ts @@ -835,7 +835,7 @@ function buildParams( applyAnthropicCacheControl(messages, params.tools, cacheControl); } - if (options?.toolChoice && params.tools?.length) { + if (options?.toolChoice) { params.tool_choice = options.toolChoice; } diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index d35a48b84..a17e3b775 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -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. */ diff --git a/packages/ai/test/openai-completions-tool-choice.test.ts b/packages/ai/test/openai-completions-tool-choice.test.ts index 35be54538..c8e03b6df 100644 --- a/packages/ai/test/openai-completions-tool-choice.test.ts +++ b/packages/ai/test/openai-completions-tool-choice.test.ts @@ -150,7 +150,7 @@ describe("openai-completions tool_choice", () => { expect(params.tools?.length ?? 0).toBeGreaterThan(0); }); - it("omits toolChoice when no tools are provided", async () => { + 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; @@ -170,7 +170,7 @@ describe("openai-completions tool_choice", () => { ).result(); const params = (payload ?? mockState.lastParams) as { tool_choice?: string; tools?: unknown[] }; - expect(params).not.toHaveProperty("tool_choice"); + expect(params.tool_choice).toBe("none"); expect(params).not.toHaveProperty("tools"); }); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5cc12fd66..d6a0d2290 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -10,6 +10,7 @@ - Fixed Windows shell aborts crashing Pi when `taskkill.exe` is unavailable on `PATH` ([#6596](https://github.com/earendil-works/pi/issues/6596)). - 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)). ## [0.84.3] - 2026-08-24 diff --git a/packages/coding-agent/src/core/compaction/compaction.ts b/packages/coding-agent/src/core/compaction/compaction.ts index cd442d4aa..6ced927ca 100644 --- a/packages/coding-agent/src/core/compaction/compaction.ts +++ b/packages/coding-agent/src/core/compaction/compaction.ts @@ -590,7 +590,6 @@ export async function completeSummarization( ...options, cacheRetention: "none", sessionId: options.sessionId ?? uuidv7(), - toolChoice: "none", }; const produce = async (): Promise => streamFn diff --git a/packages/coding-agent/test/branch-summarization.test.ts b/packages/coding-agent/test/branch-summarization.test.ts index e54da181f..7018e969f 100644 --- a/packages/coding-agent/test/branch-summarization.test.ts +++ b/packages/coding-agent/test/branch-summarization.test.ts @@ -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 () => { diff --git a/packages/coding-agent/test/compaction-summary-reasoning.test.ts b/packages/coding-agent/test/compaction-summary-reasoning.test.ts index 5a4d8e86b..e57b4ebb9 100644 --- a/packages/coding-agent/test/compaction-summary-reasoning.test.ts +++ b/packages/coding-agent/test/compaction-summary-reasoning.test.ts @@ -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", }); }); From b37ebb7f22ec1a8fbf882366dc20ae6d6010a6e2 Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:03:41 +0200 Subject: [PATCH 19/31] fix(tui): autocomplete orders nested results (#8669) --- packages/tui/src/autocomplete.ts | 44 ++++++++++++++++++++++++-- packages/tui/test/autocomplete.test.ts | 36 +++++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/packages/tui/src/autocomplete.ts b/packages/tui/src/autocomplete.ts index 205748d88..7a072d981 100644 --- a/packages/tui/src/autocomplete.ts +++ b/packages/tui/src/autocomplete.ts @@ -127,6 +127,7 @@ async function walkDirectoryWithFd( query: string, maxResults: number, signal: AbortSignal, + maxDepth?: number, ): Promise> { 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> { + 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[] = []; diff --git a/packages/tui/test/autocomplete.test.ts b/packages/tui/test/autocomplete.test.ts index be27d152b..db9ac67a1 100644 --- a/packages/tui/test/autocomplete.test.ts +++ b/packages/tui/test/autocomplete.test.ts @@ -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"], From 0b5ee5d8b316a6c0c031360ec226e2b3ec843276 Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Wed, 26 Aug 2026 12:07:30 +0200 Subject: [PATCH 20/31] fix(coding-agent): repair unterminated session files Append a newline after loading a valid session so subsequent entries cannot fuse with an unterminated tail.\n\nFixes #8345 --- packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/core/session-manager.ts | 5 ++-- .../session-manager/file-operations.test.ts | 30 +++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d6a0d2290..34a48e6cd 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixed - 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)). diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index f8db9b0e5..8cad6fe39 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -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; } diff --git a/packages/coding-agent/test/session-manager/file-operations.test.ts b/packages/coding-agent/test/session-manager/file-operations.test.ts index c978b3200..40f88131b 100644 --- a/packages/coding-agent/test/session-manager/file-operations.test.ts +++ b/packages/coding-agent/test/session-manager/file-operations.test.ts @@ -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"], From 7aab6c26e53c82e63f369b497552849aff36f903 Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:51:09 +0200 Subject: [PATCH 21/31] fix(ai): serialize thinking signature once (#8671) --- packages/ai/src/api/openai-completions.ts | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/ai/src/api/openai-completions.ts b/packages/ai/src/api/openai-completions.ts index 04326ca2f..017810061 100644 --- a/packages/ai/src/api/openai-completions.ts +++ b/packages/ai/src/api/openai-completions.ts @@ -329,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); @@ -419,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, @@ -646,13 +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) ?? []; - appendOpenAIReasoningDetail(preservedDetails, detail); + 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. - block.thinkingSignature = JSON.stringify(preservedDetails); + appendOpenAIReasoningDetail(streamedReasoningDetails, detail); } } } @@ -682,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; From 6c4f360264397c59801f6da2bdac13e3b1fcbe91 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Wed, 26 Aug 2026 13:08:26 +0200 Subject: [PATCH 22/31] fix(tui): chunk large main-screen renders closes #8028 --- packages/tui/CHANGELOG.md | 4 + packages/tui/src/tui-main-screen.ts | 158 +++++++++++++++++++-------- packages/tui/test/tui-render.test.ts | 70 ++++++++++++ 3 files changed, 187 insertions(+), 45 deletions(-) diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index cc92212ee..d57e4dddf 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### 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)). + ## [0.84.3] - 2026-08-24 ### Fixed diff --git a/packages/tui/src/tui-main-screen.ts b/packages/tui/src/tui-main-screen.ts index bb2d6785d..6d7981137 100644 --- a/packages/tui/src/tui-main-screen.ts +++ b/packages/tui/src/tui-main-screen.ts @@ -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) diff --git a/packages/tui/test/tui-render.test.ts b/packages/tui/test/tui-render.test.ts index a6aab959f..e047473ba 100644 --- a/packages/tui/test/tui-render.test.ts +++ b/packages/tui/test/tui-render.test.ts @@ -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 {} + 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 }); From b07e17faa2d4a08598b3986a5616ebfc4fcd5550 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Wed, 26 Aug 2026 14:27:21 +0200 Subject: [PATCH 23/31] fix(coding-agent): preserve partial Bash output when toggling thinking closes #8611 --- packages/coding-agent/CHANGELOG.md | 1 + .../src/modes/interactive/interactive-mode.ts | 31 ++++----- ...hinking-toggle-pending-bash-output.test.ts | 68 +++++++++++++++++++ 3 files changed, 81 insertions(+), 19 deletions(-) create mode 100644 packages/coding-agent/test/suite/regressions/8611-thinking-toggle-pending-bash-output.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 34a48e6cd..b25c33fe2 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,6 +8,7 @@ ### 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)). diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index e9d888a6c..36fa60fa6 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -4195,21 +4195,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"}`); } @@ -4643,13 +4642,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); diff --git a/packages/coding-agent/test/suite/regressions/8611-thinking-toggle-pending-bash-output.test.ts b/packages/coding-agent/test/suite/regressions/8611-thinking-toggle-pending-bash-output.test.ts new file mode 100644 index 000000000..4c0d39313 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/8611-thinking-toggle-pending-bash-output.test.ts @@ -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"); + }); +}); From 1ac6128e66bd44668b58db773c2a76c29163ade5 Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:33:47 +0200 Subject: [PATCH 24/31] fix(tui): make alt screen not segment on - and / (#8676) * fix(tui): make alt screen not segment on - and / * cleanup --- packages/tui/src/tui-alt-screen.ts | 38 +++++++++++++++++++----- packages/tui/test/tui-alt-screen.test.ts | 29 ++++++++++++++++++ 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/packages/tui/src/tui-alt-screen.ts b/packages/tui/src/tui-alt-screen.ts index 855fa0f71..1ecc08162 100644 --- a/packages/tui/src/tui-alt-screen.ts +++ b/packages/tui/src/tui-alt-screen.ts @@ -66,6 +66,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 { @@ -832,18 +835,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 { diff --git a/packages/tui/test/tui-alt-screen.test.ts b/packages/tui/test/tui-alt-screen.test.ts index a681cfd8c..621e7f6a8 100644 --- a/packages/tui/test/tui-alt-screen.test.ts +++ b/packages/tui/test/tui-alt-screen.test.ts @@ -1039,6 +1039,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); From 938109e7259068ff736dbba3bed14c81af25abbe Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:43:57 +0200 Subject: [PATCH 25/31] chore: changelog entries Forgot to add the changelog entries :P --- packages/ai/CHANGELOG.md | 1 + packages/tui/CHANGELOG.md | 2 ++ 2 files changed, 3 insertions(+) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 790c432c9..3cafae8c6 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -5,6 +5,7 @@ ### 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 diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index d57e4dddf..3cc7205b7 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -5,6 +5,8 @@ ### 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 From 42f7f29ad1cf15d6ec7eb5f41749b2e6ab291eb2 Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:25:30 +0200 Subject: [PATCH 26/31] docs(settings): default model and thinking --- packages/coding-agent/README.md | 7 ++++--- packages/coding-agent/docs/quickstart.md | 2 +- packages/coding-agent/docs/settings.md | 12 ++++++++---- packages/coding-agent/docs/usage.md | 5 +++-- 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 0a6c1e14f..9f1bb560c 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -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 ` | Set session display name | diff --git a/packages/coding-agent/docs/quickstart.md b/packages/coding-agent/docs/quickstart.md index a8b63c21c..1b84fe4df 100644 --- a/packages/coding-agent/docs/quickstart.md +++ b/packages/coding-agent/docs/quickstart.md @@ -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 diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index d0ded5f68..dfae162ba 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -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. | @@ -319,6 +320,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, diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index 4db22b8c5..85ee15044 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -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 ` | Set session display name | From e86823096c5bad39e1ca282ec24bc5eb9bec745b Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 26 Aug 2026 17:39:56 +0200 Subject: [PATCH 27/31] feat(tui): add terminal capability overrides closes #8665 --- packages/coding-agent/CHANGELOG.md | 1 + .../docs/environment-variables.md | 3 + packages/coding-agent/docs/settings.md | 3 + packages/coding-agent/docs/terminal-setup.md | 12 ++++ packages/coding-agent/src/cli/startup-ui.ts | 9 ++- .../coding-agent/src/core/settings-manager.ts | 15 ++++- packages/coding-agent/src/main.ts | 2 + .../src/modes/interactive/interactive-mode.ts | 5 +- .../test/settings-manager.test.ts | 21 +++++- packages/tui/CHANGELOG.md | 4 ++ packages/tui/src/index.ts | 1 + packages/tui/src/terminal-image.ts | 47 ++++++++++++- packages/tui/test/terminal-image.test.ts | 66 ++++++++++++++++++- 13 files changed, 181 insertions(+), 8 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index b25c33fe2..3cdbf461e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - 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)). ### Fixed diff --git a/packages/coding-agent/docs/environment-variables.md b/packages/coding-agent/docs/environment-variables.md index fe9cafa8b..3d3523825 100644 --- a/packages/coding-agent/docs/environment-variables.md +++ b/packages/coding-agent/docs/environment-variables.md @@ -87,6 +87,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 | diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index dfae162ba..222ab4ee4 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -182,6 +182,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 | diff --git a/packages/coding-agent/docs/terminal-setup.md b/packages/coding-agent/docs/terminal-setup.md index 5db077828..29f7320ad 100644 --- a/packages/coding-agent/docs/terminal-setup.md +++ b/packages/coding-agent/docs/terminal-setup.md @@ -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. diff --git a/packages/coding-agent/src/cli/startup-ui.ts b/packages/coding-agent/src/cli/startup-ui.ts index 37d6882ec..8dbda3e54 100644 --- a/packages/coding-agent/src/cli/startup-ui.ts +++ b/packages/coding-agent/src/cli/startup-ui.ts @@ -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 { + setCapabilityOverrides(settingsManager.getTerminalCapabilityOverrides()); setRegisteredThemes(await loadStartupThemes(settingsManager)); const terminalTheme = detectTerminalBackgroundFromEnv().theme; initTheme(resolveThemeSetting(settingsManager.getThemeSetting(), terminalTheme) ?? terminalTheme); diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 19d59483e..469570fb3 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -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 { @@ -1125,6 +1128,16 @@ export class SettingsManager { return this.settings.thinkingBudgets; } + getTerminalCapabilityOverrides(): Partial { + 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; } diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 23b4d42e2..e92b49bea 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -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 { @@ -844,6 +845,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()); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 36fa60fa6..429c768a7 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -35,6 +35,7 @@ import { matchesKey, ProcessTerminal, Spacer, + setCapabilityOverrides, setKeybindings, Text, TruncatedText, @@ -563,6 +564,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; @@ -1982,6 +1984,7 @@ export class InteractiveMode { } private applyRuntimeSettings(): void { + setCapabilityOverrides(this.settingsManager.getTerminalCapabilityOverrides()); configureHttpDispatcher(this.settingsManager.getHttpIdleTimeoutMs()); this.applyFullscreenScrollbarSetting(); this.footer.setSession(this.session); @@ -5962,8 +5965,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); diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index 3a2dbd0f7..043e9039f 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -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) => + 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); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 3cc7205b7..5a18c9717 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [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)). + ### 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)). diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 0d5a4a109..483df546c 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -110,6 +110,7 @@ export { renderImage, resetCapabilitiesCache, setCapabilities, + setCapabilityOverrides, setCellDimensions, type TerminalCapabilities, } from "./terminal-image.ts"; diff --git a/packages/tui/src/terminal-image.ts b/packages/tui/src/terminal-image.ts index cef4e4405..f82946389 100644 --- a/packages/tui/src/terminal-image.ts +++ b/packages/tui/src/terminal-image.ts @@ -32,6 +32,7 @@ export interface ImageRenderOptions { } let cachedCapabilities: TerminalCapabilities | null = null; +let capabilityOverrides: Partial = {}; // 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): 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; diff --git a/packages/tui/test/terminal-image.test.ts b/packages/tui/test/terminal-image.test.ts index ec52878be..b00af7782 100644 --- a/packages/tui/test/terminal-image.test.ts +++ b/packages/tui/test/terminal-image.test.ts @@ -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, fn: () => void): void { +function withEnv(overrides: Record, fn: () => T): T { const saved: Record = {}; for (const key of ENV_KEYS) { saved[key] = process.env[key]; @@ -55,7 +60,7 @@ function withEnv(overrides: Record, 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); From ccfe79ed238674f760c986e3a61493aab794000a Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:28:05 +0200 Subject: [PATCH 28/31] feat(extensions): ui prompt events (#8355) * feat(extensions): ui prompt events * fix: handlers should not delay the prompt --- packages/coding-agent/CHANGELOG.md | 8 ++- packages/coding-agent/docs/extensions.md | 18 +++++++ .../coding-agent/src/core/extensions/index.ts | 3 ++ .../src/core/extensions/runner.ts | 52 ++++++++++++++++++- .../coding-agent/src/core/extensions/types.ts | 22 ++++++++ packages/coding-agent/src/index.ts | 3 ++ 6 files changed, 104 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 3cdbf461e..814067a56 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,8 @@ ### 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)). @@ -32,7 +34,11 @@ - Added an optional `powershell` tool for Windows, configurable through `defaultTools` and the SDK. See [PowerShell Tool](docs/windows.md#powershell-tool). - Added a `/thinking` selector and searchable default choices to the model and thinking selectors; Ctrl+S saves the selected model as the global default. See [Models and Thinking](docs/keybindings.md#models-and-thinking). - Added optional routing session IDs to exported compaction summary helpers so callers can preserve provider routing without enabling prompt cache writes. -- Added transcript usage notices for compaction and branch summaries when cache miss notices are enabled. +- Added + + + +usage notices for compaction and branch summaries when cache miss notices are enabled. - Added `session_compact_failed` extension events so compaction failures and aborts expose their reason, retry state, source, and error message to handlers ([#8175](https://github.com/earendil-works/pi/issues/8175)). - Added inherited provider-neutral `toolChoice` support to simple stream requests. - Added inherited automatic Anthropic server-side refusal fallback for supported first-party models, including returned-model usage pricing ([#8017](https://github.com/earendil-works/pi/issues/8017)). diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 7643856be..03ca0d53b 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -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). diff --git a/packages/coding-agent/src/core/extensions/index.ts b/packages/coding-agent/src/core/extensions/index.ts index 90c786041..f19816a9a 100644 --- a/packages/coding-agent/src/core/extensions/index.ts +++ b/packages/coding-agent/src/core/extensions/index.ts @@ -166,6 +166,9 @@ export type { TreePreparation, TurnEndEvent, TurnStartEvent, + UIPromptEndEvent, + UIPromptKind, + UIPromptStartEvent, // Events - User Bash UserBashEvent, UserBashEventResult, diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index fc486e4cd..3346645a1 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -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(kind: UIPromptKind, title: string | undefined, run: () => Promise): Promise { + 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): void { + queueMicrotask(() => { + void this.emit(event); + }); + } + getUIContext(): ExtensionUIContext { return this.uiContext; } diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index bade46419..0b9474664 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -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): void; on(event: "agent_end", handler: ExtensionHandler): void; on(event: "agent_settled", handler: ExtensionHandler): void; + on(event: "ui_prompt_start", handler: ExtensionHandler): void; + on(event: "ui_prompt_end", handler: ExtensionHandler): void; on(event: "turn_start", handler: ExtensionHandler): void; on(event: "turn_end", handler: ExtensionHandler): void; on(event: "message_start", handler: ExtensionHandler): void; diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 52d8f9b06..8e5ce2f3d 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -144,6 +144,9 @@ export type { ToolResultEvent, TurnEndEvent, TurnStartEvent, + UIPromptEndEvent, + UIPromptKind, + UIPromptStartEvent, UserBashEvent, UserBashEventResult, WidgetPlacement, From 1defa151e0c1dac87d38a2d0ac09d67f817b30f9 Mon Sep 17 00:00:00 2001 From: Ramiz Wachtler Date: Thu, 27 Aug 2026 15:51:29 +0200 Subject: [PATCH 29/31] fix(coding-agent): expose https-proxy-agent named export (#8723) - closes #8610 --- packages/coding-agent/CHANGELOG.md | 1 + scripts/build-coding-agent-bundle.mjs | 26 +++++++++++++++++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 814067a56..a5221a0d6 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -16,6 +16,7 @@ - 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 diff --git a/scripts/build-coding-agent-bundle.mjs b/scripts/build-coding-agent-bundle.mjs index 1b6d233eb..c27e8d56d 100644 --- a/scripts/build-coding-agent-bundle.mjs +++ b/scripts/build-coding-agent-bundle.mjs @@ -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 From 4e494929998d6bc4fccf75e0a233f727db4b70ee Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:07:54 +0200 Subject: [PATCH 30/31] feat(tui): allow disable copy on fullscreen, ctrl + x copies selection (#8731) --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/README.md | 2 +- packages/coding-agent/docs/keybindings.md | 2 +- packages/coding-agent/docs/settings.md | 1 + packages/coding-agent/docs/usage.md | 2 +- .../coding-agent/src/core/settings-manager.ts | 11 +++ .../components/settings-selector.ts | 12 +++ .../src/modes/interactive/interactive-mode.ts | 31 ++++++- .../coding-agent/test/interactive-tui.test.ts | 93 ++++++++++++++++++- .../test/settings-manager.test.ts | 4 + .../test/settings-selector.test.ts | 5 + packages/tui/CHANGELOG.md | 1 + packages/tui/README.md | 2 +- packages/tui/src/tui-alt-screen.ts | 48 ++++++++-- packages/tui/test/tui-alt-screen.test.ts | 61 ++++++++++++ 15 files changed, 260 insertions(+), 16 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a5221a0d6..2dc6355ac 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,6 +8,7 @@ - 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 diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 9f1bb560c..5ea32dac4 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -217,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 diff --git a/packages/coding-agent/docs/keybindings.md b/packages/coding-agent/docs/keybindings.md index 9da435172..ae2e65c2e 100644 --- a/packages/coding-agent/docs/keybindings.md +++ b/packages/coding-agent/docs/keybindings.md @@ -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 | diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index 222ab4ee4..649f031a5 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -69,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: diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index 85ee15044..88ada91ba 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -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 | diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 469570fb3..3d765ca97 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -142,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 { @@ -1229,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; } diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index 6dac4def3..308bf8109 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -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; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 429c768a7..bce3828e3 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -362,6 +362,7 @@ interface InteractiveTuiOptions { logDirectory: string; terminal?: Terminal; onRightClickPaste?: () => void; + fullscreenCopyOnSelect?: boolean; } /** Composition root for selecting the interactive terminal renderer. */ @@ -374,6 +375,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); @@ -581,6 +583,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()); @@ -865,6 +868,7 @@ export class InteractiveMode { logDirectory: getAgentDir(), terminal, onRightClickPaste: this.onRightClickPaste, + fullscreenCopyOnSelect: this.settingsManager.getFullscreenCopyOnSelect(), }); nextUi.setClearOnShrink(clearOnShrink); nextUi.onDebug = onDebug; @@ -1987,6 +1991,9 @@ export class InteractiveMode { 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()); @@ -2894,7 +2901,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()); @@ -4568,6 +4578,7 @@ export class InteractiveMode { tuiMode: this.ui.mode, fullscreenExitOutput: this.settingsManager.getFullscreenExitOutput(), fullscreenScrollbar: this.settingsManager.getFullscreenScrollbar(), + fullscreenCopyOnSelect: this.settingsManager.getFullscreenCopyOnSelect(), warnings: this.settingsManager.getWarnings(), }, { @@ -4740,6 +4751,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); }, @@ -6096,7 +6111,19 @@ export class InteractiveMode { }); } - private async handleCopyCommand(options: { flashConfirmation?: boolean } = {}): Promise { + private async handleCopyCommand( + options: { flashConfirmation?: boolean; preferSelection?: boolean } = {}, + ): Promise { + 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."); diff --git a/packages/coding-agent/test/interactive-tui.test.ts b/packages/coding-agent/test/interactive-tui.test.ts index a4a0408d8..cd94dfb61 100644 --- a/packages/coding-agent/test/interactive-tui.test.ts +++ b/packages/coding-agent/test/interactive-tui.test.ts @@ -87,6 +87,7 @@ describe("createInteractiveTui", () => { renderer.setFocus(component); type SwitchContext = { + runtimeHost: { session: { settingsManager: { getFullscreenCopyOnSelect: () => boolean } } }; renderer: ReturnType; ui: TUI; fullscreenLayoutRoot: Component; @@ -95,6 +96,7 @@ describe("createInteractiveTui", () => { extensionTerminalInputSubscriptions: Set; }; 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; @@ -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(); diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index 043e9039f..685243254 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -455,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"), @@ -470,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", () => { diff --git a/packages/coding-agent/test/settings-selector.test.ts b/packages/coding-agent/test/settings-selector.test.ts index 9c88fd282..c93178936 100644 --- a/packages/coding-agent/test/settings-selector.test.ts +++ b/packages/coding-agent/test/settings-selector.test.ts @@ -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]); }); }); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 5a18c9717..15ae98abc 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -5,6 +5,7 @@ ### 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 diff --git a/packages/tui/README.md b/packages/tui/README.md index 58e829370..553148306 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -657,7 +657,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. diff --git a/packages/tui/src/tui-alt-screen.ts b/packages/tui/src/tui-alt-screen.ts index 1ecc08162..c37689a99 100644 --- a/packages/tui/src/tui-alt-screen.ts +++ b/packages/tui/src/tui-alt-screen.ts @@ -156,6 +156,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. @@ -200,6 +202,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; constructor( @@ -223,6 +226,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)); } @@ -235,6 +239,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 { + 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; @@ -1008,7 +1032,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { this.requestRender(); return; } - void this.copySelectionToClipboard(); + if (this.copyOnSelect) void this.copySelectionToClipboard(); this.requestRender(); return; } @@ -1084,14 +1108,14 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { return { start: Math.max(minColumn, start), end: Math.min(maxColumn, end) }; } - private async copySelectionToClipboard(): Promise { + 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[] = []; @@ -1105,7 +1129,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 { + const text = this.getActiveSelectionText(); + if (!text) return false; + return this.copyTextToClipboard(text); + } + + private async copyTextToClipboard(text: string): Promise { // 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 @@ -1113,10 +1146,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 { diff --git a/packages/tui/test/tui-alt-screen.test.ts b/packages/tui/test/tui-alt-screen.test.ts index 621e7f6a8..0be8fc2fd 100644 --- a/packages/tui/test/tui-alt-screen.test.ts +++ b/packages/tui/test/tui-alt-screen.test.ts @@ -1000,6 +1000,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, { From 56f3f33a9a675ef2a2c30cf2e35a6a385cdf2ed4 Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Fri, 28 Aug 2026 10:39:45 +0200 Subject: [PATCH 31/31] fix(ai): remove retired Fireworks turbo router test --- packages/ai/test/fireworks-models.test.ts | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/packages/ai/test/fireworks-models.test.ts b/packages/ai/test/fireworks-models.test.ts index b37991b5b..1a604ba9e 100644 --- a/packages/ai/test/fireworks-models.test.ts +++ b/packages/ai/test/fireworks-models.test.ts @@ -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");