mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
Merge branch 'main' into dev
# Conflicts: # packages/agent/CHANGELOG.md # packages/ai/scripts/generate-models.ts # packages/ai/src/providers/cloudflare-ai-gateway.ts # packages/ai/test/zai-coding-plan-models.test.ts # packages/coding-agent/src/modes/interactive/interactive-mode.ts # packages/tui/src/tui-alt-screen.ts
This commit is contained in:
commit
758a0ee9b7
67 changed files with 1956 additions and 188 deletions
8
.github/APPROVED_CONTRIBUTORS
vendored
8
.github/APPROVED_CONTRIBUTORS
vendored
|
|
@ -375,3 +375,11 @@ gaoyk19 pr
|
|||
cad0p pr
|
||||
|
||||
Jaaneek pr
|
||||
|
||||
CaiJichang212 pr
|
||||
|
||||
Mallikarjun-0 pr
|
||||
|
||||
wutongyuonce pr
|
||||
|
||||
Terminator666666 pr
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
16
packages/agent/src/harness/env/nodejs.ts
vendored
16
packages/agent/src/harness/env/nodejs.ts
vendored
|
|
@ -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.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,10 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSy
|
|||
import { dirname, join, resolve } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { getEffortThinkingLevelMap, type ModelsDevReasoningOption } from "./models-dev-reasoning-options.ts";
|
||||
import {
|
||||
getOpenRouterThinkingLevelMap,
|
||||
type OpenRouterReasoningMetadata,
|
||||
} from "./openrouter-reasoning-options.ts";
|
||||
import {
|
||||
CLOUDFLARE_AI_GATEWAY_ANTHROPIC_BASE_URL,
|
||||
CLOUDFLARE_AI_GATEWAY_COMPAT_BASE_URL,
|
||||
|
|
@ -127,6 +131,25 @@ interface NvidiaNimModelListItem {
|
|||
id: string;
|
||||
}
|
||||
|
||||
interface OpenRouterModelListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
supported_parameters?: string[];
|
||||
architecture?: { modality?: string };
|
||||
pricing?: {
|
||||
prompt?: string;
|
||||
completion?: string;
|
||||
input_cache_read?: string;
|
||||
input_cache_write?: string;
|
||||
};
|
||||
top_provider?: {
|
||||
context_length?: number;
|
||||
max_completion_tokens?: number;
|
||||
};
|
||||
context_length?: number;
|
||||
reasoning?: OpenRouterReasoningMetadata;
|
||||
}
|
||||
|
||||
interface AiGatewayModel {
|
||||
id: string;
|
||||
name?: string;
|
||||
|
|
@ -1051,11 +1074,11 @@ async function fetchOpenRouterModels(): Promise<Model<any>[]> {
|
|||
console.log("Fetching models from OpenRouter API...");
|
||||
const response = await fetch("https://openrouter.ai/api/v1/models");
|
||||
if (!response.ok) throw new Error(`OpenRouter API returned ${response.status}`);
|
||||
const data = await response.json();
|
||||
const data = (await response.json()) as { data?: OpenRouterModelListItem[] };
|
||||
|
||||
const models: Model<any>[] = [];
|
||||
|
||||
for (const model of data.data) {
|
||||
for (const model of data.data ?? []) {
|
||||
// Only include models that support tools
|
||||
if (!model.supported_parameters?.includes("tools")) continue;
|
||||
|
||||
|
|
@ -1078,6 +1101,7 @@ async function fetchOpenRouterModels(): Promise<Model<any>[]> {
|
|||
const cacheWriteCost = roundCost(parseFloat(model.pricing?.input_cache_write || "0") * 1_000_000);
|
||||
|
||||
const contextWindow = model.top_provider?.context_length || model.context_length || 4096;
|
||||
const thinkingLevelMap = getOpenRouterThinkingLevelMap(model.reasoning);
|
||||
|
||||
const normalizedModel: Model<any> = {
|
||||
id: modelKey,
|
||||
|
|
@ -1086,6 +1110,7 @@ async function fetchOpenRouterModels(): Promise<Model<any>[]> {
|
|||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
provider,
|
||||
reasoning: model.supported_parameters?.includes("reasoning") || false,
|
||||
...(thinkingLevelMap && { thinkingLevelMap }),
|
||||
input,
|
||||
cost: {
|
||||
input: inputCost,
|
||||
|
|
@ -1666,7 +1691,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||
}
|
||||
|
||||
// Process Cloudflare AI Gateway models
|
||||
const gatewayIds = new Set<string>();
|
||||
const cloudflareAIGatewayModelIds = new Set<string>();
|
||||
if (data["cloudflare-ai-gateway"]?.models) {
|
||||
for (const [prefixedId, model] of Object.entries(data["cloudflare-ai-gateway"].models)) {
|
||||
const m = model as ModelsDevModel;
|
||||
|
|
@ -1701,6 +1726,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||
const compat =
|
||||
upstream === "anthropic" || upstream === "workers-ai" ? { sendSessionAffinityHeaders: true } : undefined;
|
||||
|
||||
cloudflareAIGatewayModelIds.add(id);
|
||||
models.push({
|
||||
id,
|
||||
name: m.name || id,
|
||||
|
|
@ -1719,22 +1745,22 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||
maxTokens: m.limit?.output || 4096,
|
||||
...(compat ? { compat } : {}),
|
||||
});
|
||||
gatewayIds.add(id);
|
||||
recordModelsDevReasoningOptions("cloudflare-ai-gateway", id, m);
|
||||
}
|
||||
}
|
||||
|
||||
// The gateway proxies Workers AI through its OpenAI-compatible endpoint, but
|
||||
// models.dev's gateway catalog only lists `workers-ai/*` entries intermittently.
|
||||
// Derive them from the Workers AI catalog so the gateway's openai-completions
|
||||
// models stay stable regardless of that duplication.
|
||||
// The gateway proxies Workers AI through its OpenAI-compatible /compat endpoint,
|
||||
// but models.dev may omit or intermittently drop those `workers-ai/*` entries
|
||||
// from the AI Gateway catalog. Mirror the Workers AI catalog under the documented
|
||||
// prefix so the gateway keeps its OpenAI-compatible models stable.
|
||||
if (data["cloudflare-workers-ai"]?.models) {
|
||||
for (const [modelId, model] of Object.entries(data["cloudflare-workers-ai"].models)) {
|
||||
const m = model as ModelsDevModel;
|
||||
if (m.tool_call !== true) continue;
|
||||
|
||||
const id = `workers-ai/${modelId}`;
|
||||
if (gatewayIds.has(id)) continue;
|
||||
if (cloudflareAIGatewayModelIds.has(id)) continue;
|
||||
cloudflareAIGatewayModelIds.add(id);
|
||||
|
||||
models.push({
|
||||
id,
|
||||
|
|
@ -1754,7 +1780,6 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||
maxTokens: m.limit?.output || 4096,
|
||||
compat: { sendSessionAffinityHeaders: true },
|
||||
});
|
||||
gatewayIds.add(id);
|
||||
recordModelsDevReasoningOptions("cloudflare-ai-gateway", id, m);
|
||||
}
|
||||
}
|
||||
|
|
@ -2542,6 +2567,24 @@ async function generateModels() {
|
|||
maxTokens: 384000,
|
||||
compat: deepseekCompat,
|
||||
},
|
||||
{
|
||||
id: "deepseek-v4-flash-vision-exp",
|
||||
name: "DeepSeek V4 Flash Vision Exp",
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.deepseek.com",
|
||||
provider: "deepseek",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: {
|
||||
input: 0.14,
|
||||
output: 0.28,
|
||||
cacheRead: 0.0028,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 1000000,
|
||||
maxTokens: 384000,
|
||||
compat: deepseekCompat,
|
||||
},
|
||||
{
|
||||
id: "deepseek-v4-pro",
|
||||
name: "DeepSeek V4 Pro",
|
||||
|
|
|
|||
23
packages/ai/scripts/openrouter-reasoning-options.ts
Normal file
23
packages/ai/scripts/openrouter-reasoning-options.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import type { ThinkingLevel, ThinkingLevelMap } from "../src/types.ts";
|
||||
import { getEffortThinkingLevelMap } from "./models-dev-reasoning-options.ts";
|
||||
|
||||
export interface OpenRouterReasoningMetadata {
|
||||
mandatory?: boolean;
|
||||
default_enabled?: boolean;
|
||||
supported_efforts?: Array<ThinkingLevel | "none">;
|
||||
default_effort?: ThinkingLevel | "none";
|
||||
}
|
||||
|
||||
/** Convert OpenRouter's reasoning metadata into Pi model capabilities. */
|
||||
export function getOpenRouterThinkingLevelMap(
|
||||
reasoning: OpenRouterReasoningMetadata | undefined,
|
||||
): ThinkingLevelMap | undefined {
|
||||
if (!reasoning) return undefined;
|
||||
if (!reasoning.supported_efforts?.length) return reasoning.mandatory === true ? { off: null } : undefined;
|
||||
|
||||
// OpenRouter's supported_efforts uses the same effort values as models.dev reasoning_options,
|
||||
// so both sources can share the same Pi thinking-level conversion.
|
||||
const map = getEffortThinkingLevelMap([{ type: "effort", values: reasoning.supported_efforts }]);
|
||||
if (!map) return reasoning.mandatory === true ? { off: null } : undefined;
|
||||
return { ...map, off: reasoning.mandatory === true ? null : "none" };
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -6,14 +6,14 @@ import { CLOUDFLARE_AI_GATEWAY_MODELS } from "./cloudflare-ai-gateway.models.ts"
|
|||
import { cloudflareAIGatewayAuth } from "./cloudflare-auth.ts";
|
||||
import { cloudflareStreams } from "./cloudflare-stream.ts";
|
||||
|
||||
export function cloudflareAIGatewayProvider(): Provider<
|
||||
"anthropic-messages" | "openai-completions" | "openai-responses"
|
||||
> {
|
||||
type CloudflareAIGatewayApi = "anthropic-messages" | "openai-completions" | "openai-responses";
|
||||
|
||||
export function cloudflareAIGatewayProvider(): Provider<CloudflareAIGatewayApi> {
|
||||
// The api map is pinned to all three APIs: models.dev's gateway catalog drops and
|
||||
// restores `workers-ai/*` (openai-completions) entries over time, and inference from
|
||||
// `models` alone would otherwise reject the openai-completions entry whenever the
|
||||
// generated catalog happens to contain none.
|
||||
return createProvider<"anthropic-messages" | "openai-completions" | "openai-responses">({
|
||||
return createProvider<CloudflareAIGatewayApi>({
|
||||
id: "cloudflare-ai-gateway",
|
||||
name: "Cloudflare AI Gateway",
|
||||
auth: { apiKey: cloudflareAIGatewayAuth() },
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
109
packages/ai/test/openrouter-reasoning-options.test.ts
Normal file
109
packages/ai/test/openrouter-reasoning-options.test.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { getOpenRouterThinkingLevelMap } from "../scripts/openrouter-reasoning-options.ts";
|
||||
import { streamSimple } from "../src/api/openai-completions.ts";
|
||||
import type { Context, Model, ThinkingLevelMap } from "../src/types.ts";
|
||||
|
||||
const context: Context = {
|
||||
messages: [{ role: "user", content: "Hello", timestamp: 0 }],
|
||||
};
|
||||
|
||||
function openRouterModel(thinkingLevelMap?: ThinkingLevelMap): Model<"openai-completions"> {
|
||||
return {
|
||||
id: "stealth/ox-alpha",
|
||||
name: "Ox Alpha",
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://example.invalid/v1",
|
||||
reasoning: true,
|
||||
thinkingLevelMap,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128000,
|
||||
maxTokens: 4096,
|
||||
compat: { thinkingFormat: "openrouter" },
|
||||
};
|
||||
}
|
||||
|
||||
async function capturePayload(model: Model<"openai-completions">, reasoning?: "low") {
|
||||
let payload: { reasoning?: { effort?: string } } | undefined;
|
||||
await streamSimple(model, context, {
|
||||
apiKey: "test",
|
||||
reasoning,
|
||||
onPayload: (request) => {
|
||||
payload = request as { reasoning?: { effort?: string } };
|
||||
throw new Error("payload captured");
|
||||
},
|
||||
}).result();
|
||||
if (!payload) throw new Error("OpenRouter payload was not captured");
|
||||
return payload;
|
||||
}
|
||||
|
||||
describe("getOpenRouterThinkingLevelMap", () => {
|
||||
it("marks mandatory reasoning and unsupported efforts unavailable", () => {
|
||||
expect(
|
||||
getOpenRouterThinkingLevelMap({
|
||||
mandatory: true,
|
||||
default_enabled: true,
|
||||
supported_efforts: ["max", "high", "low"],
|
||||
default_effort: "max",
|
||||
}),
|
||||
).toEqual({
|
||||
off: null,
|
||||
minimal: null,
|
||||
low: "low",
|
||||
medium: null,
|
||||
high: "high",
|
||||
xhigh: null,
|
||||
max: "max",
|
||||
});
|
||||
});
|
||||
|
||||
it("still marks off unavailable when OpenRouter omits effort metadata", () => {
|
||||
expect(getOpenRouterThinkingLevelMap({ mandatory: true })).toEqual({ off: null });
|
||||
});
|
||||
|
||||
it("keeps off available while restricting optional models to supported efforts", () => {
|
||||
expect(
|
||||
getOpenRouterThinkingLevelMap({
|
||||
mandatory: false,
|
||||
default_enabled: true,
|
||||
supported_efforts: ["high", "low"],
|
||||
}),
|
||||
).toEqual({
|
||||
off: "none",
|
||||
minimal: null,
|
||||
low: "low",
|
||||
medium: null,
|
||||
high: "high",
|
||||
xhigh: null,
|
||||
max: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not add metadata for optional models without effort controls", () => {
|
||||
expect(getOpenRouterThinkingLevelMap({ mandatory: false })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpenRouter mandatory reasoning payloads", () => {
|
||||
const mandatoryMap = getOpenRouterThinkingLevelMap({
|
||||
mandatory: true,
|
||||
supported_efforts: ["max", "high", "low"],
|
||||
});
|
||||
|
||||
it("omits reasoning when a background call does not request it", async () => {
|
||||
expect(await capturePayload(openRouterModel(mandatoryMap))).not.toHaveProperty("reasoning");
|
||||
});
|
||||
|
||||
it("still sends an explicitly selected supported effort", async () => {
|
||||
expect(await capturePayload(openRouterModel(mandatoryMap), "low")).toMatchObject({
|
||||
reasoning: { effort: "low" },
|
||||
});
|
||||
});
|
||||
|
||||
it("continues to explicitly disable reasoning for optional models", async () => {
|
||||
expect(await capturePayload(openRouterModel())).toMatchObject({
|
||||
reasoning: { effort: "none" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ Then just talk to pi. By default, pi gives the model four tools: `read`, `write`
|
|||
|
||||
## Providers & Models
|
||||
|
||||
For each built-in provider, pi maintains a list of tool-capable models. Configured provider catalogs refresh automatically; run `pi update --models` to force an immediate refresh. Authenticate via subscription (`/login`) or API key, then select any model from that provider via `/model` (or Ctrl+L).
|
||||
For each built-in provider, pi maintains a list of tool-capable models. Configured provider catalogs refresh automatically; run `pi update --models` to force an immediate refresh. Authenticate via subscription (`/login`) or API key, then select any model from that provider via `/model` (or Ctrl+L). Press Ctrl+S in the model picker to save the highlighted model as the startup default.
|
||||
|
||||
**Subscriptions:**
|
||||
- Anthropic Claude Pro/Max
|
||||
|
|
@ -178,9 +178,10 @@ Type `/` in the editor to trigger commands. [Extensions](#extensions) can regist
|
|||
|---------|-------------|
|
||||
| `/login`, `/logout` | Manage provider credentials |
|
||||
| [`/llama`](docs/llama-cpp.md) | Download, load, and unload llama.cpp router models |
|
||||
| `/model` | Switch models |
|
||||
| `/model` | Switch models; Ctrl+S in the picker saves the startup default |
|
||||
| `/thinking` | Switch thinking level; Ctrl+S in the picker saves the startup default |
|
||||
| `/scoped-models` | Enable/disable models for Ctrl+P cycling |
|
||||
| `/settings` | Thinking level, theme, message delivery, transport |
|
||||
| `/settings` | Theme, message delivery, transport, and other preferences |
|
||||
| `/resume` | Pick from previous sessions |
|
||||
| `/new` | Start a new session |
|
||||
| `/name <name>` | Set session display name |
|
||||
|
|
@ -216,7 +217,7 @@ See `/hotkeys` for the full list. Customize via `~/.pi/agent/keybindings.json`.
|
|||
| Shift+Tab | Cycle thinking level |
|
||||
| Ctrl+O | Collapse/expand tool output |
|
||||
| Ctrl+T | Collapse/expand thinking blocks |
|
||||
| Ctrl+X | Copy the last assistant message |
|
||||
| Ctrl+X | Copy the last assistant message; with fullscreen copy-on-select disabled, copy the active text selection |
|
||||
|
||||
### Message Queue
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ The editor can be replaced temporarily by built-in UI such as `/settings` or by
|
|||
| File reference | Type `@` to fuzzy-search project files |
|
||||
| Path completion | Press Tab to complete paths |
|
||||
| Multi-line input | Shift+Enter, or Ctrl+Enter on Windows Terminal |
|
||||
| Copy response | Ctrl+X copies the last assistant message; in `/tree`, it copies the selected message |
|
||||
| Copy response | Ctrl+X copies the selected message in `/tree`; otherwise it copies the last assistant message, or the active fullscreen text selection when `fullscreenCopyOnSelect` is disabled |
|
||||
| Images | Paste with Ctrl+V, Alt+V on Windows, or drag into the terminal |
|
||||
| Shell command | `!command` runs and sends output to the model |
|
||||
| Hidden shell command | `!!command` runs without sending output to the model |
|
||||
|
|
@ -38,9 +38,10 @@ Type `/` in the editor to open command completion. Extensions can register custo
|
|||
|---------|-------------|
|
||||
| `/login`, `/logout` | Manage OAuth or API-key credentials |
|
||||
| [`/llama`](llama-cpp.md) | Download, load, and unload llama.cpp router models |
|
||||
| `/model` | Switch models |
|
||||
| `/model` | Switch models; Ctrl+S in the picker saves the startup default |
|
||||
| `/thinking` | Switch thinking level; Ctrl+S in the picker saves the startup default |
|
||||
| `/scoped-models` | Enable/disable models for Ctrl+P cycling |
|
||||
| `/settings` | Thinking level, theme, message delivery, transport |
|
||||
| `/settings` | Theme, message delivery, transport, and other preferences |
|
||||
| `/resume` | Pick from previous sessions |
|
||||
| `/new` | Start a new session |
|
||||
| `/name <name>` | Set session display name |
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
import { ProcessTerminal, setKeybindings, type TUI, TuiMainScreen } from "@earendil-works/pi-tui";
|
||||
import {
|
||||
ProcessTerminal,
|
||||
setCapabilityOverrides,
|
||||
setKeybindings,
|
||||
type TUI,
|
||||
TuiMainScreen,
|
||||
} from "@earendil-works/pi-tui";
|
||||
import { existsSync } from "fs";
|
||||
import { APP_NAME, CONFIG_DIR_NAME, ENV_AGENT_DIR, getAgentDir, getSettingsPath, PACKAGE_NAME } from "../config.ts";
|
||||
import { areExperimentalFeaturesEnabled } from "../core/experimental.ts";
|
||||
|
|
@ -75,6 +81,7 @@ async function loadStartupThemes(settingsManager: SettingsManager): Promise<Them
|
|||
}
|
||||
|
||||
export async function createStartupTui(settingsManager: SettingsManager): Promise<TUI> {
|
||||
setCapabilityOverrides(settingsManager.getTerminalCapabilityOverrides());
|
||||
setRegisteredThemes(await loadStartupThemes(settingsManager));
|
||||
const terminalTheme = detectTerminalBackgroundFromEnv().theme;
|
||||
initTheme(resolveThemeSetting(settingsManager.getThemeSetting(), terminalTheme) ?? terminalTheme);
|
||||
|
|
|
|||
|
|
@ -327,6 +327,8 @@ export class AgentSession {
|
|||
private _followUpMessages: string[] = [];
|
||||
/** Messages queued to be included with the next user prompt as context ("asides"). */
|
||||
private _pendingNextTurnMessages: CustomMessage[] = [];
|
||||
/** Context-only custom messages queued during a run, flushed once the current turn's tool results are in. */
|
||||
private _pendingCustomMessages: CustomMessage[] = [];
|
||||
|
||||
// Compaction state
|
||||
private _compactionAbortController: AbortController | undefined = undefined;
|
||||
|
|
@ -689,6 +691,15 @@ export class AgentSession {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A turn ends after its assistant message and every tool result has been appended,
|
||||
// so this is the first point in the run where a context-only custom message can be
|
||||
// inserted without landing between a tool call and its result. Flushing after the
|
||||
// extension and listener dispatch above also picks up messages that turn_end
|
||||
// handlers queued.
|
||||
if (event.type === "turn_end") {
|
||||
this._flushPendingCustomMessages();
|
||||
}
|
||||
};
|
||||
|
||||
private _willRetryAfterAgentEnd(event: Extract<AgentEvent, { type: "agent_end" }>): boolean {
|
||||
|
|
@ -1081,6 +1092,7 @@ export class AgentSession {
|
|||
} finally {
|
||||
this._systemPromptOverride = undefined;
|
||||
this._flushPendingBashMessages();
|
||||
this._flushPendingCustomMessages();
|
||||
await this._emitAgentSettled();
|
||||
}
|
||||
}
|
||||
|
|
@ -1190,8 +1202,9 @@ export class AgentSession {
|
|||
return;
|
||||
}
|
||||
|
||||
// Flush any pending bash messages before the new prompt
|
||||
// Flush any pending bash and custom messages before the new prompt
|
||||
this._flushPendingBashMessages();
|
||||
this._flushPendingCustomMessages();
|
||||
|
||||
// Validate model
|
||||
if (!this.model) {
|
||||
|
|
@ -1436,8 +1449,9 @@ export class AgentSession {
|
|||
/**
|
||||
* Send a custom message to the session. Creates a CustomMessageEntry.
|
||||
*
|
||||
* Handles three cases:
|
||||
* Handles four cases:
|
||||
* - Streaming: queues message, processed when loop pulls from queue
|
||||
* - Streaming + triggerTurn false: appended to state/session once the current turn ends
|
||||
* - Not streaming + triggerTurn: appends to state/session, starts new turn
|
||||
* - Not streaming + no trigger: appends to state/session, no turn
|
||||
*
|
||||
|
|
@ -1468,16 +1482,40 @@ export class AgentSession {
|
|||
}
|
||||
} else if (options?.triggerTurn) {
|
||||
await this._runAgentPrompt(appMessage);
|
||||
} else if (this.isStreaming) {
|
||||
// Appending now would put the message between an assistant tool call and its
|
||||
// result, which providers that validate message order reject on replay. Defer
|
||||
// to the end of the turn. Nothing is emitted yet: message events must not
|
||||
// describe messages the session tree does not contain.
|
||||
this._pendingCustomMessages.push(appMessage);
|
||||
} else {
|
||||
this.agent.state.messages.push(appMessage);
|
||||
this.sessionManager.appendCustomMessageEntry(
|
||||
message.customType,
|
||||
message.content,
|
||||
message.display,
|
||||
message.details,
|
||||
);
|
||||
this._emit({ type: "message_start", message: appMessage });
|
||||
this._emit({ type: "message_end", message: appMessage });
|
||||
this._appendCustomMessage(appMessage);
|
||||
}
|
||||
}
|
||||
|
||||
private _appendCustomMessage(appMessage: CustomMessage): void {
|
||||
this.agent.state.messages.push(appMessage);
|
||||
this.sessionManager.appendCustomMessageEntry(
|
||||
appMessage.customType,
|
||||
appMessage.content,
|
||||
appMessage.display,
|
||||
appMessage.details,
|
||||
);
|
||||
this._emit({ type: "message_start", message: appMessage });
|
||||
this._emit({ type: "message_end", message: appMessage });
|
||||
}
|
||||
|
||||
/**
|
||||
* Append custom messages queued while the agent was running.
|
||||
* Called once the current turn's tool results are in agent state and session history.
|
||||
*/
|
||||
private _flushPendingCustomMessages(): void {
|
||||
if (this._pendingCustomMessages.length === 0) return;
|
||||
|
||||
const pending = this._pendingCustomMessages;
|
||||
this._pendingCustomMessages = [];
|
||||
for (const appMessage of pending) {
|
||||
this._appendCustomMessage(appMessage);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1606,6 +1644,7 @@ export class AgentSession {
|
|||
this.sessionManager.appendModelChange(model.provider, model.id);
|
||||
if (options.persist) {
|
||||
this.settingsManager.setDefaultModelAndProvider(model.provider, model.id);
|
||||
this._addPersistedDefaultToNonEmptyScope(model);
|
||||
}
|
||||
|
||||
// Apply thinking level for the new model.
|
||||
|
|
@ -1616,6 +1655,20 @@ export class AgentSession {
|
|||
await this._emitModelSelect(model, previousModel, "set");
|
||||
}
|
||||
|
||||
private _addPersistedDefaultToNonEmptyScope(model: Model<any>): void {
|
||||
if (this._scopedModels.length === 0) return;
|
||||
if (this._scopedModels.some((scoped) => modelsAreEqual(scoped.model, model))) return;
|
||||
|
||||
this._scopedModels = [...this._scopedModels, { model }];
|
||||
|
||||
const enabledModels = this.settingsManager.getEnabledModels();
|
||||
if (!enabledModels?.length) return;
|
||||
|
||||
const modelReference = `${model.provider}/${model.id}`;
|
||||
if (enabledModels.some((pattern) => pattern.toLowerCase() === modelReference.toLowerCase())) return;
|
||||
this.settingsManager.setEnabledModels([...enabledModels, modelReference]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cycle to next/previous model.
|
||||
* Uses scoped models (from --models flag) if available, otherwise all available models.
|
||||
|
|
@ -1658,6 +1711,7 @@ export class AgentSession {
|
|||
this.sessionManager.appendModelChange(next.model.provider, next.model.id);
|
||||
if (options.persist) {
|
||||
this.settingsManager.setDefaultModelAndProvider(next.model.provider, next.model.id);
|
||||
this._addPersistedDefaultToNonEmptyScope(next.model);
|
||||
}
|
||||
|
||||
// Apply thinking level for the new model.
|
||||
|
|
@ -1692,6 +1746,7 @@ export class AgentSession {
|
|||
this.sessionManager.appendModelChange(nextModel.provider, nextModel.id);
|
||||
if (options.persist) {
|
||||
this.settingsManager.setDefaultModelAndProvider(nextModel.provider, nextModel.id);
|
||||
this._addPersistedDefaultToNonEmptyScope(nextModel);
|
||||
}
|
||||
|
||||
// Apply thinking level for the new model.
|
||||
|
|
|
|||
|
|
@ -590,7 +590,6 @@ export async function completeSummarization(
|
|||
...options,
|
||||
cacheRetention: "none",
|
||||
sessionId: options.sessionId ?? uuidv7(),
|
||||
toolChoice: "none",
|
||||
};
|
||||
const produce = async (): Promise<AssistantMessage> =>
|
||||
streamFn
|
||||
|
|
|
|||
|
|
@ -166,6 +166,9 @@ export type {
|
|||
TreePreparation,
|
||||
TurnEndEvent,
|
||||
TurnStartEvent,
|
||||
UIPromptEndEvent,
|
||||
UIPromptKind,
|
||||
UIPromptStartEvent,
|
||||
// Events - User Bash
|
||||
UserBashEvent,
|
||||
UserBashEventResult,
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ import type {
|
|||
ToolCallEventResult,
|
||||
ToolResultEvent,
|
||||
ToolResultEventResult,
|
||||
UIPromptKind,
|
||||
UserBashEvent,
|
||||
UserBashEventResult,
|
||||
} from "./types.ts";
|
||||
|
|
@ -295,6 +296,8 @@ export class ExtensionRunner {
|
|||
private shortcutDiagnostics: ResourceDiagnostic[] = [];
|
||||
private commandDiagnostics: ResourceDiagnostic[] = [];
|
||||
private staleMessage: string | undefined;
|
||||
private uiPromptDepth = 0;
|
||||
private activeUIPrompt: { kind: UIPromptKind; title?: string } | undefined;
|
||||
|
||||
constructor(
|
||||
extensions: Extension[],
|
||||
|
|
@ -431,10 +434,57 @@ export class ExtensionRunner {
|
|||
}
|
||||
|
||||
setUIContext(uiContext?: ExtensionUIContext, mode: ExtensionMode = "print"): void {
|
||||
this.uiContext = uiContext ?? noOpUIContext;
|
||||
this.uiContext = uiContext ? this.wrapUIPromptContext(uiContext) : noOpUIContext;
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
private wrapUIPromptContext(ui: ExtensionUIContext): ExtensionUIContext {
|
||||
return {
|
||||
...ui,
|
||||
select: (title, options, opts) => this.withUIPrompt("select", title, () => ui.select(title, options, opts)),
|
||||
confirm: (title, message, opts) => this.withUIPrompt("confirm", title, () => ui.confirm(title, message, opts)),
|
||||
input: (title, placeholder, opts) =>
|
||||
this.withUIPrompt("input", title, () => ui.input(title, placeholder, opts)),
|
||||
editor: (title, prefill) => this.withUIPrompt("editor", title, () => ui.editor(title, prefill)),
|
||||
custom: (factory, options) => this.withUIPrompt("custom", undefined, () => ui.custom(factory, options)),
|
||||
};
|
||||
}
|
||||
|
||||
private withUIPrompt<T>(kind: UIPromptKind, title: string | undefined, run: () => Promise<T>): Promise<T> {
|
||||
const outerPrompt = this.uiPromptDepth++ === 0;
|
||||
if (outerPrompt) {
|
||||
this.activeUIPrompt = { kind, title };
|
||||
this.emitUIPromptEvent({ type: "ui_prompt_start", reason: "ui_prompt", kind, ...(title ? { title } : {}) });
|
||||
}
|
||||
|
||||
const finish = () => {
|
||||
if (--this.uiPromptDepth > 0) return;
|
||||
this.uiPromptDepth = 0;
|
||||
|
||||
const prompt = this.activeUIPrompt ?? { kind, title };
|
||||
this.activeUIPrompt = undefined;
|
||||
this.emitUIPromptEvent({
|
||||
type: "ui_prompt_end",
|
||||
reason: "ui_prompt",
|
||||
kind: prompt.kind,
|
||||
...(prompt.title ? { title: prompt.title } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
return run().finally(finish);
|
||||
} catch (err) {
|
||||
finish();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private emitUIPromptEvent(event: Extract<RunnerEmitEvent, { type: "ui_prompt_start" | "ui_prompt_end" }>): void {
|
||||
queueMicrotask(() => {
|
||||
void this.emit(event);
|
||||
});
|
||||
}
|
||||
|
||||
getUIContext(): ExtensionUIContext {
|
||||
return this.uiContext;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -742,6 +742,24 @@ export interface AgentSettledEvent {
|
|||
type: "agent_settled";
|
||||
}
|
||||
|
||||
export type UIPromptKind = "select" | "confirm" | "input" | "editor" | "custom";
|
||||
|
||||
/** Fired when Pi starts waiting on a blocking user-facing extension UI prompt. */
|
||||
export interface UIPromptStartEvent {
|
||||
type: "ui_prompt_start";
|
||||
reason: "ui_prompt";
|
||||
kind: UIPromptKind;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/** Fired when Pi is no longer waiting on a blocking user-facing extension UI prompt. */
|
||||
export interface UIPromptEndEvent {
|
||||
type: "ui_prompt_end";
|
||||
reason: "ui_prompt";
|
||||
kind: UIPromptKind;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/** Fired at the start of each turn */
|
||||
export interface TurnStartEvent {
|
||||
type: "turn_start";
|
||||
|
|
@ -1077,6 +1095,8 @@ export type ExtensionEvent =
|
|||
| AgentStartEvent
|
||||
| AgentEndEvent
|
||||
| AgentSettledEvent
|
||||
| UIPromptStartEvent
|
||||
| UIPromptEndEvent
|
||||
| TurnStartEvent
|
||||
| TurnEndEvent
|
||||
| MessageStartEvent
|
||||
|
|
@ -1263,6 +1283,8 @@ export interface ExtensionAPI {
|
|||
on(event: "agent_start", handler: ExtensionHandler<AgentStartEvent>): void;
|
||||
on(event: "agent_end", handler: ExtensionHandler<AgentEndEvent>): void;
|
||||
on(event: "agent_settled", handler: ExtensionHandler<AgentSettledEvent>): void;
|
||||
on(event: "ui_prompt_start", handler: ExtensionHandler<UIPromptStartEvent>): void;
|
||||
on(event: "ui_prompt_end", handler: ExtensionHandler<UIPromptEndEvent>): void;
|
||||
on(event: "turn_start", handler: ExtensionHandler<TurnStartEvent>): void;
|
||||
on(event: "turn_end", handler: ExtensionHandler<TurnEndEvent>): void;
|
||||
on(event: "message_start", handler: ExtensionHandler<MessageStartEvent>): void;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||
import type { Transport } from "@earendil-works/pi-ai";
|
||||
import type { TuiMode as RendererTuiMode, ScrollViewScrollbar } from "@earendil-works/pi-tui";
|
||||
import type { TuiMode as RendererTuiMode, ScrollViewScrollbar, TerminalCapabilities } from "@earendil-works/pi-tui";
|
||||
import { randomUUID } from "crypto";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
||||
import { dirname, join } from "path";
|
||||
|
|
@ -42,6 +42,9 @@ export interface TerminalSettings {
|
|||
imageWidthCells?: number; // default: 60 (preferred inline image width in terminal cells)
|
||||
clearOnShrink?: boolean; // default: false (clear empty rows when content shrinks)
|
||||
showTerminalProgress?: boolean; // default: false (OSC 9;4 terminal progress indicators)
|
||||
hyperlinks?: boolean | "auto";
|
||||
images?: "kitty" | "iterm2" | "auto" | false;
|
||||
trueColor?: boolean | "auto";
|
||||
}
|
||||
|
||||
export interface ImageSettings {
|
||||
|
|
@ -139,6 +142,7 @@ export interface Settings {
|
|||
tuiMode?: TuiMode; // default: "regular"
|
||||
fullscreenExitOutput?: FullscreenExitOutput; // default: "transcript"; no effect in regular TUI mode
|
||||
fullscreenScrollbar?: ScrollViewScrollbar; // default: "auto"; no effect in regular TUI mode
|
||||
fullscreenCopyOnSelect?: boolean; // default: true; no effect in regular TUI mode
|
||||
}
|
||||
|
||||
function isMergeableObject(value: unknown): value is Record<string, unknown> {
|
||||
|
|
@ -1125,6 +1129,16 @@ export class SettingsManager {
|
|||
return this.settings.thinkingBudgets;
|
||||
}
|
||||
|
||||
getTerminalCapabilityOverrides(): Partial<TerminalCapabilities> {
|
||||
const terminal = this.settings.terminal;
|
||||
const images = terminal?.images;
|
||||
return {
|
||||
...(images === "kitty" || images === "iterm2" ? { images } : images === false ? { images: null } : {}),
|
||||
...(typeof terminal?.trueColor === "boolean" ? { trueColor: terminal.trueColor } : {}),
|
||||
...(typeof terminal?.hyperlinks === "boolean" ? { hyperlinks: terminal.hyperlinks } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
getShowImages(): boolean {
|
||||
return this.settings.terminal?.showImages ?? true;
|
||||
}
|
||||
|
|
@ -1216,6 +1230,16 @@ export class SettingsManager {
|
|||
this.save();
|
||||
}
|
||||
|
||||
getFullscreenCopyOnSelect(): boolean {
|
||||
return this.settings.fullscreenCopyOnSelect ?? true;
|
||||
}
|
||||
|
||||
setFullscreenCopyOnSelect(enabled: boolean): void {
|
||||
this.globalSettings.fullscreenCopyOnSelect = enabled;
|
||||
this.markModified("fullscreenCopyOnSelect");
|
||||
this.save();
|
||||
}
|
||||
|
||||
getImageAutoResize(): boolean {
|
||||
return this.settings.images?.autoResize ?? true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import {
|
|||
Markdown,
|
||||
matchesKey,
|
||||
Spacer,
|
||||
setCapabilityOverrides,
|
||||
setKeybindings,
|
||||
Text,
|
||||
TruncatedText,
|
||||
|
|
@ -503,6 +504,7 @@ export class InteractiveMode {
|
|||
|
||||
constructor(runtimeHost: AgentSessionRuntime, options: InteractiveModeOptions = {}) {
|
||||
this.runtimeHost = runtimeHost;
|
||||
setCapabilityOverrides(this.settingsManager.getTerminalCapabilityOverrides());
|
||||
const tuiMode = options.tuiMode ?? this.settingsManager.getTuiMode();
|
||||
this.options = { ...options, tuiMode };
|
||||
this.autoTrustOnReloadCwd = options.autoTrustOnReloadCwd;
|
||||
|
|
@ -519,6 +521,7 @@ export class InteractiveMode {
|
|||
showHardwareCursor: this.settingsManager.getShowHardwareCursor(),
|
||||
logDirectory: getAgentDir(),
|
||||
onRightClickPaste: this.onRightClickPaste,
|
||||
fullscreenCopyOnSelect: this.settingsManager.getFullscreenCopyOnSelect(),
|
||||
});
|
||||
this.ui = createInteractiveTuiReference(() => this.renderer);
|
||||
this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink());
|
||||
|
|
@ -803,6 +806,7 @@ export class InteractiveMode {
|
|||
logDirectory: getAgentDir(),
|
||||
terminal,
|
||||
onRightClickPaste: this.onRightClickPaste,
|
||||
fullscreenCopyOnSelect: this.settingsManager.getFullscreenCopyOnSelect(),
|
||||
});
|
||||
nextUi.setClearOnShrink(clearOnShrink);
|
||||
nextUi.onDebug = onDebug;
|
||||
|
|
@ -1916,8 +1920,12 @@ export class InteractiveMode {
|
|||
}
|
||||
|
||||
private applyRuntimeSettings(): void {
|
||||
setCapabilityOverrides(this.settingsManager.getTerminalCapabilityOverrides());
|
||||
configureHttpDispatcher(this.settingsManager.getHttpIdleTimeoutMs());
|
||||
this.applyFullscreenScrollbarSetting();
|
||||
if (this.renderer instanceof TuiAltScreen) {
|
||||
this.renderer.setCopyOnSelect(this.settingsManager.getFullscreenCopyOnSelect());
|
||||
}
|
||||
this.footer.setSession(this.session);
|
||||
this.footer.setAutoCompactEnabled(this.session.autoCompactionEnabled);
|
||||
this.footerDataProvider.setCwd(this.sessionManager.getCwd());
|
||||
|
|
@ -2829,7 +2837,10 @@ export class InteractiveMode {
|
|||
this.defaultEditor.onAction("app.tools.expand", () => this.toggleToolOutputExpansion());
|
||||
this.defaultEditor.onAction("app.thinking.toggle", () => this.toggleThinkingBlockVisibility());
|
||||
this.defaultEditor.onAction("app.editor.external", () => void this.handleOpenExternalEditor());
|
||||
this.defaultEditor.onAction("app.message.copy", () => void this.handleCopyCommand({ flashConfirmation: true }));
|
||||
this.defaultEditor.onAction(
|
||||
"app.message.copy",
|
||||
() => void this.handleCopyCommand({ flashConfirmation: true, preferSelection: true }),
|
||||
);
|
||||
this.defaultEditor.onAction("app.message.followUp", () => this.handleFollowUp());
|
||||
this.defaultEditor.onAction("app.message.dequeue", () => this.handleDequeue());
|
||||
this.defaultEditor.onAction("app.session.new", () => this.handleClearCommand());
|
||||
|
|
@ -4133,21 +4144,20 @@ export class InteractiveMode {
|
|||
this.showStatus(`Tool output: ${expanded ? "expanded" : "collapsed"}`);
|
||||
}
|
||||
|
||||
/** Update rendered assistant messages without rebuilding live tool components. */
|
||||
private updateThinkingBlockVisibility(): void {
|
||||
for (const child of this.chatContainer.children) {
|
||||
if (child instanceof AssistantMessageComponent) {
|
||||
child.setHideThinkingBlock(this.hideThinkingBlock);
|
||||
}
|
||||
}
|
||||
this.ui.requestRender();
|
||||
}
|
||||
|
||||
private toggleThinkingBlockVisibility(): void {
|
||||
this.hideThinkingBlock = !this.hideThinkingBlock;
|
||||
this.settingsManager.setHideThinkingBlock(this.hideThinkingBlock);
|
||||
|
||||
// Rebuild chat from session messages
|
||||
this.chatContainer.clear();
|
||||
this.rebuildChatFromMessages();
|
||||
|
||||
// If streaming, re-add the streaming component with updated visibility and re-render
|
||||
if (this.streamingComponent && this.streamingMessage) {
|
||||
this.streamingComponent.setHideThinkingBlock(this.hideThinkingBlock);
|
||||
this.streamingComponent.updateContent(this.streamingMessage);
|
||||
this.chatContainer.addChild(this.streamingComponent);
|
||||
}
|
||||
|
||||
this.updateThinkingBlockVisibility();
|
||||
this.showStatus(`Thinking blocks: ${this.hideThinkingBlock ? "hidden" : "visible"}`);
|
||||
}
|
||||
|
||||
|
|
@ -4504,6 +4514,7 @@ export class InteractiveMode {
|
|||
tuiMode: this.ui.mode,
|
||||
fullscreenExitOutput: this.settingsManager.getFullscreenExitOutput(),
|
||||
fullscreenScrollbar: this.settingsManager.getFullscreenScrollbar(),
|
||||
fullscreenCopyOnSelect: this.settingsManager.getFullscreenCopyOnSelect(),
|
||||
warnings: this.settingsManager.getWarnings(),
|
||||
},
|
||||
{
|
||||
|
|
@ -4581,13 +4592,7 @@ export class InteractiveMode {
|
|||
onHideThinkingBlockChange: (hidden) => {
|
||||
this.hideThinkingBlock = hidden;
|
||||
this.settingsManager.setHideThinkingBlock(hidden);
|
||||
for (const child of this.chatContainer.children) {
|
||||
if (child instanceof AssistantMessageComponent) {
|
||||
child.setHideThinkingBlock(hidden);
|
||||
}
|
||||
}
|
||||
this.chatContainer.clear();
|
||||
this.rebuildChatFromMessages();
|
||||
this.updateThinkingBlockVisibility();
|
||||
},
|
||||
onMermaidRenderingModeChange: (mode) => {
|
||||
this.settingsManager.setMermaidRenderingMode(mode);
|
||||
|
|
@ -4682,6 +4687,10 @@ export class InteractiveMode {
|
|||
this.settingsManager.setFullscreenScrollbar(mode);
|
||||
this.applyFullscreenScrollbarSetting();
|
||||
},
|
||||
onFullscreenCopyOnSelectChange: (enabled) => {
|
||||
this.settingsManager.setFullscreenCopyOnSelect(enabled);
|
||||
if (this.renderer instanceof TuiAltScreen) this.renderer.setCopyOnSelect(enabled);
|
||||
},
|
||||
onWarningsChange: (warnings) => {
|
||||
this.settingsManager.setWarnings(warnings);
|
||||
},
|
||||
|
|
@ -4898,6 +4907,7 @@ export class InteractiveMode {
|
|||
const selectModel = async (model: Model<any>, persist: boolean) => {
|
||||
try {
|
||||
await this.session.setModel(model, { persist });
|
||||
this.updateAvailableProviderCount();
|
||||
this.footer.invalidate();
|
||||
this.updateEditorBorderColor();
|
||||
done();
|
||||
|
|
@ -5906,8 +5916,8 @@ export class InteractiveMode {
|
|||
activeHeader.setExpanded(this.toolOutputExpanded);
|
||||
}
|
||||
setRegisteredThemes(this.session.resourceLoader.getThemes().themes);
|
||||
await this.themeController.applyFromSettings();
|
||||
this.applyRuntimeSettings();
|
||||
await this.themeController.applyFromSettings();
|
||||
this.setupAutocompleteProvider();
|
||||
const runner = this.session.extensionRunner;
|
||||
this.setupExtensionShortcuts(runner);
|
||||
|
|
@ -6037,7 +6047,19 @@ export class InteractiveMode {
|
|||
});
|
||||
}
|
||||
|
||||
private async handleCopyCommand(options: { flashConfirmation?: boolean } = {}): Promise<void> {
|
||||
private async handleCopyCommand(
|
||||
options: { flashConfirmation?: boolean; preferSelection?: boolean } = {},
|
||||
): Promise<void> {
|
||||
if (
|
||||
options.preferSelection &&
|
||||
this.ui instanceof TuiAltScreen &&
|
||||
!this.ui.getCopyOnSelect() &&
|
||||
this.ui.hasActiveSelection()
|
||||
) {
|
||||
await this.ui.copyActiveSelectionToClipboard();
|
||||
return;
|
||||
}
|
||||
|
||||
const text = this.session.getLastAssistantText();
|
||||
if (!text) {
|
||||
this.showError("No agent messages to copy yet.");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -430,6 +430,10 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise<neve
|
|||
return success(id, "abort");
|
||||
}
|
||||
|
||||
case "clear_queue": {
|
||||
return success(id, "clear_queue", session.clearQueue());
|
||||
}
|
||||
|
||||
case "new_session": {
|
||||
const options = command.parentSession ? { parentSession: command.parentSession } : undefined;
|
||||
const result = await runtimeHost.newSession(options);
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export type RpcCommand =
|
|||
| { id?: string; type: "steer"; message: string; images?: ImageContent[] }
|
||||
| { id?: string; type: "follow_up"; message: string; images?: ImageContent[] }
|
||||
| { id?: string; type: "abort" }
|
||||
| { id?: string; type: "clear_queue" }
|
||||
| { id?: string; type: "new_session"; parentSession?: string }
|
||||
|
||||
// State
|
||||
|
|
@ -118,6 +119,13 @@ export type RpcResponse =
|
|||
| { id?: string; type: "response"; command: "steer"; success: true }
|
||||
| { id?: string; type: "response"; command: "follow_up"; success: true }
|
||||
| { id?: string; type: "response"; command: "abort"; success: true }
|
||||
| {
|
||||
id?: string;
|
||||
type: "response";
|
||||
command: "clear_queue";
|
||||
success: true;
|
||||
data: { steering: string[]; followUp: string[] };
|
||||
}
|
||||
| { id?: string; type: "response"; command: "new_session"; success: true; data: { cancelled: boolean } }
|
||||
|
||||
// State
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { existsSync } from "node:fs";
|
||||
import { delimiter } from "node:path";
|
||||
import { delimiter, join } from "node:path";
|
||||
import { spawn, spawnSync } from "child_process";
|
||||
import { getBinDir } from "../config.ts";
|
||||
|
||||
|
|
@ -215,15 +215,21 @@ export function killTrackedDetachedChildren(): void {
|
|||
*/
|
||||
export function killProcessTree(pid: number): void {
|
||||
if (process.platform === "win32") {
|
||||
// Use taskkill on Windows to kill process tree
|
||||
// Use the trusted System32 executable so cleanup does not depend on PATH.
|
||||
try {
|
||||
spawn("taskkill", ["/F", "/T", "/PID", String(pid)], {
|
||||
stdio: "ignore",
|
||||
detached: true,
|
||||
windowsHide: true,
|
||||
});
|
||||
const child = spawn(
|
||||
join(process.env.SystemRoot ?? "C:\\Windows", "System32", "taskkill.exe"),
|
||||
["/F", "/T", "/PID", String(pid)],
|
||||
{
|
||||
stdio: "ignore",
|
||||
detached: true,
|
||||
windowsHide: true,
|
||||
},
|
||||
);
|
||||
// A failed spawn emits "error" asynchronously; consume it to avoid crashing Node.
|
||||
child.once("error", () => {});
|
||||
} catch {
|
||||
// Ignore errors if taskkill fails
|
||||
// Ignore errors if taskkill fails.
|
||||
}
|
||||
} else {
|
||||
// Use SIGKILL on Unix/Linux/Mac
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ describe("createInteractiveTui", () => {
|
|||
renderer.setFocus(component);
|
||||
|
||||
type SwitchContext = {
|
||||
runtimeHost: { session: { settingsManager: { getFullscreenCopyOnSelect: () => boolean } } };
|
||||
renderer: ReturnType<typeof createInteractiveTui>;
|
||||
ui: TUI;
|
||||
fullscreenLayoutRoot: Component;
|
||||
|
|
@ -95,6 +96,7 @@ describe("createInteractiveTui", () => {
|
|||
extensionTerminalInputSubscriptions: Set<never>;
|
||||
};
|
||||
const context = Object.assign(Object.create(InteractiveMode.prototype), {
|
||||
runtimeHost: { session: { settingsManager: { getFullscreenCopyOnSelect: () => true } } },
|
||||
renderer,
|
||||
ui: undefined as unknown as TUI,
|
||||
fullscreenLayoutRoot: component,
|
||||
|
|
@ -156,7 +158,7 @@ type CopyCommandContext = {
|
|||
showError: (message: string) => void;
|
||||
};
|
||||
|
||||
type CopyCommandOptions = { flashConfirmation?: boolean };
|
||||
type CopyCommandOptions = { flashConfirmation?: boolean; preferSelection?: boolean };
|
||||
|
||||
type CopyCommandPrototype = {
|
||||
handleCopyCommand(this: CopyCommandContext, options?: CopyCommandOptions): Promise<void>;
|
||||
|
|
@ -170,6 +172,91 @@ describe("InteractiveMode copy confirmation", () => {
|
|||
clipboardMocks.copyToClipboard.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("copies an active fullscreen selection when copy-on-select is disabled", async () => {
|
||||
const terminal = new RecordingTerminal(40, 4);
|
||||
const ui = createInteractiveTui({
|
||||
tuiMode: "fullscreen",
|
||||
showHardwareCursor: false,
|
||||
logDirectory: "/tmp",
|
||||
terminal,
|
||||
fullscreenCopyOnSelect: false,
|
||||
});
|
||||
const getLastAssistantText = vi.fn(() => "assistant response");
|
||||
const showStatus = vi.fn();
|
||||
const showError = vi.fn();
|
||||
const context: CopyCommandContext = {
|
||||
session: { getLastAssistantText },
|
||||
ui,
|
||||
showStatus,
|
||||
showError,
|
||||
};
|
||||
ui.addChild(new Text("alpha\nbeta\ngamma\ndelta", 0, 0));
|
||||
|
||||
ui.start();
|
||||
try {
|
||||
await terminal.waitForRender();
|
||||
terminal.sendInput("\x1b[<0;1;1M");
|
||||
terminal.sendInput("\x1b[<32;4;2M");
|
||||
terminal.sendInput("\x1b[<0;4;2m");
|
||||
await terminal.waitForRender();
|
||||
clipboardMocks.copyToClipboard.mockClear();
|
||||
|
||||
await copyCommandPrototype.handleCopyCommand.call(context, { flashConfirmation: true, preferSelection: true });
|
||||
await terminal.waitForRender();
|
||||
|
||||
expect(clipboardMocks.copyToClipboard).toHaveBeenCalledOnce();
|
||||
expect(clipboardMocks.copyToClipboard).toHaveBeenCalledWith("alpha\nbeta");
|
||||
expect(getLastAssistantText).not.toHaveBeenCalled();
|
||||
expect(showStatus).not.toHaveBeenCalled();
|
||||
expect(showError).not.toHaveBeenCalled();
|
||||
expect(terminal.getViewport().some((line) => line.includes("Copied!"))).toBe(true);
|
||||
} finally {
|
||||
ui.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("copies the last assistant message with an active fullscreen selection when copy-on-select is enabled", async () => {
|
||||
const terminal = new RecordingTerminal(40, 4);
|
||||
const ui = createInteractiveTui({
|
||||
tuiMode: "fullscreen",
|
||||
showHardwareCursor: false,
|
||||
logDirectory: "/tmp",
|
||||
terminal,
|
||||
});
|
||||
const getLastAssistantText = vi.fn(() => "assistant response");
|
||||
const showStatus = vi.fn();
|
||||
const showError = vi.fn();
|
||||
const context: CopyCommandContext = {
|
||||
session: { getLastAssistantText },
|
||||
ui,
|
||||
showStatus,
|
||||
showError,
|
||||
};
|
||||
ui.addChild(new Text("alpha\nbeta\ngamma\ndelta", 0, 0));
|
||||
|
||||
ui.start();
|
||||
try {
|
||||
await terminal.waitForRender();
|
||||
terminal.sendInput("\x1b[<0;1;1M");
|
||||
terminal.sendInput("\x1b[<32;4;2M");
|
||||
terminal.sendInput("\x1b[<0;4;2m");
|
||||
await terminal.waitForRender();
|
||||
clipboardMocks.copyToClipboard.mockClear();
|
||||
|
||||
await copyCommandPrototype.handleCopyCommand.call(context, { flashConfirmation: true, preferSelection: true });
|
||||
await terminal.waitForRender();
|
||||
|
||||
expect(clipboardMocks.copyToClipboard).toHaveBeenCalledOnce();
|
||||
expect(clipboardMocks.copyToClipboard).toHaveBeenCalledWith("assistant response");
|
||||
expect(getLastAssistantText).toHaveBeenCalledOnce();
|
||||
expect(showStatus).not.toHaveBeenCalled();
|
||||
expect(showError).not.toHaveBeenCalled();
|
||||
expect(terminal.getViewport().some((line) => line.includes("Copied!"))).toBe(true);
|
||||
} finally {
|
||||
ui.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("flashes Copied! for the copy shortcut in fullscreen mode", async () => {
|
||||
const terminal = new RecordingTerminal(40, 4);
|
||||
const ui = createInteractiveTui({
|
||||
|
|
@ -190,7 +277,7 @@ describe("InteractiveMode copy confirmation", () => {
|
|||
ui.start();
|
||||
try {
|
||||
await terminal.waitForRender();
|
||||
await copyCommandPrototype.handleCopyCommand.call(context, { flashConfirmation: true });
|
||||
await copyCommandPrototype.handleCopyCommand.call(context, { flashConfirmation: true, preferSelection: true });
|
||||
await terminal.waitForRender();
|
||||
|
||||
expect(clipboardMocks.copyToClipboard).toHaveBeenCalledWith("assistant response");
|
||||
|
|
@ -218,7 +305,7 @@ describe("InteractiveMode copy confirmation", () => {
|
|||
showError,
|
||||
};
|
||||
|
||||
await copyCommandPrototype.handleCopyCommand.call(context, { flashConfirmation: true });
|
||||
await copyCommandPrototype.handleCopyCommand.call(context, { flashConfirmation: true, preferSelection: true });
|
||||
|
||||
expect(showStatus).toHaveBeenCalledWith("Copied last agent message to clipboard");
|
||||
expect(showError).not.toHaveBeenCalled();
|
||||
|
|
|
|||
|
|
@ -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}`]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
29
packages/coding-agent/test/rpc-client-clear-queue.test.ts
Normal file
29
packages/coding-agent/test/rpc-client-clear-queue.test.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { RpcClient } from "../src/modes/rpc/rpc-client.ts";
|
||||
|
||||
type RpcClientPrivate = {
|
||||
send: (command: { type: string }) => Promise<unknown>;
|
||||
getData: <T>(response: unknown) => T;
|
||||
};
|
||||
|
||||
describe("RpcClient clearQueue", () => {
|
||||
it("sends the clear_queue RPC command", async () => {
|
||||
const client = new RpcClient();
|
||||
const privateClient = client as unknown as RpcClientPrivate;
|
||||
const send = vi.fn(async () => ({
|
||||
type: "response",
|
||||
command: "clear_queue",
|
||||
success: true,
|
||||
data: { steering: ["Change direction"], followUp: ["Summarize when finished"] },
|
||||
}));
|
||||
privateClient.send = send;
|
||||
privateClient.getData = <T>(response: unknown): T => {
|
||||
return (response as { data: T }).data;
|
||||
};
|
||||
|
||||
const result = await client.clearQueue();
|
||||
|
||||
expect(send).toHaveBeenCalledWith({ type: "clear_queue" });
|
||||
expect(result).toEqual({ steering: ["Change direction"], followUp: ["Summarize when finished"] });
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { homedir } from "os";
|
|||
import { join } from "path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { DEFAULT_HTTP_IDLE_TIMEOUT_MS } from "../src/core/http-dispatcher.ts";
|
||||
import { SettingsManager } from "../src/core/settings-manager.ts";
|
||||
import { type Settings, SettingsManager } from "../src/core/settings-manager.ts";
|
||||
|
||||
describe("SettingsManager", () => {
|
||||
const testDir = join(process.cwd(), "test-settings-tmp");
|
||||
|
|
@ -335,6 +335,25 @@ describe("SettingsManager", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("terminal capability overrides", () => {
|
||||
it("maps explicit values and omits auto values", () => {
|
||||
const getOverrides = (terminal: NonNullable<Settings["terminal"]>) =>
|
||||
SettingsManager.inMemory({ terminal }).getTerminalCapabilityOverrides();
|
||||
|
||||
expect(getOverrides({ images: false, trueColor: false, hyperlinks: false })).toEqual({
|
||||
images: null,
|
||||
trueColor: false,
|
||||
hyperlinks: false,
|
||||
});
|
||||
expect(getOverrides({ images: "kitty", trueColor: true, hyperlinks: true })).toEqual({
|
||||
images: "kitty",
|
||||
trueColor: true,
|
||||
hyperlinks: true,
|
||||
});
|
||||
expect(getOverrides({ images: "auto", trueColor: "auto", hyperlinks: "auto" })).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("httpIdleTimeoutMs", () => {
|
||||
it("should default to 5 minutes", () => {
|
||||
const manager = SettingsManager.create(projectDir, agentDir);
|
||||
|
|
@ -436,13 +455,16 @@ describe("SettingsManager", () => {
|
|||
const manager = SettingsManager.create(projectDir, agentDir);
|
||||
expect(manager.getFullscreenExitOutput()).toBe("transcript");
|
||||
expect(manager.getFullscreenScrollbar()).toBe("auto");
|
||||
expect(manager.getFullscreenCopyOnSelect()).toBe(true);
|
||||
|
||||
manager.setFullscreenExitOutput("resume-hint");
|
||||
manager.setFullscreenScrollbar("hidden");
|
||||
manager.setFullscreenCopyOnSelect(false);
|
||||
await manager.flush();
|
||||
const savedSettings = JSON.parse(readFileSync(join(agentDir, "settings.json"), "utf-8"));
|
||||
expect(savedSettings.fullscreenExitOutput).toBe("resume-hint");
|
||||
expect(savedSettings.fullscreenScrollbar).toBe("hidden");
|
||||
expect(savedSettings.fullscreenCopyOnSelect).toBe(false);
|
||||
|
||||
writeFileSync(
|
||||
join(agentDir, "settings.json"),
|
||||
|
|
@ -451,6 +473,7 @@ describe("SettingsManager", () => {
|
|||
const reloadedManager = SettingsManager.create(projectDir, agentDir);
|
||||
expect(reloadedManager.getFullscreenExitOutput()).toBe("transcript");
|
||||
expect(reloadedManager.getFullscreenScrollbar()).toBe("auto");
|
||||
expect(reloadedManager.getFullscreenCopyOnSelect()).toBe(true);
|
||||
});
|
||||
|
||||
describe("outputPad", () => {
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
import type { ChildProcess } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() }));
|
||||
|
||||
vi.mock("child_process", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("child_process")>();
|
||||
return { ...actual, spawn: spawnMock };
|
||||
});
|
||||
|
||||
import { killProcessTree } from "../../../src/utils/shell.ts";
|
||||
|
||||
function withWindowsPlatform(test: () => void): void {
|
||||
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
try {
|
||||
Object.defineProperty(process, "platform", { configurable: true, value: "win32" });
|
||||
test();
|
||||
} finally {
|
||||
if (platformDescriptor) Object.defineProperty(process, "platform", platformDescriptor);
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
spawnMock.mockReset();
|
||||
});
|
||||
|
||||
describe("issue #6596 taskkill spawn failures", () => {
|
||||
it("uses System32 taskkill and consumes its asynchronous spawn error", () => {
|
||||
const child = new EventEmitter() as ChildProcess;
|
||||
const previousSystemRoot = process.env.SystemRoot;
|
||||
process.env.SystemRoot = "C:\\CustomWindows";
|
||||
spawnMock.mockReturnValue(child);
|
||||
|
||||
try {
|
||||
withWindowsPlatform(() => {
|
||||
killProcessTree(1234);
|
||||
});
|
||||
} finally {
|
||||
if (previousSystemRoot === undefined) delete process.env.SystemRoot;
|
||||
else process.env.SystemRoot = previousSystemRoot;
|
||||
}
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
join("C:\\CustomWindows", "System32", "taskkill.exe"),
|
||||
["/F", "/T", "/PID", "1234"],
|
||||
{ detached: true, stdio: "ignore", windowsHide: true },
|
||||
);
|
||||
expect(() => child.emit("error", new Error("spawn taskkill ENOENT"))).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
import type { AgentMessage, AgentTool } from "@earendil-works/pi-agent-core";
|
||||
import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai";
|
||||
import { Type } from "typebox";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { convertToLlm } from "../../../src/core/messages.ts";
|
||||
import { createHarness, type Harness } from "../harness.ts";
|
||||
|
||||
function roles(messages: AgentMessage[]): string[] {
|
||||
return messages.map((message) => message.role);
|
||||
}
|
||||
|
||||
describe("#8537 custom messages injected during tool execution", () => {
|
||||
const harnesses: Harness[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (harnesses.length > 0) {
|
||||
harnesses.pop()?.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("appends the message after the turn's tool results instead of between call and result", async () => {
|
||||
let notify: (() => Promise<void>) | undefined;
|
||||
const slowTool: AgentTool = {
|
||||
name: "wait",
|
||||
label: "Wait",
|
||||
description: "Wait for a background task",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => {
|
||||
// A background task (e.g. a subagent reply) notifies the session while the
|
||||
// tool is still running.
|
||||
await notify?.();
|
||||
return { content: [{ type: "text", text: "tool done" }], details: {} };
|
||||
},
|
||||
};
|
||||
|
||||
const harness = await createHarness({ tools: [slowTool] });
|
||||
harnesses.push(harness);
|
||||
notify = () =>
|
||||
harness.session.sendCustomMessage(
|
||||
{ customType: "subagent-reply", content: "subagent replied", display: true },
|
||||
{ triggerTurn: false },
|
||||
);
|
||||
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage([fauxToolCall("wait", {})], { stopReason: "toolUse" }),
|
||||
fauxAssistantMessage("done"),
|
||||
]);
|
||||
|
||||
await harness.session.prompt("hi");
|
||||
|
||||
expect(roles(harness.session.messages)).toEqual(["user", "assistant", "toolResult", "custom", "assistant"]);
|
||||
});
|
||||
|
||||
it("keeps session entries and message events in the same order as agent state", async () => {
|
||||
let notify: (() => Promise<void>) | undefined;
|
||||
const slowTool: AgentTool = {
|
||||
name: "wait",
|
||||
label: "Wait",
|
||||
description: "Wait for a background task",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => {
|
||||
await notify?.();
|
||||
return { content: [{ type: "text", text: "tool done" }], details: {} };
|
||||
},
|
||||
};
|
||||
|
||||
const harness = await createHarness({ tools: [slowTool] });
|
||||
harnesses.push(harness);
|
||||
notify = () =>
|
||||
harness.session.sendCustomMessage(
|
||||
{ customType: "subagent-reply", content: "subagent replied", display: true },
|
||||
{ triggerTurn: false },
|
||||
);
|
||||
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage([fauxToolCall("wait", {})], { stopReason: "toolUse" }),
|
||||
fauxAssistantMessage("done"),
|
||||
]);
|
||||
|
||||
await harness.session.prompt("hi");
|
||||
|
||||
const entryKinds = harness.sessionManager
|
||||
.getBranch()
|
||||
.flatMap((entry) =>
|
||||
entry.type === "message" ? [entry.message.role] : entry.type === "custom_message" ? ["custom"] : [],
|
||||
);
|
||||
expect(entryKinds).toEqual(["user", "assistant", "toolResult", "custom", "assistant"]);
|
||||
|
||||
// message events must never describe a message the session tree does not contain yet
|
||||
const messageStarts = harness.events.flatMap((event) =>
|
||||
event.type === "message_start" ? [event.message.role] : [],
|
||||
);
|
||||
expect(messageStarts).toEqual(["user", "assistant", "toolResult", "custom", "assistant"]);
|
||||
});
|
||||
|
||||
it("produces an llm history where every tool result follows its tool call", async () => {
|
||||
let notify: (() => Promise<void>) | undefined;
|
||||
const slowTool: AgentTool = {
|
||||
name: "wait",
|
||||
label: "Wait",
|
||||
description: "Wait for a background task",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => {
|
||||
await notify?.();
|
||||
return { content: [{ type: "text", text: "tool done" }], details: {} };
|
||||
},
|
||||
};
|
||||
|
||||
const harness = await createHarness({ tools: [slowTool] });
|
||||
harnesses.push(harness);
|
||||
notify = () =>
|
||||
harness.session.sendCustomMessage(
|
||||
{ customType: "subagent-reply", content: "subagent replied", display: true },
|
||||
{ triggerTurn: false },
|
||||
);
|
||||
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage([fauxToolCall("wait", {})], { stopReason: "toolUse" }),
|
||||
fauxAssistantMessage("done"),
|
||||
fauxAssistantMessage("second turn"),
|
||||
]);
|
||||
|
||||
await harness.session.prompt("hi");
|
||||
await harness.session.prompt("and now?");
|
||||
|
||||
const llmMessages = convertToLlm(harness.session.messages);
|
||||
const openToolCallIds = new Set<string>();
|
||||
for (const message of llmMessages) {
|
||||
if (message.role === "assistant") {
|
||||
openToolCallIds.clear();
|
||||
for (const block of message.content) {
|
||||
if (block.type === "toolCall") openToolCallIds.add(block.id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (message.role === "toolResult") {
|
||||
expect(openToolCallIds.has(message.toolCallId)).toBe(true);
|
||||
openToolCallIds.delete(message.toolCallId);
|
||||
continue;
|
||||
}
|
||||
openToolCallIds.clear();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -127,6 +127,7 @@ async function walkDirectoryWithFd(
|
|||
query: string,
|
||||
maxResults: number,
|
||||
signal: AbortSignal,
|
||||
maxDepth?: number,
|
||||
): Promise<Array<{ path: string; isDirectory: boolean }>> {
|
||||
const args = [
|
||||
"--base-directory",
|
||||
|
|
@ -147,6 +148,10 @@ async function walkDirectoryWithFd(
|
|||
".git/**",
|
||||
];
|
||||
|
||||
if (maxDepth !== undefined) {
|
||||
args.push("--max-depth", String(maxDepth));
|
||||
}
|
||||
|
||||
if (toDisplayPath(query).includes("/")) {
|
||||
args.push("--full-path");
|
||||
}
|
||||
|
|
@ -716,6 +721,18 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|||
return score;
|
||||
}
|
||||
|
||||
private async getBaseDirSuggestions(
|
||||
baseDir: string,
|
||||
query: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<Array<{ path: string; isDirectory: boolean }>> {
|
||||
if (!this.fdPath || signal.aborted) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return await walkDirectoryWithFd(baseDir, this.fdPath, query, 100, signal, 1);
|
||||
}
|
||||
|
||||
// Fuzzy file search using fd (fast, respects .gitignore)
|
||||
private async getFuzzyFileSuggestions(
|
||||
query: string,
|
||||
|
|
@ -729,7 +746,17 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|||
const scopedQuery = this.resolveScopedFuzzyQuery(query);
|
||||
const fdBaseDir = scopedQuery?.baseDir ?? this.basePath;
|
||||
const fdQuery = scopedQuery?.query ?? query;
|
||||
const entries = await walkDirectoryWithFd(fdBaseDir, this.fdPath, fdQuery, 100, options.signal);
|
||||
const baseDirEntries = await this.getBaseDirSuggestions(fdBaseDir, fdQuery, options.signal);
|
||||
const recursiveEntries = await walkDirectoryWithFd(fdBaseDir, this.fdPath, fdQuery, 100, options.signal);
|
||||
const seenPaths = new Set(baseDirEntries.map((entry) => entry.path));
|
||||
const entries = [
|
||||
...baseDirEntries,
|
||||
...recursiveEntries.filter((entry) => {
|
||||
if (seenPaths.has(entry.path)) return false;
|
||||
seenPaths.add(entry.path);
|
||||
return true;
|
||||
}),
|
||||
];
|
||||
if (options.signal.aborted) {
|
||||
return [];
|
||||
}
|
||||
|
|
@ -741,7 +768,20 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|||
}))
|
||||
.filter((entry) => entry.score > 0);
|
||||
|
||||
scoredEntries.sort((a, b) => b.score - a.score);
|
||||
scoredEntries.sort((a, b) => {
|
||||
const scoreDiff = b.score - a.score;
|
||||
if (scoreDiff !== 0) return scoreDiff;
|
||||
|
||||
const aDepth = toDisplayPath(a.path).split("/").filter(Boolean).length;
|
||||
const bDepth = toDisplayPath(b.path).split("/").filter(Boolean).length;
|
||||
const depthDiff = aDepth - bDepth;
|
||||
if (depthDiff !== 0) return depthDiff;
|
||||
|
||||
const lengthDiff = a.path.length - b.path.length;
|
||||
if (lengthDiff !== 0) return lengthDiff;
|
||||
|
||||
return a.path.localeCompare(b.path);
|
||||
});
|
||||
const topEntries = scoredEntries.slice(0, 20);
|
||||
|
||||
const suggestions: AutocompleteItem[] = [];
|
||||
|
|
|
|||
|
|
@ -111,6 +111,7 @@ export {
|
|||
renderImage,
|
||||
resetCapabilitiesCache,
|
||||
setCapabilities,
|
||||
setCapabilityOverrides,
|
||||
setCellDimensions,
|
||||
type TerminalCapabilities,
|
||||
} from "./terminal-image.ts";
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export interface ImageRenderOptions {
|
|||
}
|
||||
|
||||
let cachedCapabilities: TerminalCapabilities | null = null;
|
||||
let capabilityOverrides: Partial<TerminalCapabilities> = {};
|
||||
|
||||
// Default cell dimensions - updated by TUI when terminal responds to query
|
||||
let cellDimensions: CellDimensions = { widthPx: 9, heightPx: 18 };
|
||||
|
|
@ -65,7 +66,7 @@ function probeTmuxHyperlinks(): boolean {
|
|||
}
|
||||
}
|
||||
|
||||
export function detectCapabilities(tmuxForwardsHyperlink: () => boolean = probeTmuxHyperlinks): TerminalCapabilities {
|
||||
function detectCapabilitiesFromEnvironment(tmuxForwardsHyperlink: () => boolean): TerminalCapabilities {
|
||||
const termProgram = process.env.TERM_PROGRAM?.toLowerCase() || "";
|
||||
const terminalEmulator = process.env.TERMINAL_EMULATOR?.toLowerCase() || "";
|
||||
const term = process.env.TERM?.toLowerCase() || "";
|
||||
|
|
@ -135,9 +136,38 @@ export function detectCapabilities(tmuxForwardsHyperlink: () => boolean = probeT
|
|||
return { images: null, trueColor: hasTrueColorHint, hyperlinks: false };
|
||||
}
|
||||
|
||||
function parseBooleanCapabilityOverride(value: string | undefined): boolean | undefined {
|
||||
return value === "1" ? true : value === "0" ? false : undefined;
|
||||
}
|
||||
|
||||
export function detectCapabilities(tmuxForwardsHyperlink: () => boolean = probeTmuxHyperlinks): TerminalCapabilities {
|
||||
const hyperlinks = parseBooleanCapabilityOverride(process.env.PI_HYPERLINKS);
|
||||
const detected = detectCapabilitiesFromEnvironment(
|
||||
hyperlinks === undefined ? tmuxForwardsHyperlink : () => hyperlinks,
|
||||
);
|
||||
const imageProtocol = process.env.PI_IMAGE_PROTOCOL?.toLowerCase();
|
||||
const images =
|
||||
imageProtocol === "kitty" || imageProtocol === "iterm2"
|
||||
? imageProtocol
|
||||
: imageProtocol === "none" || imageProtocol === "0"
|
||||
? null
|
||||
: undefined;
|
||||
const trueColor = parseBooleanCapabilityOverride(process.env.PI_TRUE_COLOR);
|
||||
return {
|
||||
...detected,
|
||||
...(images !== undefined ? { images } : {}),
|
||||
...(trueColor !== undefined ? { trueColor } : {}),
|
||||
...(hyperlinks !== undefined ? { hyperlinks } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function getCapabilities(): TerminalCapabilities {
|
||||
if (!cachedCapabilities) {
|
||||
cachedCapabilities = detectCapabilities();
|
||||
const hyperlinks = capabilityOverrides.hyperlinks;
|
||||
cachedCapabilities = {
|
||||
...detectCapabilities(hyperlinks === undefined ? undefined : () => hyperlinks),
|
||||
...capabilityOverrides,
|
||||
};
|
||||
}
|
||||
return cachedCapabilities;
|
||||
}
|
||||
|
|
@ -146,6 +176,19 @@ export function resetCapabilitiesCache(): void {
|
|||
cachedCapabilities = null;
|
||||
}
|
||||
|
||||
/** Override selected auto-detected capabilities. */
|
||||
export function setCapabilityOverrides(overrides: Partial<TerminalCapabilities>): void {
|
||||
if (
|
||||
capabilityOverrides.images === overrides.images &&
|
||||
capabilityOverrides.trueColor === overrides.trueColor &&
|
||||
capabilityOverrides.hyperlinks === overrides.hyperlinks
|
||||
) {
|
||||
return;
|
||||
}
|
||||
capabilityOverrides = { ...overrides };
|
||||
cachedCapabilities = null;
|
||||
}
|
||||
|
||||
/** Override the cached capabilities. Useful in tests to exercise both code paths. */
|
||||
export function setCapabilities(caps: TerminalCapabilities): void {
|
||||
cachedCapabilities = caps;
|
||||
|
|
|
|||
|
|
@ -75,6 +75,9 @@ const MAX_CACHED_OFFSCREEN_KITTY_IMAGES = 16;
|
|||
const MAX_CACHED_OFFSCREEN_KITTY_TRANSMISSION_BYTES = 32 * 1024 * 1024;
|
||||
const MAX_CACHED_OFFSCREEN_KITTY_DECODED_BYTES = 64 * 1024 * 1024;
|
||||
const DOUBLE_CLICK_INTERVAL_MS = 500;
|
||||
// Regular mode delegates double-click selection to the terminal emulator. Fullscreen owns mouse selection,
|
||||
// so mirror common terminal word-selection behavior by keeping paths and kebab-case tokens whole.
|
||||
const TERMINAL_WORD_SELECTION_JOINERS = new Set(["/", "-"]);
|
||||
const wordSegmenter = getWordSegmenter();
|
||||
|
||||
interface CachedKittyImage {
|
||||
|
|
@ -163,6 +166,8 @@ export interface TuiAltScreenOptions {
|
|||
openUrl?: (url: string) => void;
|
||||
/** Handle an unmodified secondary-button press for clipboard paste. Currently enabled on Windows only. */
|
||||
onRightClickPaste?: () => void;
|
||||
/** Automatically copy selected text to the clipboard on mouse release (default: true). */
|
||||
copyOnSelect?: boolean;
|
||||
/**
|
||||
* Copy selected text to the system clipboard. Return `true` on success; the caller flashes
|
||||
* an error otherwise. When omitted, the selection is copied via an OSC 52 write.
|
||||
|
|
@ -218,6 +223,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
|||
private readonly searchCurrentMatchStyle: (text: string) => string;
|
||||
private readonly openUrl?: (url: string) => void;
|
||||
private readonly onRightClickPaste?: () => void;
|
||||
private copyOnSelect: boolean;
|
||||
private readonly copySelection?: (text: string) => Promise<boolean>;
|
||||
|
||||
constructor(
|
||||
|
|
@ -242,6 +248,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
|||
this.searchCurrentMatchStyle = options.searchCurrentMatchStyle ?? ((text) => `\x1b[1;7m${text}\x1b[22;27m`);
|
||||
this.openUrl = options.openUrl;
|
||||
this.onRightClickPaste = options.onRightClickPaste;
|
||||
this.copyOnSelect = options.copyOnSelect ?? true;
|
||||
this.copySelection = options.copySelection;
|
||||
this.addInputListener((data) => this.handleViewportInput(data));
|
||||
}
|
||||
|
|
@ -254,6 +261,26 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
|||
return this.getPrimaryScrollView().isFollowingEnd;
|
||||
}
|
||||
|
||||
getCopyOnSelect(): boolean {
|
||||
return this.copyOnSelect;
|
||||
}
|
||||
|
||||
setCopyOnSelect(enabled: boolean): void {
|
||||
this.copyOnSelect = enabled;
|
||||
}
|
||||
|
||||
/** Whether the fullscreen viewport has a non-empty active text selection. */
|
||||
hasActiveSelection(): boolean {
|
||||
return this.getActiveSelectionText() !== undefined;
|
||||
}
|
||||
|
||||
/** Copy the active fullscreen text selection, if any, using the configured selection clipboard path. */
|
||||
async copyActiveSelectionToClipboard(): Promise<boolean> {
|
||||
const text = this.getActiveSelectionText();
|
||||
if (!text) return false;
|
||||
return this.copyTextToClipboard(text);
|
||||
}
|
||||
|
||||
setLayoutRoot(component: Component | undefined): void {
|
||||
if (this.layoutRoot === component) return;
|
||||
this.layoutRoot = component;
|
||||
|
|
@ -1039,18 +1066,39 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
|||
|
||||
private getWordSelection(point: SelectionPoint): SelectionRange | undefined {
|
||||
const line = stripTerminalSequences(this.getSelectionSourceLine(point));
|
||||
const segments: Array<{ start: number; end: number; selectable: boolean; joiner: boolean }> = [];
|
||||
let start = 0;
|
||||
for (const segment of wordSegmenter.segment(line)) {
|
||||
const end = start + visibleWidth(segment.segment);
|
||||
if (point.col >= start && point.col < end) {
|
||||
return {
|
||||
start: { ...point, col: start },
|
||||
end: { ...point, col: end, boundary: true },
|
||||
};
|
||||
}
|
||||
const joiner = TERMINAL_WORD_SELECTION_JOINERS.has(segment.segment);
|
||||
segments.push({ start, end, selectable: segment.isWordLike === true || joiner, joiner });
|
||||
start = end;
|
||||
}
|
||||
return undefined;
|
||||
const clickedSegmentIndex = segments.findIndex(
|
||||
(segment) => point.col >= segment.start && point.col < segment.end,
|
||||
);
|
||||
if (clickedSegmentIndex < 0) return undefined;
|
||||
|
||||
const canJoin = (
|
||||
left: { selectable: boolean; joiner: boolean },
|
||||
right: { selectable: boolean; joiner: boolean },
|
||||
): boolean => left.selectable && right.selectable && (left.joiner || right.joiner);
|
||||
let selectionStart = segments[clickedSegmentIndex].start;
|
||||
let selectionEnd = segments[clickedSegmentIndex].end;
|
||||
for (let index = clickedSegmentIndex; index > 0 && canJoin(segments[index - 1], segments[index]); index--) {
|
||||
selectionStart = segments[index - 1].start;
|
||||
}
|
||||
for (
|
||||
let index = clickedSegmentIndex;
|
||||
index < segments.length - 1 && canJoin(segments[index], segments[index + 1]);
|
||||
index++
|
||||
) {
|
||||
selectionEnd = segments[index + 1].end;
|
||||
}
|
||||
return {
|
||||
start: { ...point, col: selectionStart },
|
||||
end: { ...point, col: selectionEnd, boundary: true },
|
||||
};
|
||||
}
|
||||
|
||||
private getLineSelection(point: SelectionPoint): SelectionRange {
|
||||
|
|
@ -1203,7 +1251,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
|||
return;
|
||||
}
|
||||
}
|
||||
void this.copySelectionToClipboard();
|
||||
if (this.copyOnSelect) void this.copySelectionToClipboard();
|
||||
this.requestRender();
|
||||
return;
|
||||
}
|
||||
|
|
@ -1279,14 +1327,14 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
|||
return { start: Math.max(minColumn, start), end: Math.min(maxColumn, end) };
|
||||
}
|
||||
|
||||
private async copySelectionToClipboard(): Promise<void> {
|
||||
private getActiveSelectionText(): string | undefined {
|
||||
const selection = this.getSelectionBounds();
|
||||
if (!selection) return;
|
||||
if (!selection) return undefined;
|
||||
let sourceLines: readonly string[] = this.previousScreen;
|
||||
if (selection.start.scrollView) {
|
||||
if (!this.currentLayout) return;
|
||||
if (!this.currentLayout) return undefined;
|
||||
const box = getScrollViewBox(this.currentLayout, selection.start.scrollView);
|
||||
if (!box?.scrollContentLines) return;
|
||||
if (!box?.scrollContentLines) return undefined;
|
||||
sourceLines = box.scrollContentLines;
|
||||
}
|
||||
const lines: string[] = [];
|
||||
|
|
@ -1300,7 +1348,16 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
|||
);
|
||||
}
|
||||
const text = lines.join("\n");
|
||||
if (text.length === 0) return;
|
||||
return text.length === 0 ? undefined : text;
|
||||
}
|
||||
|
||||
private async copySelectionToClipboard(): Promise<boolean> {
|
||||
const text = this.getActiveSelectionText();
|
||||
if (!text) return false;
|
||||
return this.copyTextToClipboard(text);
|
||||
}
|
||||
|
||||
private async copyTextToClipboard(text: string): Promise<boolean> {
|
||||
// Prefer an injected clipboard implementation (native clipboard + platform tools with a
|
||||
// verified success path) when the host app provides one. A bare OSC 52 write can show
|
||||
// "Copied!" while leaving the system clipboard untouched (e.g. macOS Terminal.app, tmux
|
||||
|
|
@ -1308,10 +1365,11 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
|||
if (this.copySelection) {
|
||||
const ok = await this.copySelection(text);
|
||||
this.flash(ok ? "Copied!" : "Copy failed");
|
||||
return;
|
||||
return ok;
|
||||
}
|
||||
this.terminal.write(`\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`);
|
||||
this.flash("Copied!");
|
||||
return true;
|
||||
}
|
||||
|
||||
private applySearchTextHighlight(text: string, current: boolean): string {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
detectCapabilities,
|
||||
encodeITerm2,
|
||||
encodeKitty,
|
||||
getCapabilities,
|
||||
getKittyImageMetadata,
|
||||
getKittyImagePlacement,
|
||||
hyperlink,
|
||||
|
|
@ -24,6 +25,7 @@ import {
|
|||
renderImage,
|
||||
resetCapabilitiesCache,
|
||||
setCapabilities,
|
||||
setCapabilityOverrides,
|
||||
setCellDimensions,
|
||||
} from "../src/terminal-image.ts";
|
||||
import { visibleWidth } from "../src/utils.ts";
|
||||
|
|
@ -42,9 +44,12 @@ const ENV_KEYS = [
|
|||
"CMUX_WORKSPACE_ID",
|
||||
"WARP_SESSION_ID",
|
||||
"WARP_TERMINAL_SESSION_UUID",
|
||||
"PI_HYPERLINKS",
|
||||
"PI_IMAGE_PROTOCOL",
|
||||
"PI_TRUE_COLOR",
|
||||
] as const;
|
||||
|
||||
function withEnv(overrides: Record<string, string | undefined>, fn: () => void): void {
|
||||
function withEnv<T>(overrides: Record<string, string | undefined>, fn: () => T): T {
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
for (const key of ENV_KEYS) {
|
||||
saved[key] = process.env[key];
|
||||
|
|
@ -55,7 +60,7 @@ function withEnv(overrides: Record<string, string | undefined>, fn: () => void):
|
|||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
fn();
|
||||
return fn();
|
||||
} finally {
|
||||
for (const key of ENV_KEYS) {
|
||||
if (saved[key] === undefined) delete process.env[key];
|
||||
|
|
@ -219,6 +224,63 @@ describe("detectCapabilities", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("applies environment overrides", () => {
|
||||
assert.deepStrictEqual(
|
||||
withEnv({ PI_HYPERLINKS: "1", PI_IMAGE_PROTOCOL: "kitty", PI_TRUE_COLOR: "1" }, () => detectCapabilities()),
|
||||
{ images: "kitty", trueColor: true, hyperlinks: true },
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
withEnv({ TERM_PROGRAM: "iterm.app", PI_HYPERLINKS: "0", PI_IMAGE_PROTOCOL: "none", PI_TRUE_COLOR: "0" }, () =>
|
||||
detectCapabilities(),
|
||||
),
|
||||
{ images: null, trueColor: false, hyperlinks: false },
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves auto-detection for auto environment overrides", () => {
|
||||
assert.deepStrictEqual(
|
||||
withEnv(
|
||||
{
|
||||
TERM_PROGRAM: "ghostty",
|
||||
PI_HYPERLINKS: "auto",
|
||||
PI_IMAGE_PROTOCOL: "auto",
|
||||
PI_TRUE_COLOR: "auto",
|
||||
},
|
||||
() => detectCapabilities(),
|
||||
),
|
||||
{ images: "kitty", trueColor: true, hyperlinks: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("applies and clears programmatic overrides", () => {
|
||||
withEnv({ PI_HYPERLINKS: "1", PI_IMAGE_PROTOCOL: "kitty", PI_TRUE_COLOR: "1" }, () => {
|
||||
setCapabilityOverrides({ images: null, trueColor: false, hyperlinks: false });
|
||||
try {
|
||||
assert.deepStrictEqual(getCapabilities(), { images: null, trueColor: false, hyperlinks: false });
|
||||
setCapabilityOverrides({});
|
||||
assert.deepStrictEqual(getCapabilities(), { images: "kitty", trueColor: true, hyperlinks: true });
|
||||
} finally {
|
||||
setCapabilityOverrides({});
|
||||
resetCapabilitiesCache();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("bypasses the tmux probe when hyperlinks are overridden", () => {
|
||||
let probed = false;
|
||||
const caps = withEnv(
|
||||
{ TMUX: "/tmp/tmux-1000/default,1234,0", PI_HYPERLINKS: "1", PI_IMAGE_PROTOCOL: "kitty" },
|
||||
() =>
|
||||
detectCapabilities(() => {
|
||||
probed = true;
|
||||
return false;
|
||||
}),
|
||||
);
|
||||
assert.strictEqual(probed, false);
|
||||
assert.strictEqual(caps.hyperlinks, true);
|
||||
assert.strictEqual(caps.images, "kitty");
|
||||
});
|
||||
|
||||
it("enables hyperlinks under tmux when the client forwards them", () => {
|
||||
withEnv({ TMUX: "/tmp/tmux-1000/default,1234,0", TERM_PROGRAM: "ghostty" }, () => {
|
||||
const caps = detectCapabilities(() => true);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { join } from "node:path";
|
|||
import { describe, it } from "node:test";
|
||||
import type { Terminal as XtermTerminalType } from "@xterm/headless";
|
||||
import { Image } from "../src/components/image.ts";
|
||||
import type { Terminal } from "../src/terminal.ts";
|
||||
import {
|
||||
deleteKittyImage,
|
||||
encodeKitty,
|
||||
|
|
@ -37,6 +38,30 @@ class InputComponent extends TestComponent {
|
|||
}
|
||||
}
|
||||
|
||||
const MAX_RENDER_WRITE_CHARS = 1024 * 1024;
|
||||
|
||||
class BoundedWriteTerminal implements Terminal {
|
||||
readonly writes: string[] = [];
|
||||
columns = 80;
|
||||
rows = 24;
|
||||
readonly kittyProtocolActive = false;
|
||||
|
||||
start(_onInput: (data: string) => void, _onResize: () => void): void {}
|
||||
stop(): void {}
|
||||
async drainInput(_maxMs?: number, _idleMs?: number): Promise<void> {}
|
||||
write(data: string): void {
|
||||
this.writes.push(data);
|
||||
}
|
||||
moveBy(_lines: number): void {}
|
||||
hideCursor(): void {}
|
||||
showCursor(): void {}
|
||||
clearLine(): void {}
|
||||
clearFromCursor(): void {}
|
||||
clearScreen(): void {}
|
||||
setTitle(_title: string): void {}
|
||||
setProgress(_active: boolean): void {}
|
||||
}
|
||||
|
||||
class LoggingVirtualTerminal extends VirtualTerminal {
|
||||
private writes: string[] = [];
|
||||
|
||||
|
|
@ -136,6 +161,51 @@ describe("TUI debug logging", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("TUI bounded render output", () => {
|
||||
it("splits a large full render without changing its output", () => {
|
||||
const terminal = new BoundedWriteTerminal();
|
||||
const tui = new TuiMainScreen(terminal);
|
||||
const component = new TestComponent();
|
||||
const kittyLine = `\x1b_Ga=T,f=100;${"A".repeat(1_200_000)}\x1b\\`;
|
||||
component.lines = [kittyLine, kittyLine];
|
||||
tui.addChild(component);
|
||||
|
||||
tui.renderNow();
|
||||
|
||||
assert.ok(terminal.writes.length > 2, "large output should be split across terminal writes");
|
||||
assert.ok(
|
||||
terminal.writes.every((write) => write.length <= MAX_RENDER_WRITE_CHARS),
|
||||
"each terminal write should stay below the configured limit",
|
||||
);
|
||||
assert.strictEqual(
|
||||
terminal.writes.join(""),
|
||||
`\x1b[?2026h${kittyLine}\r\n${kittyLine}\x1b[?2026l`,
|
||||
"chunking must preserve the synchronized render output",
|
||||
);
|
||||
});
|
||||
|
||||
it("splits large differential updates without a full redraw", () => {
|
||||
const terminal = new BoundedWriteTerminal();
|
||||
const tui = new TuiMainScreen(terminal);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
component.lines = ["before"];
|
||||
tui.renderNow();
|
||||
terminal.writes.length = 0;
|
||||
|
||||
const kittyLine = `\x1b_Ga=T,f=100;${"A".repeat(1_200_000)}\x1b\\`;
|
||||
component.lines = ["before", kittyLine, kittyLine];
|
||||
tui.renderNow();
|
||||
|
||||
assert.ok(terminal.writes.length > 2, "large output should be split across terminal writes");
|
||||
assert.ok(terminal.writes.every((write) => write.length <= MAX_RENDER_WRITE_CHARS));
|
||||
const output = terminal.writes.join("");
|
||||
assert.ok(output.startsWith("\x1b[?2026h"));
|
||||
assert.ok(output.endsWith("\x1b[?2026l"));
|
||||
assert.ok(!output.includes("\x1b[2J"), "the update should stay on the differential render path");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TUI Kitty image cleanup", () => {
|
||||
it("clears reserved Kitty image rows before drawing appended image placements", async () => {
|
||||
setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true });
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue