diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index fb8384f18..37dc4fee8 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -375,3 +375,11 @@ gaoyk19 pr cad0p pr Jaaneek pr + +CaiJichang212 pr + +Mallikarjun-0 pr + +wutongyuonce pr + +Terminator666666 pr diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 5ec580d50..79ca2f5ad 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -17,6 +17,10 @@ - Added assistant request recovery, durable retry waits, deferred suspension, and missing-identity suspension to `AgentHarness`. - Added durable tool planning, context-bound sequential and parallel execution, ordered result settlement, and safe tool replay recovery to `AgentHarness`. +### Fixed + +- Fixed Windows `NodeExecutionEnv` aborts crashing when `taskkill.exe` is unavailable on `PATH` ([#6596](https://github.com/earendil-works/pi/issues/6596)). + ### Removed - Removed the withdrawn manual-drive configuration, action inspection methods, action outcomes, and snapshot action field from `AgentHarness`. diff --git a/packages/agent/src/harness/env/nodejs.ts b/packages/agent/src/harness/env/nodejs.ts index 732dc84db..b8c8fd6dd 100644 --- a/packages/agent/src/harness/env/nodejs.ts +++ b/packages/agent/src/harness/env/nodejs.ts @@ -254,11 +254,17 @@ function getShellEnv( function killProcessTree(pid: number): void { if (process.platform === "win32") { try { - spawn("taskkill", ["/F", "/T", "/PID", String(pid)], { - stdio: "ignore", - detached: true, - windowsHide: true, - }); + const child = spawn( + join(process.env.SystemRoot ?? "C:\\Windows", "System32", "taskkill.exe"), + ["/F", "/T", "/PID", String(pid)], + { + stdio: "ignore", + detached: true, + windowsHide: true, + }, + ); + // A failed spawn emits "error" asynchronously; consume it to avoid crashing Node. + child.once("error", () => {}); } catch { // Ignore errors. } diff --git a/packages/agent/test/harness/nodejs-env.test.ts b/packages/agent/test/harness/nodejs-env.test.ts index 361040495..1833ccc51 100644 --- a/packages/agent/test/harness/nodejs-env.test.ts +++ b/packages/agent/test/harness/nodejs-env.test.ts @@ -538,6 +538,50 @@ describe("NodeExecutionEnv", () => { if (!result.ok) expect(result.error).toMatchObject({ code: "aborted" }); }); + it.skipIf(process.platform === "win32")("ignores asynchronous taskkill spawn errors during abort", async () => { + const root = createTempDir(); + const pidFile = join(root, "shell.pid"); + const controller = new AbortController(); + const env = new NodeExecutionEnv({ cwd: root, shellPath: "/bin/bash" }); + const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); + const previousSystemRoot = process.env.SystemRoot; + process.env.SystemRoot = "/definitely/missing/windows"; + Object.defineProperty(process, "platform", { configurable: true, value: "win32" }); + + let pid: number | undefined; + try { + const execution = env.exec( + `echo $$ > ${toBashSingleQuotedArg(pidFile)}; exec sleep 60`, + undefined, + withAbortSignal(controller.signal, BACKGROUND_CONTEXT), + ); + for (let attempt = 0; attempt < 100 && !existsSync(pidFile); attempt++) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + expect(existsSync(pidFile)).toBe(true); + + controller.abort(); + await new Promise((resolve) => setTimeout(resolve, 0)); + pid = Number.parseInt(readFileSync(pidFile, "utf8"), 10); + process.kill(pid, "SIGKILL"); + + const result = await execution; + expect(result).toMatchObject({ ok: false, error: { code: "aborted" } }); + } finally { + if (pid === undefined && existsSync(pidFile)) { + pid = Number.parseInt(readFileSync(pidFile, "utf8"), 10); + } + if (pid !== undefined && Number.isFinite(pid)) { + try { + process.kill(pid, "SIGKILL"); + } catch {} + } + if (previousSystemRoot === undefined) delete process.env.SystemRoot; + else process.env.SystemRoot = previousSystemRoot; + if (platformDescriptor) Object.defineProperty(process, "platform", platformDescriptor); + } + }); + it("captures large shell output to a full output file through the execution env", async () => { const root = createTempDir(); const env = new NodeExecutionEnv({ cwd: root }); diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 6cc3e74da..3cafae8c6 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,11 @@ ## [Unreleased] +### Fixed + +- Fixed OpenAI-compatible Chat Completions ignoring an explicitly requested `toolChoice` when no tools are defined. +- Fixed thinking signature serialization to run once after the signature is complete ([#8671](https://github.com/earendil-works/pi/pull/8671)). + ## [0.84.3] - 2026-08-24 ### Breaking Changes @@ -34,6 +39,7 @@ - Fixed Kimi OpenAI-compatible usage reporting so top-level `cached_tokens` count as cache reads instead of normal input tokens ([#8075](https://github.com/earendil-works/pi/issues/8075)). - Fixed Google Generative AI and Vertex AI custom models ignoring `thinkingLevelMap`, which dropped extended thinking controls ([#8135](https://github.com/earendil-works/pi/issues/8135)). - Fixed Xiaomi model catalog generation retaining shut-down MiMo V2 model names after models.dev marked them deprecated ([#8187](https://github.com/earendil-works/pi/issues/8187)). +- Fixed OpenRouter reasoning controls by deriving `off` support and available effort levels from OpenRouter's model metadata, preventing reasoning-mandatory models from receiving `effort: "none"` ([#8454](https://github.com/earendil-works/pi/issues/8454)). ## [0.84.2] - 2026-08-14 diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index a49814634..cbcdc3c79 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, @@ -1666,7 +1691,7 @@ async function loadModelsDevData(): Promise[]> { } // Process Cloudflare AI Gateway models - const gatewayIds = new Set(); + 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; @@ -1701,6 +1726,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, @@ -1719,22 +1745,22 @@ async function loadModelsDevData(): Promise[]> { maxTokens: m.limit?.output || 4096, ...(compat ? { compat } : {}), }); - gatewayIds.add(id); recordModelsDevReasoningOptions("cloudflare-ai-gateway", id, m); } } - // The gateway proxies Workers AI through its OpenAI-compatible endpoint, but - // models.dev's gateway catalog only lists `workers-ai/*` entries intermittently. - // Derive them from the Workers AI catalog so the gateway's openai-completions - // models stay stable regardless of that duplication. + // The gateway proxies Workers AI through its OpenAI-compatible /compat endpoint, + // but models.dev may omit or intermittently drop those `workers-ai/*` entries + // from the AI Gateway catalog. Mirror the Workers AI catalog under the documented + // prefix so the gateway keeps its OpenAI-compatible models stable. if (data["cloudflare-workers-ai"]?.models) { for (const [modelId, model] of Object.entries(data["cloudflare-workers-ai"].models)) { const m = model as ModelsDevModel; if (m.tool_call !== true) continue; const id = `workers-ai/${modelId}`; - if (gatewayIds.has(id)) continue; + if (cloudflareAIGatewayModelIds.has(id)) continue; + cloudflareAIGatewayModelIds.add(id); models.push({ id, @@ -1754,7 +1780,6 @@ async function loadModelsDevData(): Promise[]> { maxTokens: m.limit?.output || 4096, compat: { sendSessionAffinityHeaders: true }, }); - gatewayIds.add(id); recordModelsDevReasoningOptions("cloudflare-ai-gateway", id, m); } } @@ -2542,6 +2567,24 @@ async function generateModels() { maxTokens: 384000, compat: deepseekCompat, }, + { + id: "deepseek-v4-flash-vision-exp", + name: "DeepSeek V4 Flash Vision Exp", + api: "openai-completions", + baseUrl: "https://api.deepseek.com", + provider: "deepseek", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.14, + output: 0.28, + cacheRead: 0.0028, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 384000, + compat: deepseekCompat, + }, { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", 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/src/api/openai-completions.ts b/packages/ai/src/api/openai-completions.ts index 4b6eb34c7..838f6612a 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]; @@ -304,6 +329,15 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio timestamp: Date.now(), }; + // `reasoning_details` are replay metadata, not user-visible stream deltas. + // Keep them in memory during streaming and serialize once when the block is finalized. + let streamedReasoningDetails: OpenAIReasoningDetail[] | undefined; + const applyStreamedReasoningDetails = (block: ThinkingContent): void => { + if (streamedReasoningDetails !== undefined) { + block.thinkingSignature = JSON.stringify(streamedReasoningDetails); + } + }; + try { const apiKey = getClientApiKey(model.provider, options?.apiKey, options?.headers); const compat = getCompat(model); @@ -394,6 +428,7 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio partial: output, }); } else if (block.type === "thinking") { + applyStreamedReasoningDetails(block); stream.push({ type: "thinking_end", contentIndex, @@ -621,12 +656,12 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio if (Array.isArray(reasoningDetails)) { for (const detail of reasoningDetails) { if (!isOpenAIReasoningDetail(detail)) continue; - const block = ensureThinkingBlock(""); - const preservedDetails = parseOpenAIReasoningDetails(block.thinkingSignature) ?? []; - preservedDetails.push(detail); - // Keep provider replay data in the existing signature slot. OpenRouter - // requires the complete reasoning_details sequence in its original order. - block.thinkingSignature = JSON.stringify(preservedDetails); + ensureThinkingBlock(""); + streamedReasoningDetails ??= []; + // Keep provider replay data in the existing signature slot. OpenRouter streams + // reasoning_details as deltas: consecutive text/summary deltas are merged into + // logical entries, while encrypted entries remain opaque and discrete. + appendOpenAIReasoningDetail(streamedReasoningDetails, detail); } } } @@ -656,6 +691,9 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio stream.end(); } catch (error) { for (const block of output.content) { + if (block.type === "thinking") { + applyStreamedReasoningDetails(block); + } delete (block as { index?: number }).index; // Streaming scratch buffers are only used during parsing; never persist them. delete (block as { partialArgs?: string }).partialArgs; diff --git a/packages/ai/src/providers/cloudflare-ai-gateway.ts b/packages/ai/src/providers/cloudflare-ai-gateway.ts index c413ae166..99d976225 100644 --- a/packages/ai/src/providers/cloudflare-ai-gateway.ts +++ b/packages/ai/src/providers/cloudflare-ai-gateway.ts @@ -6,14 +6,14 @@ import { CLOUDFLARE_AI_GATEWAY_MODELS } from "./cloudflare-ai-gateway.models.ts" import { cloudflareAIGatewayAuth } from "./cloudflare-auth.ts"; import { cloudflareStreams } from "./cloudflare-stream.ts"; -export function cloudflareAIGatewayProvider(): Provider< - "anthropic-messages" | "openai-completions" | "openai-responses" -> { +type CloudflareAIGatewayApi = "anthropic-messages" | "openai-completions" | "openai-responses"; + +export function cloudflareAIGatewayProvider(): Provider { // The api map is pinned to all three APIs: models.dev's gateway catalog drops and // restores `workers-ai/*` (openai-completions) entries over time, and inference from // `models` alone would otherwise reject the openai-completions entry whenever the // generated catalog happens to contain none. - return createProvider<"anthropic-messages" | "openai-completions" | "openai-responses">({ + return createProvider({ id: "cloudflare-ai-gateway", name: "Cloudflare AI Gateway", auth: { apiKey: cloudflareAIGatewayAuth() }, diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 1698d1489..befc992e2 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/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"); 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); + }); }); diff --git a/packages/ai/test/openai-completions-tool-choice.test.ts b/packages/ai/test/openai-completions-tool-choice.test.ts index c453f3d54..c8e03b6df 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("includes toolChoice when no tools are provided", async () => { + const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini")!; + const model = { ...baseModel, api: "openai-completions" } as const; + let payload: unknown; + + await streamSimple( + model, + { + messages: [{ role: "user", content: "Summarize the conversation", timestamp: Date.now() }], + }, + { + apiKey: "test", + toolChoice: "none", + onPayload: (params: unknown) => { + payload = params; + }, + }, + ).result(); + + const params = (payload ?? mockState.lastParams) as { tool_choice?: string; tools?: unknown[] }; + expect(params.tool_choice).toBe("none"); + expect(params).not.toHaveProperty("tools"); + }); + it("omits strict when compat disables strict mode", async () => { const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini")!; const model = { 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" }, + }); + }); +}); diff --git a/packages/ai/test/zai-coding-plan-models.test.ts b/packages/ai/test/zai-coding-plan-models.test.ts index 7443636ad..2d0ef25b3 100644 --- a/packages/ai/test/zai-coding-plan-models.test.ts +++ b/packages/ai/test/zai-coding-plan-models.test.ts @@ -41,12 +41,14 @@ it("uses API-equivalent reference costs for Coding Plan models", () => { cacheRead: 0.24, cacheWrite: 0, }); - expect(getBuiltinModel("zai", "glm-5.3").cost).toEqual({ - input: 1.4, - output: 4.4, - cacheRead: 0.26, - cacheWrite: 0, - }); + for (const provider of ["zai", "zai-coding-cn"] as const) { + expect(getBuiltinModel(provider, "glm-5.3").cost).toEqual({ + input: 1.4, + output: 4.4, + cacheRead: 0.26, + cacheWrite: 0, + }); + } }); it("keeps zero costs for Coding Plan models without a matching API price", () => { diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1eb1ede01..86138d6c6 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,23 @@ ## [Unreleased] +### Added + +- Added `ui_prompt_start` and `ui_prompt_end` extension events so host integrations can distinguish active agent work from waiting on user-facing `ctx.ui` prompts ([#5329](https://github.com/earendil-works/pi/issues/5329)). +- Added transcript usage notices for compaction and branch summaries when cache miss notices are enabled. +- Added RPC `clear_queue` to retrieve and remove queued steering and follow-up messages ([#8432](https://github.com/earendil-works/pi/issues/8432)). +- Added environment variables and advanced settings for overriding auto-detected terminal hyperlink, image, and truecolor capabilities ([#8665](https://github.com/earendil-works/pi/issues/8665)). +- Added `fullscreenCopyOnSelect` to disable automatic fullscreen selection copy; when disabled, `Ctrl+X` copies the active text selection before falling back to the last assistant message, while `/tree` still copies the selected message ([#7720](https://github.com/earendil-works/pi/issues/7720)). + +### Fixed + +- Fixed toggling thinking visibility clearing partial output from running Bash tools ([#8611](https://github.com/earendil-works/pi/issues/8611)). +- Fixed Windows shell aborts crashing Pi when `taskkill.exe` is unavailable on `PATH` ([#6596](https://github.com/earendil-works/pi/issues/6596)). +- Fixed resumed sessions corrupting the next appended entry when their JSONL file lacks a trailing newline ([#8345](https://github.com/earendil-works/pi/issues/8345)). +- Fixed extension messages sent with `triggerTurn: false` while the agent is running being inserted between a tool call and its result, which made providers that validate message order reject the replayed history. They are now appended once the turn's tool results are in ([#8537](https://github.com/earendil-works/pi/issues/8537)). +- Fixed compaction and branch summaries forcing `toolChoice: "none"` ([#8649](https://github.com/earendil-works/pi/issues/8649), [#8638](https://github.com/earendil-works/pi/issues/8638)). +- Fixed Google Vertex requests failing with `HttpsProxyAgent is not a constructor` when the bundled Node.js runtime uses an HTTP(S) proxy ([#8610](https://github.com/earendil-works/pi/issues/8610)). + ## [0.84.3] - 2026-08-24 ### New Features diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index c74f96d49..63e8cac7a 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 | @@ -216,7 +217,7 @@ See `/hotkeys` for the full list. Customize via `~/.pi/agent/keybindings.json`. | Shift+Tab | Cycle thinking level | | Ctrl+O | Collapse/expand tool output | | Ctrl+T | Collapse/expand thinking blocks | -| Ctrl+X | Copy the last assistant message | +| Ctrl+X | Copy the last assistant message; with fullscreen copy-on-select disabled, copy the active text selection | ### Message Queue 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 diff --git a/packages/coding-agent/docs/environment-variables.md b/packages/coding-agent/docs/environment-variables.md index 79f6329a1..9abe74b33 100644 --- a/packages/coding-agent/docs/environment-variables.md +++ b/packages/coding-agent/docs/environment-variables.md @@ -89,6 +89,9 @@ These variables are read by Pi itself: | `PI_CACHE_RETENTION` | Set to `long` for extended provider prompt caching where supported | | `PI_SHARE_VIEWER_URL` | Override the base URL used by `/share` | | `PI_HARDWARE_CURSOR` | Set to `1` to show the hardware cursor; see [Terminal setup](terminal-setup.md) | +| `PI_HYPERLINKS` | Override OSC 8 hyperlink detection with `1`, `0`, or `auto` | +| `PI_IMAGE_PROTOCOL` | Override inline image detection with `kitty`, `iterm2`, `none`, or `auto` | +| `PI_TRUE_COLOR` | Override truecolor detection with `1`, `0`, or `auto` | | `PI_TUI_ESC_TIMEOUT` | How long to wait after a lone ESC before treating it as Escape, in milliseconds; defaults to `100` over SSH and `10` otherwise. Increase if Alt-key input is misread as Escape | | `VISUAL`, `EDITOR` | External editor fallback when `externalEditor` is unset | | `HTTP_PROXY`, `HTTPS_PROXY` | Proxy outbound HTTP requests | 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/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/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/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/docs/settings.md b/packages/coding-agent/docs/settings.md index 4425cf4b7..d6333d55b 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. | @@ -68,6 +69,7 @@ Use `/trust` in interactive mode to save a project trust decision for future ses | `tuiMode` | string | `"regular"` | Interactive TUI mode: `"regular"` or experimental `"fullscreen"`. Changes from `/settings` apply immediately; `--tui-mode` overrides this setting at startup | | `fullscreenExitOutput` | string | `"transcript"` | Fullscreen exit output: `"transcript"` prints the final transcript and resume hint, while `"resume-hint"` restores the previous screen and prints only the resume hint. Has no effect in regular TUI mode | | `fullscreenScrollbar` | string | `"auto"` | Fullscreen transcript scrollbar: `"auto"` shows it temporarily while scrolling, `"always"` reserves the rightmost column and keeps it visible, and `"hidden"` hides it. Has no effect in regular TUI mode | +| `fullscreenCopyOnSelect` | boolean | `true` | Automatically copy selected text in fullscreen mode. When disabled, selections stay highlighted and `Ctrl+X` copies the active selection | For VS Code, include `--wait` so pi resumes after the editor exits: @@ -181,6 +183,9 @@ Keep `retry.provider.maxRetries` at `0` unless provider-level retries are explic | `terminal.showImages` | boolean | `true` | Show images in terminal (if supported) | | `terminal.imageWidthCells` | number | `60` | Preferred inline image width in terminal cells | | `terminal.clearOnShrink` | boolean | `false` | Clear empty rows when content shrinks (can cause flicker) | +| `terminal.hyperlinks` | boolean or `"auto"` | `"auto"` | Override OSC 8 hyperlink support (advanced, JSON-only) | +| `terminal.images` | string or boolean | `"auto"` | Override image protocol support with `"kitty"`, `"iterm2"`, `false`, or `"auto"` (advanced, JSON-only) | +| `terminal.trueColor` | boolean or `"auto"` | `"auto"` | Override truecolor support (advanced, JSON-only) | | `images.autoResize` | boolean | `true` | Resize images to 2000x2000 max. Applies to `@file` attachments, `read`, and images returned by tools | | `images.blockImages` | boolean | `false` | Block all images from being sent to LLM | @@ -319,6 +324,9 @@ See [packages.md](packages.md) for package management details. "defaultProvider": "anthropic", "defaultModel": "claude-sonnet-4-20250514", "defaultThinkingLevel": "medium", + "modelThinkingLevels": { + "anthropic/claude-sonnet-4-20250514": "high" + }, "theme": "dark", "compaction": { "enabled": true, 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/docs/usage.md b/packages/coding-agent/docs/usage.md index 4db22b8c5..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 | @@ -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 | 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/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 488f5e0ae..ea69b84ef 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); } } @@ -1606,6 +1644,7 @@ export class AgentSession { this.sessionManager.appendModelChange(model.provider, model.id); if (options.persist) { this.settingsManager.setDefaultModelAndProvider(model.provider, model.id); + this._addPersistedDefaultToNonEmptyScope(model); } // Apply thinking level for the new model. @@ -1616,6 +1655,20 @@ export class AgentSession { await this._emitModelSelect(model, previousModel, "set"); } + private _addPersistedDefaultToNonEmptyScope(model: Model): void { + if (this._scopedModels.length === 0) return; + if (this._scopedModels.some((scoped) => modelsAreEqual(scoped.model, model))) return; + + this._scopedModels = [...this._scopedModels, { model }]; + + const enabledModels = this.settingsManager.getEnabledModels(); + if (!enabledModels?.length) return; + + const modelReference = `${model.provider}/${model.id}`; + if (enabledModels.some((pattern) => pattern.toLowerCase() === modelReference.toLowerCase())) return; + this.settingsManager.setEnabledModels([...enabledModels, modelReference]); + } + /** * Cycle to next/previous model. * Uses scoped models (from --models flag) if available, otherwise all available models. @@ -1658,6 +1711,7 @@ export class AgentSession { this.sessionManager.appendModelChange(next.model.provider, next.model.id); if (options.persist) { this.settingsManager.setDefaultModelAndProvider(next.model.provider, next.model.id); + this._addPersistedDefaultToNonEmptyScope(next.model); } // Apply thinking level for the new model. @@ -1692,6 +1746,7 @@ export class AgentSession { this.sessionManager.appendModelChange(nextModel.provider, nextModel.id); if (options.persist) { this.settingsManager.setDefaultModelAndProvider(nextModel.provider, nextModel.id); + this._addPersistedDefaultToNonEmptyScope(nextModel); } // Apply thinking level for the new model. 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/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/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/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 19d59483e..3d765ca97 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 { @@ -139,6 +142,7 @@ export interface Settings { tuiMode?: TuiMode; // default: "regular" fullscreenExitOutput?: FullscreenExitOutput; // default: "transcript"; no effect in regular TUI mode fullscreenScrollbar?: ScrollViewScrollbar; // default: "auto"; no effect in regular TUI mode + fullscreenCopyOnSelect?: boolean; // default: true; no effect in regular TUI mode } function isMergeableObject(value: unknown): value is Record { @@ -1125,6 +1129,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; } @@ -1216,6 +1230,16 @@ export class SettingsManager { this.save(); } + getFullscreenCopyOnSelect(): boolean { + return this.settings.fullscreenCopyOnSelect ?? true; + } + + setFullscreenCopyOnSelect(enabled: boolean): void { + this.globalSettings.fullscreenCopyOnSelect = enabled; + this.markModified("fullscreenCopyOnSelect"); + this.save(); + } + getImageAutoResize(): boolean { return this.settings.images?.autoResize ?? true; } diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 210b84010..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, @@ -415,5 +418,6 @@ export { copyToClipboard } from "./utils/clipboard.ts"; export { parseFrontmatter, stripFrontmatter } from "./utils/frontmatter.ts"; export { convertToPng } from "./utils/image-convert.ts"; export { formatDimensionNote, type ResizedImage, resizeImage } from "./utils/image-resize.ts"; +export { detectSupportedImageMimeTypeFromFile } from "./utils/mime.ts"; // Shell utilities export { getPowerShellConfig, getShellConfig } from "./utils/shell.ts"; diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 0d76965b9..edceb07ed 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 { @@ -964,6 +965,7 @@ export async function main(args: string[], options?: MainOptions) { time("createAgentSessionRuntime"); const { services, session, modelFallbackMessage } = runtime; const { settingsManager, modelRuntime, resourceLoader } = services; + setCapabilityOverrides(settingsManager.getTerminalCapabilityOverrides()); applyHttpProxySettings(settingsManager.getGlobalSettings().httpProxy); configureHttpDispatcher(settingsManager.getHttpIdleTimeoutMs()); 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 cfbb41d2c..d5aa6ef60 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -33,6 +33,7 @@ import { Markdown, matchesKey, Spacer, + setCapabilityOverrides, setKeybindings, Text, TruncatedText, @@ -503,6 +504,7 @@ export class InteractiveMode { constructor(runtimeHost: AgentSessionRuntime, options: InteractiveModeOptions = {}) { this.runtimeHost = runtimeHost; + setCapabilityOverrides(this.settingsManager.getTerminalCapabilityOverrides()); const tuiMode = options.tuiMode ?? this.settingsManager.getTuiMode(); this.options = { ...options, tuiMode }; this.autoTrustOnReloadCwd = options.autoTrustOnReloadCwd; @@ -519,6 +521,7 @@ export class InteractiveMode { showHardwareCursor: this.settingsManager.getShowHardwareCursor(), logDirectory: getAgentDir(), onRightClickPaste: this.onRightClickPaste, + fullscreenCopyOnSelect: this.settingsManager.getFullscreenCopyOnSelect(), }); this.ui = createInteractiveTuiReference(() => this.renderer); this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink()); @@ -803,6 +806,7 @@ export class InteractiveMode { logDirectory: getAgentDir(), terminal, onRightClickPaste: this.onRightClickPaste, + fullscreenCopyOnSelect: this.settingsManager.getFullscreenCopyOnSelect(), }); nextUi.setClearOnShrink(clearOnShrink); nextUi.onDebug = onDebug; @@ -1916,8 +1920,12 @@ export class InteractiveMode { } private applyRuntimeSettings(): void { + setCapabilityOverrides(this.settingsManager.getTerminalCapabilityOverrides()); configureHttpDispatcher(this.settingsManager.getHttpIdleTimeoutMs()); this.applyFullscreenScrollbarSetting(); + if (this.renderer instanceof TuiAltScreen) { + this.renderer.setCopyOnSelect(this.settingsManager.getFullscreenCopyOnSelect()); + } this.footer.setSession(this.session); this.footer.setAutoCompactEnabled(this.session.autoCompactionEnabled); this.footerDataProvider.setCwd(this.sessionManager.getCwd()); @@ -2829,7 +2837,10 @@ export class InteractiveMode { this.defaultEditor.onAction("app.tools.expand", () => this.toggleToolOutputExpansion()); this.defaultEditor.onAction("app.thinking.toggle", () => this.toggleThinkingBlockVisibility()); this.defaultEditor.onAction("app.editor.external", () => void this.handleOpenExternalEditor()); - this.defaultEditor.onAction("app.message.copy", () => void this.handleCopyCommand({ flashConfirmation: true })); + this.defaultEditor.onAction( + "app.message.copy", + () => void this.handleCopyCommand({ flashConfirmation: true, preferSelection: true }), + ); this.defaultEditor.onAction("app.message.followUp", () => this.handleFollowUp()); this.defaultEditor.onAction("app.message.dequeue", () => this.handleDequeue()); this.defaultEditor.onAction("app.session.new", () => this.handleClearCommand()); @@ -4133,21 +4144,20 @@ export class InteractiveMode { this.showStatus(`Tool output: ${expanded ? "expanded" : "collapsed"}`); } + /** Update rendered assistant messages without rebuilding live tool components. */ + private updateThinkingBlockVisibility(): void { + for (const child of this.chatContainer.children) { + if (child instanceof AssistantMessageComponent) { + child.setHideThinkingBlock(this.hideThinkingBlock); + } + } + this.ui.requestRender(); + } + private toggleThinkingBlockVisibility(): void { this.hideThinkingBlock = !this.hideThinkingBlock; this.settingsManager.setHideThinkingBlock(this.hideThinkingBlock); - - // Rebuild chat from session messages - this.chatContainer.clear(); - this.rebuildChatFromMessages(); - - // If streaming, re-add the streaming component with updated visibility and re-render - if (this.streamingComponent && this.streamingMessage) { - this.streamingComponent.setHideThinkingBlock(this.hideThinkingBlock); - this.streamingComponent.updateContent(this.streamingMessage); - this.chatContainer.addChild(this.streamingComponent); - } - + this.updateThinkingBlockVisibility(); this.showStatus(`Thinking blocks: ${this.hideThinkingBlock ? "hidden" : "visible"}`); } @@ -4504,6 +4514,7 @@ export class InteractiveMode { tuiMode: this.ui.mode, fullscreenExitOutput: this.settingsManager.getFullscreenExitOutput(), fullscreenScrollbar: this.settingsManager.getFullscreenScrollbar(), + fullscreenCopyOnSelect: this.settingsManager.getFullscreenCopyOnSelect(), warnings: this.settingsManager.getWarnings(), }, { @@ -4581,13 +4592,7 @@ export class InteractiveMode { onHideThinkingBlockChange: (hidden) => { this.hideThinkingBlock = hidden; this.settingsManager.setHideThinkingBlock(hidden); - for (const child of this.chatContainer.children) { - if (child instanceof AssistantMessageComponent) { - child.setHideThinkingBlock(hidden); - } - } - this.chatContainer.clear(); - this.rebuildChatFromMessages(); + this.updateThinkingBlockVisibility(); }, onMermaidRenderingModeChange: (mode) => { this.settingsManager.setMermaidRenderingMode(mode); @@ -4682,6 +4687,10 @@ export class InteractiveMode { this.settingsManager.setFullscreenScrollbar(mode); this.applyFullscreenScrollbarSetting(); }, + onFullscreenCopyOnSelectChange: (enabled) => { + this.settingsManager.setFullscreenCopyOnSelect(enabled); + if (this.renderer instanceof TuiAltScreen) this.renderer.setCopyOnSelect(enabled); + }, onWarningsChange: (warnings) => { this.settingsManager.setWarnings(warnings); }, @@ -4898,6 +4907,7 @@ export class InteractiveMode { const selectModel = async (model: Model, persist: boolean) => { try { await this.session.setModel(model, { persist }); + this.updateAvailableProviderCount(); this.footer.invalidate(); this.updateEditorBorderColor(); done(); @@ -5906,8 +5916,8 @@ export class InteractiveMode { activeHeader.setExpanded(this.toolOutputExpanded); } setRegisteredThemes(this.session.resourceLoader.getThemes().themes); - await this.themeController.applyFromSettings(); this.applyRuntimeSettings(); + await this.themeController.applyFromSettings(); this.setupAutocompleteProvider(); const runner = this.session.extensionRunner; this.setupExtensionShortcuts(runner); @@ -6037,7 +6047,19 @@ export class InteractiveMode { }); } - private async handleCopyCommand(options: { flashConfirmation?: boolean } = {}): Promise { + 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/src/modes/interactive/tui-renderer.ts b/packages/coding-agent/src/modes/interactive/tui-renderer.ts index 2ed2d9905..c71bfa4f0 100644 --- a/packages/coding-agent/src/modes/interactive/tui-renderer.ts +++ b/packages/coding-agent/src/modes/interactive/tui-renderer.ts @@ -10,6 +10,7 @@ export interface InteractiveTuiOptions { readonly logDirectory: string; readonly terminal?: Terminal; readonly onRightClickPaste?: () => void; + readonly fullscreenCopyOnSelect?: boolean; } /** Composition root shared by coding-agent presentations. */ @@ -25,6 +26,7 @@ export function createInteractiveTui(options: InteractiveTuiOptions): TuiMainScr searchCurrentMatchStyle: (text) => theme.bold(theme.inverse(styleSearchMatch(text))), openUrl: openBrowser, onRightClickPaste: options.onRightClickPaste, + copyOnSelect: options.fullscreenCopyOnSelect, copySelection: async (text) => { try { await copyToClipboard(text); 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 {}); } 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/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", }); }); 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/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}`]); + }); + }); }); diff --git a/packages/coding-agent/test/rpc-client-clear-queue.test.ts b/packages/coding-agent/test/rpc-client-clear-queue.test.ts new file mode 100644 index 000000000..c81e3cc08 --- /dev/null +++ b/packages/coding-agent/test/rpc-client-clear-queue.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from "vitest"; +import { RpcClient } from "../src/modes/rpc/rpc-client.ts"; + +type RpcClientPrivate = { + send: (command: { type: string }) => Promise; + 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(); + } + }); }); 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"], diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index 3a2dbd0f7..685243254 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); @@ -436,13 +455,16 @@ describe("SettingsManager", () => { const manager = SettingsManager.create(projectDir, agentDir); expect(manager.getFullscreenExitOutput()).toBe("transcript"); expect(manager.getFullscreenScrollbar()).toBe("auto"); + expect(manager.getFullscreenCopyOnSelect()).toBe(true); manager.setFullscreenExitOutput("resume-hint"); manager.setFullscreenScrollbar("hidden"); + manager.setFullscreenCopyOnSelect(false); await manager.flush(); const savedSettings = JSON.parse(readFileSync(join(agentDir, "settings.json"), "utf-8")); expect(savedSettings.fullscreenExitOutput).toBe("resume-hint"); expect(savedSettings.fullscreenScrollbar).toBe("hidden"); + expect(savedSettings.fullscreenCopyOnSelect).toBe(false); writeFileSync( join(agentDir, "settings.json"), @@ -451,6 +473,7 @@ describe("SettingsManager", () => { const reloadedManager = SettingsManager.create(projectDir, agentDir); expect(reloadedManager.getFullscreenExitOutput()).toBe("transcript"); expect(reloadedManager.getFullscreenScrollbar()).toBe("auto"); + expect(reloadedManager.getFullscreenCopyOnSelect()).toBe(true); }); describe("outputPad", () => { 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/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(); + }); +}); 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(); + } + }); +}); 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"); + }); +}); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index cc92212ee..15ae98abc 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,17 @@ ## [Unreleased] +### Added + +- Added environment and programmatic overrides for OSC 8 hyperlinks, inline image protocols, and truecolor terminal capabilities ([#8665](https://github.com/earendil-works/pi/issues/8665)). +- Added a `TuiAltScreen` `copyOnSelect` option plus helpers to detect and copy the active fullscreen text selection programmatically ([#7720](https://github.com/earendil-works/pi/issues/7720)). + +### Fixed + +- Fixed main-screen rendering crashing when image-heavy output exceeded V8's string length limit ([#8028](https://github.com/earendil-works/pi/issues/8028)). +- Fixed autocomplete ordering for nested results ([#8669](https://github.com/earendil-works/pi/pull/8669)). +- Fixed fullscreen double-click word selection splitting paths and kebab-case tokens on `/` and `-` ([#7746](https://github.com/earendil-works/pi/issues/7746)). + ## [0.84.3] - 2026-08-24 ### Fixed diff --git a/packages/tui/README.md b/packages/tui/README.md index 9b799bdd0..052105402 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -705,7 +705,7 @@ if (matchesKey(data, Key.enter)) { 2. **Width Changed or Change Above Viewport**: Clear screen and fully re-render 3. **Normal Update**: Move the cursor to the first changed line, clear to the end, and render changed lines -`TuiAltScreen` owns a terminal-height viewport. Without an explicit layout root it preserves the legacy single-document scrolling behavior. With `setLayoutRoot()`, `VStack`, `HStack`, and nested `ScrollView` components can reserve fixed regions and independently scroll constrained regions. It updates changed viewport rows in place, follows streaming output while at the bottom, and preserves a manually selected scroll position while content grows. Mouse-wheel and configurable keyboard navigation scroll without modifying terminal scrollback, including jumps between OSC 133 semantic prompt markers. Clicking an OSC 8 hyperlink opens it with the configured URL handler. Dragging with the primary mouse button selects text and copies it to the clipboard with OSC 52; holding the drag at a scroll view's top or bottom edge auto-scrolls and extends the selection into off-screen content. Kitty images support vertical viewport cropping; iTerm2 inline images fall back to text because the iTerm2 protocol cannot delete or crop placements during viewport repainting. +`TuiAltScreen` owns a terminal-height viewport. Without an explicit layout root it preserves the legacy single-document scrolling behavior. With `setLayoutRoot()`, `VStack`, `HStack`, and nested `ScrollView` components can reserve fixed regions and independently scroll constrained regions. It updates changed viewport rows in place, follows streaming output while at the bottom, and preserves a manually selected scroll position while content grows. Mouse-wheel and configurable keyboard navigation scroll without modifying terminal scrollback, including jumps between OSC 133 semantic prompt markers. Clicking an OSC 8 hyperlink opens it with the configured URL handler. Dragging with the primary mouse button selects text and, unless `TuiAltScreenOptions.copyOnSelect` is `false`, copies it to the clipboard with OSC 52; holding the drag at a scroll view's top or bottom edge auto-scrolls and extends the selection into off-screen content. Kitty images support vertical viewport cropping; iTerm2 inline images fall back to text because the iTerm2 protocol cannot delete or crop placements during viewport repainting. Both renderers wrap updates in **synchronized output** (`\x1b[?2026h` ... `\x1b[?2026l`) for atomic, flicker-free rendering. 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/src/index.ts b/packages/tui/src/index.ts index cd1707235..837e6216c 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -111,6 +111,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/src/tui-alt-screen.ts b/packages/tui/src/tui-alt-screen.ts index f2d9b62cc..ff59cf34f 100644 --- a/packages/tui/src/tui-alt-screen.ts +++ b/packages/tui/src/tui-alt-screen.ts @@ -75,6 +75,9 @@ const MAX_CACHED_OFFSCREEN_KITTY_IMAGES = 16; const MAX_CACHED_OFFSCREEN_KITTY_TRANSMISSION_BYTES = 32 * 1024 * 1024; const MAX_CACHED_OFFSCREEN_KITTY_DECODED_BYTES = 64 * 1024 * 1024; const DOUBLE_CLICK_INTERVAL_MS = 500; +// Regular mode delegates double-click selection to the terminal emulator. Fullscreen owns mouse selection, +// so mirror common terminal word-selection behavior by keeping paths and kebab-case tokens whole. +const TERMINAL_WORD_SELECTION_JOINERS = new Set(["/", "-"]); const wordSegmenter = getWordSegmenter(); interface CachedKittyImage { @@ -163,6 +166,8 @@ export interface TuiAltScreenOptions { openUrl?: (url: string) => void; /** Handle an unmodified secondary-button press for clipboard paste. Currently enabled on Windows only. */ onRightClickPaste?: () => void; + /** Automatically copy selected text to the clipboard on mouse release (default: true). */ + copyOnSelect?: boolean; /** * Copy selected text to the system clipboard. Return `true` on success; the caller flashes * an error otherwise. When omitted, the selection is copied via an OSC 52 write. @@ -218,6 +223,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { private readonly searchCurrentMatchStyle: (text: string) => string; private readonly openUrl?: (url: string) => void; private readonly onRightClickPaste?: () => void; + private copyOnSelect: boolean; private readonly copySelection?: (text: string) => Promise; constructor( @@ -242,6 +248,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { this.searchCurrentMatchStyle = options.searchCurrentMatchStyle ?? ((text) => `\x1b[1;7m${text}\x1b[22;27m`); this.openUrl = options.openUrl; this.onRightClickPaste = options.onRightClickPaste; + this.copyOnSelect = options.copyOnSelect ?? true; this.copySelection = options.copySelection; this.addInputListener((data) => this.handleViewportInput(data)); } @@ -254,6 +261,26 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { return this.getPrimaryScrollView().isFollowingEnd; } + getCopyOnSelect(): boolean { + return this.copyOnSelect; + } + + setCopyOnSelect(enabled: boolean): void { + this.copyOnSelect = enabled; + } + + /** Whether the fullscreen viewport has a non-empty active text selection. */ + hasActiveSelection(): boolean { + return this.getActiveSelectionText() !== undefined; + } + + /** Copy the active fullscreen text selection, if any, using the configured selection clipboard path. */ + async copyActiveSelectionToClipboard(): Promise { + const text = this.getActiveSelectionText(); + if (!text) return false; + return this.copyTextToClipboard(text); + } + setLayoutRoot(component: Component | undefined): void { if (this.layoutRoot === component) return; this.layoutRoot = component; @@ -1039,18 +1066,39 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { private getWordSelection(point: SelectionPoint): SelectionRange | undefined { const line = stripTerminalSequences(this.getSelectionSourceLine(point)); + const segments: Array<{ start: number; end: number; selectable: boolean; joiner: boolean }> = []; let start = 0; for (const segment of wordSegmenter.segment(line)) { const end = start + visibleWidth(segment.segment); - if (point.col >= start && point.col < end) { - return { - start: { ...point, col: start }, - end: { ...point, col: end, boundary: true }, - }; - } + const joiner = TERMINAL_WORD_SELECTION_JOINERS.has(segment.segment); + segments.push({ start, end, selectable: segment.isWordLike === true || joiner, joiner }); start = end; } - return undefined; + const clickedSegmentIndex = segments.findIndex( + (segment) => point.col >= segment.start && point.col < segment.end, + ); + if (clickedSegmentIndex < 0) return undefined; + + const canJoin = ( + left: { selectable: boolean; joiner: boolean }, + right: { selectable: boolean; joiner: boolean }, + ): boolean => left.selectable && right.selectable && (left.joiner || right.joiner); + let selectionStart = segments[clickedSegmentIndex].start; + let selectionEnd = segments[clickedSegmentIndex].end; + for (let index = clickedSegmentIndex; index > 0 && canJoin(segments[index - 1], segments[index]); index--) { + selectionStart = segments[index - 1].start; + } + for ( + let index = clickedSegmentIndex; + index < segments.length - 1 && canJoin(segments[index], segments[index + 1]); + index++ + ) { + selectionEnd = segments[index + 1].end; + } + return { + start: { ...point, col: selectionStart }, + end: { ...point, col: selectionEnd, boundary: true }, + }; } private getLineSelection(point: SelectionPoint): SelectionRange { @@ -1203,7 +1251,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { return; } } - void this.copySelectionToClipboard(); + if (this.copyOnSelect) void this.copySelectionToClipboard(); this.requestRender(); return; } @@ -1279,14 +1327,14 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { return { start: Math.max(minColumn, start), end: Math.min(maxColumn, end) }; } - private async copySelectionToClipboard(): Promise { + private getActiveSelectionText(): string | undefined { const selection = this.getSelectionBounds(); - if (!selection) return; + if (!selection) return undefined; let sourceLines: readonly string[] = this.previousScreen; if (selection.start.scrollView) { - if (!this.currentLayout) return; + if (!this.currentLayout) return undefined; const box = getScrollViewBox(this.currentLayout, selection.start.scrollView); - if (!box?.scrollContentLines) return; + if (!box?.scrollContentLines) return undefined; sourceLines = box.scrollContentLines; } const lines: string[] = []; @@ -1300,7 +1348,16 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { ); } const text = lines.join("\n"); - if (text.length === 0) return; + return text.length === 0 ? undefined : text; + } + + private async copySelectionToClipboard(): Promise { + 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 @@ -1308,10 +1365,11 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { if (this.copySelection) { const ok = await this.copySelection(text); this.flash(ok ? "Copied!" : "Copy failed"); - return; + return ok; } this.terminal.write(`\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`); this.flash("Copied!"); + return true; } private applySearchTextHighlight(text: string, current: boolean): string { 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/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"], 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); diff --git a/packages/tui/test/tui-alt-screen.test.ts b/packages/tui/test/tui-alt-screen.test.ts index 15047d125..698f75d20 100644 --- a/packages/tui/test/tui-alt-screen.test.ts +++ b/packages/tui/test/tui-alt-screen.test.ts @@ -1040,6 +1040,67 @@ describe("TuiAltScreen", () => { tui.stop(); }); + it("leaves selections visible without copying when copyOnSelect is disabled", async () => { + const terminal = new RecordingTerminal(20, 4); + const copied: string[] = []; + const tui = new TuiAltScreen(terminal, undefined, undefined, { + copyOnSelect: false, + copySelection: async (text) => { + copied.push(text); + return true; + }, + }); + tui.addChild(new Text("alpha\nbeta\ngamma\ndelta", 0, 0)); + tui.start(); + await terminal.waitForRender(); + + terminal.sendInput("\x1b[<0;1;1M"); + terminal.sendInput("\x1b[<32;4;2M"); + terminal.sendInput("\x1b[<0;4;2m"); + await terminal.waitForRender(); + + assert.deepStrictEqual(copied, []); + assert.strictEqual(tui.hasActiveSelection(), true); + assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b[7m"))); + assert.ok(terminal.getViewport().every((line) => !line.includes("Copied!"))); + + tui.stop(); + }); + + it("copies an active selection programmatically", async () => { + const terminal = new RecordingTerminal(20, 4); + const copied: string[] = []; + const tui = new TuiAltScreen(terminal, undefined, undefined, { + copySelection: async (text) => { + copied.push(text); + return true; + }, + }); + tui.addChild(new Text("alpha\nbeta\ngamma\ndelta", 0, 0)); + tui.start(); + await terminal.waitForRender(); + + assert.strictEqual(tui.hasActiveSelection(), false); + assert.strictEqual(await tui.copyActiveSelectionToClipboard(), false); + + terminal.sendInput("\x1b[<0;1;1M"); + terminal.sendInput("\x1b[<32;4;2M"); + terminal.sendInput("\x1b[<0;4;2m"); + await terminal.waitForRender(); + + assert.deepStrictEqual(copied, ["alpha\nbeta"]); + assert.strictEqual(tui.hasActiveSelection(), true); + + copied.length = 0; + assert.strictEqual(await tui.copyActiveSelectionToClipboard(), true); + await terminal.waitForRender(); + + assert.deepStrictEqual(copied, ["alpha\nbeta"]); + assert.ok(terminal.getViewport().some((line) => line.includes("Copied!"))); + + tui.stop(); + }); + it("flashes an error when the injected copySelection handler fails", async () => { const terminal = new RecordingTerminal(20, 4); const tui = new TuiAltScreen(terminal, undefined, undefined, { @@ -1079,6 +1140,35 @@ describe("TuiAltScreen", () => { tui.stop(); }); + it("coalesces slash and hyphen separated segments for double-click word selection", async () => { + for (const { line, needle } of [ + { line: "extensions/starline/fixed-editor/compositor.ts", needle: "starline" }, + { line: "earendil-works/pi-tui", needle: "works" }, + ]) { + const copied: string[] = []; + const terminal = new RecordingTerminal(80, 1); + const tui = new TuiAltScreen(terminal, undefined, undefined, { + copySelection: async (text) => { + copied.push(text); + return true; + }, + }); + tui.addChild(new Text(line, 0, 0)); + tui.start(); + await terminal.waitForRender(); + + const oneBasedClickColumn = line.indexOf(needle) + 1; + terminal.sendInput(`\x1b[<0;${oneBasedClickColumn};1M`); + terminal.sendInput(`\x1b[<0;${oneBasedClickColumn};1m`); + terminal.sendInput(`\x1b[<0;${oneBasedClickColumn};1M`); + terminal.sendInput(`\x1b[<0;${oneBasedClickColumn};1m`); + await terminal.waitForRender(); + + assert.deepStrictEqual(copied, [line]); + tui.stop(); + } + }); + it("highlights a complete whitespace segment during a word drag", async () => { const terminal = new RecordingTerminal(20, 1); const tui = new TuiAltScreen(terminal); 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 }); diff --git a/scripts/build-coding-agent-bundle.mjs b/scripts/build-coding-agent-bundle.mjs index 397141bfa..2c5d49a8c 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