Merge branch 'main' of https://github.com/badlogic/pi-mono
# Conflicts: # packages/coding-agent/CHANGELOG.md
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
---
|
||||
description: Analyze GitHub issues (bugs or feature requests)
|
||||
argument-hint: "<issue>"
|
||||
---
|
||||
Analyze GitHub issue(s): $ARGUMENTS
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
description: Review PRs from URLs with structured issue and code analysis
|
||||
argument-hint: "<PR-URL>"
|
||||
---
|
||||
You are given one or more GitHub PR URLs: $@
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
description: Finish the current task end-to-end with changelog, commit, and push
|
||||
argument-hint: "[instructions]"
|
||||
---
|
||||
Wrap it.
|
||||
|
||||
|
||||
1000
package-lock.json
generated
1000
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,16 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed parallel tool-call finalization to convert `afterToolCall` hook throws into error tool results instead of aborting the batch ([#3084](https://github.com/badlogic/pi-mono/issues/3084))
|
||||
|
||||
## [0.67.6] - 2026-04-16
|
||||
|
||||
## [0.67.5] - 2026-04-16
|
||||
|
||||
## [0.67.4] - 2026-04-16
|
||||
|
||||
## [0.67.3] - 2026-04-15
|
||||
|
||||
## [0.67.2] - 2026-04-14
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mariozechner/pi-agent-core",
|
||||
"version": "0.67.3",
|
||||
"version": "0.67.6",
|
||||
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
@@ -17,7 +17,7 @@
|
||||
"prepublishOnly": "npm run clean && npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mariozechner/pi-ai": "^0.67.3"
|
||||
"@mariozechner/pi-ai": "^0.67.6"
|
||||
},
|
||||
"keywords": [
|
||||
"ai",
|
||||
|
||||
@@ -571,23 +571,28 @@ async function finalizeExecutedToolCall(
|
||||
let isError = executed.isError;
|
||||
|
||||
if (config.afterToolCall) {
|
||||
const afterResult = await config.afterToolCall(
|
||||
{
|
||||
assistantMessage,
|
||||
toolCall: prepared.toolCall,
|
||||
args: prepared.args,
|
||||
result,
|
||||
isError,
|
||||
context: currentContext,
|
||||
},
|
||||
signal,
|
||||
);
|
||||
if (afterResult) {
|
||||
result = {
|
||||
content: afterResult.content ?? result.content,
|
||||
details: afterResult.details ?? result.details,
|
||||
};
|
||||
isError = afterResult.isError ?? isError;
|
||||
try {
|
||||
const afterResult = await config.afterToolCall(
|
||||
{
|
||||
assistantMessage,
|
||||
toolCall: prepared.toolCall,
|
||||
args: prepared.args,
|
||||
result,
|
||||
isError,
|
||||
context: currentContext,
|
||||
},
|
||||
signal,
|
||||
);
|
||||
if (afterResult) {
|
||||
result = {
|
||||
content: afterResult.content ?? result.content,
|
||||
details: afterResult.details ?? result.details,
|
||||
};
|
||||
isError = afterResult.isError ?? isError;
|
||||
}
|
||||
} catch (error) {
|
||||
result = createErrorToolResult(error instanceof Error ? error.message : String(error));
|
||||
isError = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -98,6 +98,7 @@ export interface AgentOptions {
|
||||
streamFn?: StreamFn;
|
||||
getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
|
||||
onPayload?: SimpleStreamOptions["onPayload"];
|
||||
onResponse?: SimpleStreamOptions["onResponse"];
|
||||
beforeToolCall?: (context: BeforeToolCallContext, signal?: AbortSignal) => Promise<BeforeToolCallResult | undefined>;
|
||||
afterToolCall?: (context: AfterToolCallContext, signal?: AbortSignal) => Promise<AfterToolCallResult | undefined>;
|
||||
steeringMode?: QueueMode;
|
||||
@@ -165,6 +166,7 @@ export class Agent {
|
||||
public streamFn: StreamFn;
|
||||
public getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
|
||||
public onPayload?: SimpleStreamOptions["onPayload"];
|
||||
public onResponse?: SimpleStreamOptions["onResponse"];
|
||||
public beforeToolCall?: (
|
||||
context: BeforeToolCallContext,
|
||||
signal?: AbortSignal,
|
||||
@@ -192,6 +194,7 @@ export class Agent {
|
||||
this.streamFn = options.streamFn ?? streamSimple;
|
||||
this.getApiKey = options.getApiKey;
|
||||
this.onPayload = options.onPayload;
|
||||
this.onResponse = options.onResponse;
|
||||
this.beforeToolCall = options.beforeToolCall;
|
||||
this.afterToolCall = options.afterToolCall;
|
||||
this.steeringQueue = new PendingMessageQueue(options.steeringMode ?? "one-at-a-time");
|
||||
@@ -411,6 +414,7 @@ export class Agent {
|
||||
reasoning: this._state.thinkingLevel === "off" ? undefined : this._state.thinkingLevel,
|
||||
sessionId: this.sessionId,
|
||||
onPayload: this.onPayload,
|
||||
onResponse: this.onResponse,
|
||||
transport: this.transport,
|
||||
thinkingBudgets: this.thinkingBudgets,
|
||||
maxRetryDelayMs: this.maxRetryDelayMs,
|
||||
|
||||
@@ -2,8 +2,28 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.67.6] - 2026-04-16
|
||||
|
||||
### Added
|
||||
|
||||
- Added `onResponse` to `StreamOptions` so callers can inspect provider HTTP status and headers after each response arrives and before the response stream is consumed ([#3128](https://github.com/badlogic/pi-mono/issues/3128))
|
||||
- Added `thinkingDisplay` (`"summarized" | "omitted"`) to `AnthropicOptions` and `BedrockOptions`, wiring it through to the Anthropic/Bedrock `thinking` config. Defaults to `"summarized"` so Claude Opus 4.7 and Mythos Preview keep returning thinking text; set it to `"omitted"` to skip thinking streaming for faster time-to-first-text-token.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed OpenAI Responses prompt caching for non-`api.openai.com` base URLs (OpenAI-compatible proxies such as litellm, theclawbay) by sending the `session_id` and `x-client-request-id` cache-affinity headers unconditionally when a `sessionId` is provided, matching the official Codex CLI behavior ([#3264](https://github.com/badlogic/pi-mono/pull/3264) by [@vegarsti](https://github.com/vegarsti))
|
||||
|
||||
## [0.67.5] - 2026-04-16
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed Opus 4.7 adaptive thinking configuration across Anthropic and Bedrock providers by recognizing Opus 4.7 adaptive-thinking support and mapping `xhigh` reasoning to provider-supported effort values ([#3286](https://github.com/badlogic/pi-mono/pull/3286) by [@markusylisiurunen](https://github.com/markusylisiurunen))
|
||||
|
||||
## [0.67.4] - 2026-04-16
|
||||
|
||||
### Changed
|
||||
|
||||
- Added `claude-opus-4-7` model for Anthropic, OpenRouter.
|
||||
- Changed Anthropic prompt caching to add a `cache_control` breakpoint on the last tool definition, so tool schemas can be cached independently from transcript updates while preserving existing cache retention behavior ([#3260](https://github.com/badlogic/pi-mono/issues/3260))
|
||||
- Changed Kimi Coding model generation to normalize deprecated `k2p5` to `kimi-for-coding` from models.dev data and removed the old static fallback model list ([#3242](https://github.com/badlogic/pi-mono/issues/3242))
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mariozechner/pi-ai",
|
||||
"version": "0.67.3",
|
||||
"version": "0.67.6",
|
||||
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
@@ -72,8 +72,8 @@
|
||||
"prepublishOnly": "npm run clean && npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.73.0",
|
||||
"@aws-sdk/client-bedrock-runtime": "^3.983.0",
|
||||
"@anthropic-ai/sdk": "^0.90.0",
|
||||
"@aws-sdk/client-bedrock-runtime": "^3.1030.0",
|
||||
"@google/genai": "^1.40.0",
|
||||
"@mistralai/mistralai": "1.14.1",
|
||||
"@sinclair/typebox": "^0.34.41",
|
||||
|
||||
@@ -769,6 +769,27 @@ async function generateModels() {
|
||||
});
|
||||
}
|
||||
|
||||
// Add missing Claude Opus 4.7
|
||||
if (!allModels.some(m => m.provider === "anthropic" && m.id === "claude-opus-4-7")) {
|
||||
allModels.push({
|
||||
id: "claude-opus-4-7",
|
||||
name: "Claude Opus 4.7",
|
||||
api: "anthropic-messages",
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
provider: "anthropic",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: {
|
||||
input: 5,
|
||||
output: 25,
|
||||
cacheRead: 0.5,
|
||||
cacheWrite: 6.25,
|
||||
},
|
||||
contextWindow: 1000000,
|
||||
maxTokens: 128000,
|
||||
});
|
||||
}
|
||||
|
||||
// Add missing Claude Sonnet 4.6
|
||||
if (!allModels.some(m => m.provider === "anthropic" && m.id === "claude-sonnet-4-6")) {
|
||||
allModels.push({
|
||||
|
||||
@@ -4,8 +4,8 @@ export { Type } from "@sinclair/typebox";
|
||||
export * from "./api-registry.js";
|
||||
export * from "./env-api-keys.js";
|
||||
export * from "./models.js";
|
||||
export type { BedrockOptions } from "./providers/amazon-bedrock.js";
|
||||
export type { AnthropicOptions } from "./providers/anthropic.js";
|
||||
export type { BedrockOptions, BedrockThinkingDisplay } from "./providers/amazon-bedrock.js";
|
||||
export type { AnthropicEffort, AnthropicOptions, AnthropicThinkingDisplay } from "./providers/anthropic.js";
|
||||
export type { AzureOpenAIResponsesOptions } from "./providers/azure-openai-responses.js";
|
||||
export * from "./providers/faux.js";
|
||||
export type { GoogleOptions } from "./providers/google.js";
|
||||
|
||||
@@ -260,6 +260,23 @@ export const MODELS = {
|
||||
contextWindow: 1000000,
|
||||
maxTokens: 128000,
|
||||
} satisfies Model<"bedrock-converse-stream">,
|
||||
"anthropic.claude-opus-4-7": {
|
||||
id: "anthropic.claude-opus-4-7",
|
||||
name: "Claude Opus 4.7",
|
||||
api: "bedrock-converse-stream",
|
||||
provider: "amazon-bedrock",
|
||||
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: {
|
||||
input: 5,
|
||||
output: 25,
|
||||
cacheRead: 0.5,
|
||||
cacheWrite: 6.25,
|
||||
},
|
||||
contextWindow: 1000000,
|
||||
maxTokens: 128000,
|
||||
} satisfies Model<"bedrock-converse-stream">,
|
||||
"anthropic.claude-sonnet-4-20250514-v1:0": {
|
||||
id: "anthropic.claude-sonnet-4-20250514-v1:0",
|
||||
name: "Claude Sonnet 4",
|
||||
@@ -413,6 +430,23 @@ export const MODELS = {
|
||||
contextWindow: 1000000,
|
||||
maxTokens: 128000,
|
||||
} satisfies Model<"bedrock-converse-stream">,
|
||||
"eu.anthropic.claude-opus-4-7": {
|
||||
id: "eu.anthropic.claude-opus-4-7",
|
||||
name: "Claude Opus 4.7 (EU)",
|
||||
api: "bedrock-converse-stream",
|
||||
provider: "amazon-bedrock",
|
||||
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: {
|
||||
input: 5,
|
||||
output: 25,
|
||||
cacheRead: 0.5,
|
||||
cacheWrite: 6.25,
|
||||
},
|
||||
contextWindow: 1000000,
|
||||
maxTokens: 128000,
|
||||
} satisfies Model<"bedrock-converse-stream">,
|
||||
"eu.anthropic.claude-sonnet-4-20250514-v1:0": {
|
||||
id: "eu.anthropic.claude-sonnet-4-20250514-v1:0",
|
||||
name: "Claude Sonnet 4 (EU)",
|
||||
@@ -515,6 +549,23 @@ export const MODELS = {
|
||||
contextWindow: 1000000,
|
||||
maxTokens: 128000,
|
||||
} satisfies Model<"bedrock-converse-stream">,
|
||||
"global.anthropic.claude-opus-4-7": {
|
||||
id: "global.anthropic.claude-opus-4-7",
|
||||
name: "Claude Opus 4.7 (Global)",
|
||||
api: "bedrock-converse-stream",
|
||||
provider: "amazon-bedrock",
|
||||
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: {
|
||||
input: 5,
|
||||
output: 25,
|
||||
cacheRead: 0.5,
|
||||
cacheWrite: 6.25,
|
||||
},
|
||||
contextWindow: 1000000,
|
||||
maxTokens: 128000,
|
||||
} satisfies Model<"bedrock-converse-stream">,
|
||||
"global.anthropic.claude-sonnet-4-20250514-v1:0": {
|
||||
id: "global.anthropic.claude-sonnet-4-20250514-v1:0",
|
||||
name: "Claude Sonnet 4 (Global)",
|
||||
@@ -1348,6 +1399,23 @@ export const MODELS = {
|
||||
contextWindow: 1000000,
|
||||
maxTokens: 128000,
|
||||
} satisfies Model<"bedrock-converse-stream">,
|
||||
"us.anthropic.claude-opus-4-7": {
|
||||
id: "us.anthropic.claude-opus-4-7",
|
||||
name: "Claude Opus 4.7 (US)",
|
||||
api: "bedrock-converse-stream",
|
||||
provider: "amazon-bedrock",
|
||||
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: {
|
||||
input: 5,
|
||||
output: 25,
|
||||
cacheRead: 0.5,
|
||||
cacheWrite: 6.25,
|
||||
},
|
||||
contextWindow: 1000000,
|
||||
maxTokens: 128000,
|
||||
} satisfies Model<"bedrock-converse-stream">,
|
||||
"us.anthropic.claude-sonnet-4-20250514-v1:0": {
|
||||
id: "us.anthropic.claude-sonnet-4-20250514-v1:0",
|
||||
name: "Claude Sonnet 4 (US)",
|
||||
@@ -1775,6 +1843,23 @@ export const MODELS = {
|
||||
contextWindow: 1000000,
|
||||
maxTokens: 128000,
|
||||
} satisfies Model<"anthropic-messages">,
|
||||
"claude-opus-4-7": {
|
||||
id: "claude-opus-4-7",
|
||||
name: "Claude Opus 4.7",
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: {
|
||||
input: 5,
|
||||
output: 25,
|
||||
cacheRead: 0.5,
|
||||
cacheWrite: 6.25,
|
||||
},
|
||||
contextWindow: 1000000,
|
||||
maxTokens: 128000,
|
||||
} satisfies Model<"anthropic-messages">,
|
||||
"claude-sonnet-4-0": {
|
||||
id: "claude-sonnet-4-0",
|
||||
name: "Claude Sonnet 4 (latest)",
|
||||
@@ -6193,6 +6278,23 @@ export const MODELS = {
|
||||
contextWindow: 1000000,
|
||||
maxTokens: 128000,
|
||||
} satisfies Model<"anthropic-messages">,
|
||||
"claude-opus-4-7": {
|
||||
id: "claude-opus-4-7",
|
||||
name: "Claude Opus 4.7",
|
||||
api: "anthropic-messages",
|
||||
provider: "opencode",
|
||||
baseUrl: "https://opencode.ai/zen",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: {
|
||||
input: 5,
|
||||
output: 25,
|
||||
cacheRead: 0.5,
|
||||
cacheWrite: 6.25,
|
||||
},
|
||||
contextWindow: 1000000,
|
||||
maxTokens: 128000,
|
||||
} satisfies Model<"anthropic-messages">,
|
||||
"claude-sonnet-4": {
|
||||
id: "claude-sonnet-4",
|
||||
name: "Claude Sonnet 4",
|
||||
@@ -7115,6 +7217,23 @@ export const MODELS = {
|
||||
contextWindow: 1000000,
|
||||
maxTokens: 128000,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"anthropic/claude-opus-4.7": {
|
||||
id: "anthropic/claude-opus-4.7",
|
||||
name: "Anthropic: Claude Opus 4.7",
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: {
|
||||
input: 5,
|
||||
output: 25,
|
||||
cacheRead: 0.5,
|
||||
cacheWrite: 6.25,
|
||||
},
|
||||
contextWindow: 1000000,
|
||||
maxTokens: 128000,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"anthropic/claude-sonnet-4": {
|
||||
id: "anthropic/claude-sonnet-4",
|
||||
name: "Anthropic: Claude Sonnet 4",
|
||||
@@ -7753,13 +7872,13 @@ export const MODELS = {
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: {
|
||||
input: 0.08,
|
||||
output: 0.35,
|
||||
cacheRead: 0.01,
|
||||
input: 0.07,
|
||||
output: 0.39999999999999997,
|
||||
cacheRead: 0.04,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 262144,
|
||||
maxTokens: 4096,
|
||||
maxTokens: 262144,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"google/gemma-4-26b-a4b-it:free": {
|
||||
id: "google/gemma-4-26b-a4b-it:free",
|
||||
@@ -8456,7 +8575,7 @@ export const MODELS = {
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 131072,
|
||||
maxTokens: 131072,
|
||||
maxTokens: 32768,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"moonshotai/kimi-k2-0905": {
|
||||
id: "moonshotai/kimi-k2-0905",
|
||||
@@ -9779,7 +9898,7 @@ export const MODELS = {
|
||||
input: 0.26,
|
||||
output: 0.78,
|
||||
cacheRead: 0.052000000000000005,
|
||||
cacheWrite: 0,
|
||||
cacheWrite: 0.325,
|
||||
},
|
||||
contextWindow: 1000000,
|
||||
maxTokens: 32768,
|
||||
@@ -9796,7 +9915,7 @@ export const MODELS = {
|
||||
input: 0.26,
|
||||
output: 0.78,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
cacheWrite: 0.325,
|
||||
},
|
||||
contextWindow: 1000000,
|
||||
maxTokens: 32768,
|
||||
@@ -9813,7 +9932,7 @@ export const MODELS = {
|
||||
input: 0.26,
|
||||
output: 0.78,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
cacheWrite: 0.325,
|
||||
},
|
||||
contextWindow: 1000000,
|
||||
maxTokens: 32768,
|
||||
@@ -9912,13 +10031,13 @@ export const MODELS = {
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: {
|
||||
input: 0.14950000000000002,
|
||||
output: 1.495,
|
||||
input: 0.13,
|
||||
output: 0.6,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 131072,
|
||||
maxTokens: 4096,
|
||||
contextWindow: 262144,
|
||||
maxTokens: 262144,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"qwen/qwen3-30b-a3b": {
|
||||
id: "qwen/qwen3-30b-a3b",
|
||||
@@ -10051,7 +10170,7 @@ export const MODELS = {
|
||||
input: 0.195,
|
||||
output: 0.975,
|
||||
cacheRead: 0.039,
|
||||
cacheWrite: 0,
|
||||
cacheWrite: 0.24375,
|
||||
},
|
||||
contextWindow: 1000000,
|
||||
maxTokens: 65536,
|
||||
@@ -10085,7 +10204,7 @@ export const MODELS = {
|
||||
input: 0.65,
|
||||
output: 3.25,
|
||||
cacheRead: 0.13,
|
||||
cacheWrite: 0,
|
||||
cacheWrite: 0.8125,
|
||||
},
|
||||
contextWindow: 1000000,
|
||||
maxTokens: 65536,
|
||||
@@ -10119,7 +10238,7 @@ export const MODELS = {
|
||||
input: 0.78,
|
||||
output: 3.9,
|
||||
cacheRead: 0.156,
|
||||
cacheWrite: 0,
|
||||
cacheWrite: 0.975,
|
||||
},
|
||||
contextWindow: 262144,
|
||||
maxTokens: 32768,
|
||||
@@ -10373,7 +10492,7 @@ export const MODELS = {
|
||||
cost: {
|
||||
input: 0.39,
|
||||
output: 2.34,
|
||||
cacheRead: 0,
|
||||
cacheRead: 0.195,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 262144,
|
||||
@@ -10408,7 +10527,7 @@ export const MODELS = {
|
||||
input: 0.065,
|
||||
output: 0.26,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
cacheWrite: 0.08125,
|
||||
},
|
||||
contextWindow: 1000000,
|
||||
maxTokens: 65536,
|
||||
@@ -10425,7 +10544,7 @@ export const MODELS = {
|
||||
input: 0.26,
|
||||
output: 1.56,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
cacheWrite: 0.325,
|
||||
},
|
||||
contextWindow: 1000000,
|
||||
maxTokens: 65536,
|
||||
@@ -10442,7 +10561,7 @@ export const MODELS = {
|
||||
input: 0.325,
|
||||
output: 1.95,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
cacheWrite: 0.40625,
|
||||
},
|
||||
contextWindow: 1000000,
|
||||
maxTokens: 65536,
|
||||
@@ -11452,6 +11571,23 @@ export const MODELS = {
|
||||
contextWindow: 1000000,
|
||||
maxTokens: 128000,
|
||||
} satisfies Model<"anthropic-messages">,
|
||||
"anthropic/claude-opus-4.7": {
|
||||
id: "anthropic/claude-opus-4.7",
|
||||
name: "Claude Opus 4.7",
|
||||
api: "anthropic-messages",
|
||||
provider: "vercel-ai-gateway",
|
||||
baseUrl: "https://ai-gateway.vercel.sh",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: {
|
||||
input: 5,
|
||||
output: 25,
|
||||
cacheRead: 0.5,
|
||||
cacheWrite: 6.25,
|
||||
},
|
||||
contextWindow: 1000000,
|
||||
maxTokens: 128000,
|
||||
} satisfies Model<"anthropic-messages">,
|
||||
"anthropic/claude-sonnet-4": {
|
||||
id: "anthropic/claude-sonnet-4",
|
||||
name: "Claude Sonnet 4",
|
||||
@@ -13618,15 +13754,15 @@ export const MODELS = {
|
||||
provider: "vercel-ai-gateway",
|
||||
baseUrl: "https://ai-gateway.vercel.sh",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
input: ["text", "image"],
|
||||
cost: {
|
||||
input: 1.4,
|
||||
output: 4.4,
|
||||
cacheRead: 0.26,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 202800,
|
||||
maxTokens: 64000,
|
||||
contextWindow: 202752,
|
||||
maxTokens: 202752,
|
||||
} satisfies Model<"anthropic-messages">,
|
||||
"zai/glm-5v-turbo": {
|
||||
id: "zai/glm-5v-turbo",
|
||||
|
||||
@@ -50,14 +50,19 @@ export function calculateCost<TApi extends Api>(model: Model<TApi>, usage: Usage
|
||||
*
|
||||
* Supported today:
|
||||
* - GPT-5.2 / GPT-5.3 / GPT-5.4 model families
|
||||
* - Opus 4.6 models (xhigh maps to adaptive effort "max" on Anthropic-compatible providers)
|
||||
* - Opus 4.6+ models (xhigh maps to adaptive effort "max" on Anthropic-compatible providers)
|
||||
*/
|
||||
export function supportsXhigh<TApi extends Api>(model: Model<TApi>): boolean {
|
||||
if (model.id.includes("gpt-5.2") || model.id.includes("gpt-5.3") || model.id.includes("gpt-5.4")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (model.id.includes("opus-4-6") || model.id.includes("opus-4.6")) {
|
||||
if (
|
||||
model.id.includes("opus-4-6") ||
|
||||
model.id.includes("opus-4.6") ||
|
||||
model.id.includes("opus-4-7") ||
|
||||
model.id.includes("opus-4.7")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,11 +15,14 @@ import {
|
||||
type ConverseStreamMetadataEvent,
|
||||
ImageFormat,
|
||||
type Message,
|
||||
type ServiceInputTypes,
|
||||
type ServiceOutputTypes,
|
||||
type SystemContentBlock,
|
||||
type ToolChoice,
|
||||
type ToolConfiguration,
|
||||
ToolResultStatus,
|
||||
} from "@aws-sdk/client-bedrock-runtime";
|
||||
import type { FinalizeRequestMiddleware } from "@smithy/types";
|
||||
|
||||
import { calculateCost } from "../models.js";
|
||||
import type {
|
||||
@@ -46,6 +49,8 @@ import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
|
||||
import { adjustMaxTokensForThinking, buildBaseOptions, clampReasoning } from "./simple-options.js";
|
||||
import { transformMessages } from "./transform-messages.js";
|
||||
|
||||
export type BedrockThinkingDisplay = "summarized" | "omitted";
|
||||
|
||||
export interface BedrockOptions extends StreamOptions {
|
||||
region?: string;
|
||||
profile?: string;
|
||||
@@ -56,11 +61,28 @@ export interface BedrockOptions extends StreamOptions {
|
||||
thinkingBudgets?: ThinkingBudgets;
|
||||
/* Only supported by Claude 4.x models, see https://docs.aws.amazon.com/bedrock/latest/userguide/claude-messages-extended-thinking.html#claude-messages-extended-thinking-tool-use-interleaved */
|
||||
interleavedThinking?: boolean;
|
||||
/**
|
||||
* Controls how Claude's thinking content is returned in responses.
|
||||
* - "summarized": Thinking blocks contain summarized thinking text (default here).
|
||||
* - "omitted": Thinking content is redacted but the signature still travels back
|
||||
* for multi-turn continuity, reducing time-to-first-text-token.
|
||||
*
|
||||
* Note: Anthropic's API default for Claude Opus 4.7 and Mythos Preview is
|
||||
* "omitted". We default to "summarized" here to keep behavior consistent with
|
||||
* older Claude 4 models. Only applies to Claude models on Bedrock.
|
||||
*/
|
||||
thinkingDisplay?: BedrockThinkingDisplay;
|
||||
/** Key-value pairs attached to the inference request for cost allocation tagging.
|
||||
* Keys: max 64 chars, no `aws:` prefix. Values: max 256 chars. Max 50 pairs.
|
||||
* Tags appear in AWS Cost Explorer split cost allocation data.
|
||||
* @see https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_ConverseStream.html */
|
||||
requestMetadata?: Record<string, string>;
|
||||
/** Bearer token for Bedrock API key authentication.
|
||||
* When set, bypasses SigV4 signing and sends Authorization: Bearer <token> instead.
|
||||
* Requires `bedrock:CallWithBearerToken` IAM permission on the token's identity.
|
||||
* Set via AWS_BEARER_TOKEN_BEDROCK env var or pass directly.
|
||||
* @see https://docs.aws.amazon.com/service-authorization/latest/reference/list_amazonbedrock.html */
|
||||
bearerToken?: string;
|
||||
}
|
||||
|
||||
type Block = (TextContent | ThinkingContent | ToolCall) & { index?: number; partialJson?: string };
|
||||
@@ -97,6 +119,9 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt
|
||||
profile: options.profile,
|
||||
};
|
||||
|
||||
// Resolve bearer token for API key auth (bypasses SigV4)
|
||||
const bearerToken = options.bearerToken || process.env.AWS_BEARER_TOKEN_BEDROCK || undefined;
|
||||
|
||||
// in Node.js/Bun environment only
|
||||
if (typeof process !== "undefined" && (process.versions?.node || process.versions?.bun)) {
|
||||
// Region resolution: explicit option > env vars > SDK default chain.
|
||||
@@ -117,6 +142,15 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt
|
||||
};
|
||||
}
|
||||
|
||||
// Bearer token auth: use API key instead of SigV4 signing.
|
||||
// Requires bedrock:CallWithBearerToken IAM permission.
|
||||
if (bearerToken && process.env.AWS_BEDROCK_SKIP_AUTH !== "1") {
|
||||
config.credentials = {
|
||||
accessKeyId: "bearer-token-auth",
|
||||
secretAccessKey: "bearer-token-auth",
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
process.env.HTTP_PROXY ||
|
||||
process.env.HTTPS_PROXY ||
|
||||
@@ -151,6 +185,34 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt
|
||||
try {
|
||||
const client = new BedrockRuntimeClient(config);
|
||||
|
||||
// Inject bearer token middleware after SigV4 signing
|
||||
if (bearerToken) {
|
||||
const bearerTokenAuthMiddleware: FinalizeRequestMiddleware<ServiceInputTypes, ServiceOutputTypes> =
|
||||
(next) => async (args) => {
|
||||
const request = args.request;
|
||||
if (
|
||||
typeof request === "object" &&
|
||||
request !== null &&
|
||||
"headers" in request &&
|
||||
typeof request.headers === "object" &&
|
||||
request.headers !== null
|
||||
) {
|
||||
const headers = request.headers as Record<string, string>;
|
||||
headers.authorization = `Bearer ${bearerToken}`;
|
||||
delete headers["x-amz-date"];
|
||||
delete headers["x-amz-security-token"];
|
||||
delete headers["x-amz-content-sha256"];
|
||||
}
|
||||
return next(args);
|
||||
};
|
||||
|
||||
client.middlewareStack.addRelativeTo(bearerTokenAuthMiddleware, {
|
||||
relation: "after",
|
||||
toMiddleware: "awsAuthMiddleware",
|
||||
name: "bearerTokenAuth",
|
||||
});
|
||||
}
|
||||
|
||||
const cacheRetention = resolveCacheRetention(options.cacheRetention);
|
||||
let commandInput = {
|
||||
modelId: model.id,
|
||||
@@ -168,6 +230,13 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt
|
||||
const command = new ConverseStreamCommand(commandInput);
|
||||
|
||||
const response = await client.send(command, { abortSignal: options.signal });
|
||||
if (response.$metadata.httpStatusCode !== undefined) {
|
||||
const responseHeaders: Record<string, string> = {};
|
||||
if (response.$metadata.requestId) {
|
||||
responseHeaders["x-amzn-requestid"] = response.$metadata.requestId;
|
||||
}
|
||||
await options?.onResponse?.({ status: response.$metadata.httpStatusCode, headers: responseHeaders }, model);
|
||||
}
|
||||
|
||||
for await (const item of response.stream!) {
|
||||
if (item.messageStart) {
|
||||
@@ -423,12 +492,14 @@ function handleContentBlockStop(
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the model supports adaptive thinking (Opus 4.6 and Sonnet 4.6).
|
||||
* Check if the model supports adaptive thinking (Opus 4.6+, Sonnet 4.6).
|
||||
*/
|
||||
function supportsAdaptiveThinking(modelId: string): boolean {
|
||||
return (
|
||||
modelId.includes("opus-4-6") ||
|
||||
modelId.includes("opus-4.6") ||
|
||||
modelId.includes("opus-4-7") ||
|
||||
modelId.includes("opus-4.7") ||
|
||||
modelId.includes("sonnet-4-6") ||
|
||||
modelId.includes("sonnet-4.6")
|
||||
);
|
||||
@@ -437,7 +508,7 @@ function supportsAdaptiveThinking(modelId: string): boolean {
|
||||
function mapThinkingLevelToEffort(
|
||||
level: SimpleStreamOptions["reasoning"],
|
||||
modelId: string,
|
||||
): "low" | "medium" | "high" | "max" {
|
||||
): "low" | "medium" | "high" | "xhigh" | "max" {
|
||||
switch (level) {
|
||||
case "minimal":
|
||||
case "low":
|
||||
@@ -447,7 +518,13 @@ function mapThinkingLevelToEffort(
|
||||
case "high":
|
||||
return "high";
|
||||
case "xhigh":
|
||||
return modelId.includes("opus-4-6") || modelId.includes("opus-4.6") ? "max" : "high";
|
||||
if (modelId.includes("opus-4-6") || modelId.includes("opus-4.6")) {
|
||||
return "max";
|
||||
}
|
||||
if (modelId.includes("opus-4-7") || modelId.includes("opus-4.7")) {
|
||||
return "xhigh";
|
||||
}
|
||||
return "high";
|
||||
default:
|
||||
return "high";
|
||||
}
|
||||
@@ -744,9 +821,12 @@ function buildAdditionalModelRequestFields(
|
||||
}
|
||||
|
||||
if (model.id.includes("anthropic.claude") || model.id.includes("anthropic/claude")) {
|
||||
// Default to "summarized" so Opus 4.7 and Mythos Preview behave like
|
||||
// older Claude 4 models (whose API default is also "summarized").
|
||||
const display: BedrockThinkingDisplay = options.thinkingDisplay ?? "summarized";
|
||||
const result: Record<string, any> = supportsAdaptiveThinking(model.id)
|
||||
? {
|
||||
thinking: { type: "adaptive" },
|
||||
thinking: { type: "adaptive", display },
|
||||
output_config: { effort: mapThinkingLevelToEffort(options.reasoning, model.id) },
|
||||
}
|
||||
: (() => {
|
||||
@@ -766,6 +846,7 @@ function buildAdditionalModelRequestFields(
|
||||
thinking: {
|
||||
type: "enabled",
|
||||
budget_tokens: budget,
|
||||
display,
|
||||
},
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
ToolResultMessage,
|
||||
} from "../types.js";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream.js";
|
||||
import { headersToRecord } from "../utils/headers.js";
|
||||
import { parseStreamingJson } from "../utils/json-parse.js";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
|
||||
|
||||
@@ -153,7 +154,9 @@ function convertContentBlocks(content: (TextContent | ImageContent)[]):
|
||||
return blocks;
|
||||
}
|
||||
|
||||
export type AnthropicEffort = "low" | "medium" | "high" | "max";
|
||||
export type AnthropicEffort = "low" | "medium" | "high" | "xhigh" | "max";
|
||||
|
||||
export type AnthropicThinkingDisplay = "summarized" | "omitted";
|
||||
|
||||
export interface AnthropicOptions extends StreamOptions {
|
||||
/**
|
||||
@@ -168,15 +171,28 @@ export interface AnthropicOptions extends StreamOptions {
|
||||
*/
|
||||
thinkingBudgetTokens?: number;
|
||||
/**
|
||||
* Effort level for adaptive thinking (Opus 4.6 and Sonnet 4.6).
|
||||
* Effort level for adaptive thinking (Opus 4.6+ and Sonnet 4.6).
|
||||
* Controls how much thinking Claude allocates:
|
||||
* - "max": Always thinks with no constraints (Opus 4.6 only)
|
||||
* - "xhigh": Highest reasoning level (Opus 4.7)
|
||||
* - "high": Always thinks, deep reasoning (default)
|
||||
* - "medium": Moderate thinking, may skip for simple queries
|
||||
* - "low": Minimal thinking, skips for simple tasks
|
||||
* Ignored for older models.
|
||||
*/
|
||||
effort?: AnthropicEffort;
|
||||
/**
|
||||
* Controls how thinking content is returned in API responses.
|
||||
* - "summarized": Thinking blocks contain summarized thinking text (default here).
|
||||
* - "omitted": Thinking blocks return an empty thinking field; the encrypted
|
||||
* signature still travels back for multi-turn continuity. Use for faster
|
||||
* time-to-first-text-token when your UI does not surface thinking.
|
||||
*
|
||||
* Note: Anthropic's API default for Claude Opus 4.7 and Claude Mythos Preview
|
||||
* is "omitted". We default to "summarized" here to keep behavior consistent
|
||||
* with older Claude 4 models. Set this explicitly to "omitted" to opt in.
|
||||
*/
|
||||
thinkingDisplay?: AnthropicThinkingDisplay;
|
||||
interleavedThinking?: boolean;
|
||||
toolChoice?: "auto" | "any" | "none" | { type: "tool"; name: string };
|
||||
/**
|
||||
@@ -257,7 +273,10 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti
|
||||
if (nextParams !== undefined) {
|
||||
params = nextParams as MessageCreateParamsStreaming;
|
||||
}
|
||||
const anthropicStream = client.messages.stream({ ...params, stream: true }, { signal: options?.signal });
|
||||
const { data: anthropicStream, response } = await client.messages
|
||||
.stream({ ...params, stream: true }, { signal: options?.signal })
|
||||
.withResponse();
|
||||
await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model);
|
||||
stream.push({ type: "start", partial: output });
|
||||
|
||||
type Block = (ThinkingContent | TextContent | (ToolCall & { partialJson: string })) & { index: number };
|
||||
@@ -448,13 +467,15 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a model supports adaptive thinking (Opus 4.6 and Sonnet 4.6)
|
||||
* Check if a model supports adaptive thinking (Opus 4.6+, Sonnet 4.6)
|
||||
*/
|
||||
function supportsAdaptiveThinking(modelId: string): boolean {
|
||||
// Opus 4.6 and Sonnet 4.6 model IDs (with or without date suffix)
|
||||
// Adaptive-thinking model IDs (with or without date suffix)
|
||||
return (
|
||||
modelId.includes("opus-4-6") ||
|
||||
modelId.includes("opus-4.6") ||
|
||||
modelId.includes("opus-4-7") ||
|
||||
modelId.includes("opus-4.7") ||
|
||||
modelId.includes("sonnet-4-6") ||
|
||||
modelId.includes("sonnet-4.6")
|
||||
);
|
||||
@@ -462,7 +483,7 @@ function supportsAdaptiveThinking(modelId: string): boolean {
|
||||
|
||||
/**
|
||||
* Map ThinkingLevel to Anthropic effort levels for adaptive thinking.
|
||||
* Note: effort "max" is only valid on Opus 4.6.
|
||||
* Note: effort "max" is only valid on Opus 4.6, while Opus 4.7 supports "xhigh".
|
||||
*/
|
||||
function mapThinkingLevelToEffort(level: SimpleStreamOptions["reasoning"], modelId: string): AnthropicEffort {
|
||||
switch (level) {
|
||||
@@ -475,7 +496,13 @@ function mapThinkingLevelToEffort(level: SimpleStreamOptions["reasoning"], model
|
||||
case "high":
|
||||
return "high";
|
||||
case "xhigh":
|
||||
return modelId.includes("opus-4-6") || modelId.includes("opus-4.6") ? "max" : "high";
|
||||
if (modelId.includes("opus-4-6") || modelId.includes("opus-4.6")) {
|
||||
return "max";
|
||||
}
|
||||
if (modelId.includes("opus-4-7") || modelId.includes("opus-4.7")) {
|
||||
return "xhigh";
|
||||
}
|
||||
return "high";
|
||||
default:
|
||||
return "high";
|
||||
}
|
||||
@@ -661,21 +688,31 @@ function buildParams(
|
||||
params.tools = convertTools(context.tools, isOAuthToken, cacheControl);
|
||||
}
|
||||
|
||||
// Configure thinking mode: adaptive (Opus 4.6 and Sonnet 4.6),
|
||||
// Configure thinking mode: adaptive (Opus 4.6+ and Sonnet 4.6),
|
||||
// budget-based (older models), or explicitly disabled.
|
||||
if (model.reasoning) {
|
||||
if (options?.thinkingEnabled) {
|
||||
// Default to "summarized" so Opus 4.7 and Mythos Preview behave like
|
||||
// older Claude 4 models (whose API default is also "summarized").
|
||||
const display: AnthropicThinkingDisplay = options.thinkingDisplay ?? "summarized";
|
||||
if (supportsAdaptiveThinking(model.id)) {
|
||||
// Adaptive thinking: Claude decides when and how much to think
|
||||
params.thinking = { type: "adaptive" };
|
||||
// Adaptive thinking: Claude decides when and how much to think.
|
||||
params.thinking = { type: "adaptive", display };
|
||||
if (options.effort) {
|
||||
params.output_config = { effort: options.effort };
|
||||
// The Anthropic SDK types can lag newly supported effort values such as "xhigh".
|
||||
params.output_config =
|
||||
options.effort === "xhigh"
|
||||
? ({ effort: options.effort } as unknown as NonNullable<
|
||||
MessageCreateParamsStreaming["output_config"]
|
||||
>)
|
||||
: { effort: options.effort };
|
||||
}
|
||||
} else {
|
||||
// Budget-based thinking for older models
|
||||
params.thinking = {
|
||||
type: "enabled",
|
||||
budget_tokens: options.thinkingBudgetTokens || 1024,
|
||||
display,
|
||||
};
|
||||
}
|
||||
} else if (options?.thinkingEnabled === false) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
StreamOptions,
|
||||
} from "../types.js";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream.js";
|
||||
import { headersToRecord } from "../utils/headers.js";
|
||||
import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.js";
|
||||
import { buildBaseOptions, clampReasoning } from "./simple-options.js";
|
||||
|
||||
@@ -90,10 +91,10 @@ export const streamAzureOpenAIResponses: StreamFunction<"azure-openai-responses"
|
||||
if (nextParams !== undefined) {
|
||||
params = nextParams as ResponseCreateParamsStreaming;
|
||||
}
|
||||
const openaiStream = await client.responses.create(
|
||||
params,
|
||||
options?.signal ? { signal: options.signal } : undefined,
|
||||
);
|
||||
const { data: openaiStream, response } = await client.responses
|
||||
.create(params, options?.signal ? { signal: options.signal } : undefined)
|
||||
.withResponse();
|
||||
await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model);
|
||||
stream.push({ type: "start", partial: output });
|
||||
|
||||
await processResponsesStream(openaiStream, output, stream, model);
|
||||
|
||||
@@ -435,6 +435,7 @@ export function registerFauxProvider(options: RegisterFauxProviderOptions = {}):
|
||||
|
||||
queueMicrotask(async () => {
|
||||
try {
|
||||
await streamOptions?.onResponse?.({ status: 200, headers: {} }, requestModel);
|
||||
if (!step) {
|
||||
let message = createErrorMessage(
|
||||
new Error("No more faux responses queued"),
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
ToolCall,
|
||||
} from "../types.js";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream.js";
|
||||
import { headersToRecord } from "../utils/headers.js";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
|
||||
import {
|
||||
convertMessages,
|
||||
@@ -408,6 +409,10 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli", GoogleGe
|
||||
body: requestBodyJson,
|
||||
signal: options?.signal,
|
||||
});
|
||||
await options?.onResponse?.(
|
||||
{ status: response.status, headers: headersToRecord(response.headers) },
|
||||
model,
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
break; // Success, exit retry loop
|
||||
@@ -757,6 +762,10 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli", GoogleGe
|
||||
body: requestBodyJson,
|
||||
signal: options?.signal,
|
||||
});
|
||||
await options?.onResponse?.(
|
||||
{ status: currentResponse.status, headers: headersToRecord(currentResponse.headers) },
|
||||
model,
|
||||
);
|
||||
|
||||
if (!currentResponse.ok) {
|
||||
const retryErrorText = await currentResponse.text();
|
||||
|
||||
@@ -33,6 +33,7 @@ import type {
|
||||
Usage,
|
||||
} from "../types.js";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream.js";
|
||||
import { headersToRecord } from "../utils/headers.js";
|
||||
import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.js";
|
||||
import { buildBaseOptions, clampReasoning } from "./simple-options.js";
|
||||
|
||||
@@ -214,6 +215,10 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses"
|
||||
body: bodyJson,
|
||||
signal: options?.signal,
|
||||
});
|
||||
await options?.onResponse?.(
|
||||
{ status: response.status, headers: headersToRecord(response.headers) },
|
||||
model,
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
break;
|
||||
@@ -529,14 +534,6 @@ function getWebSocketConstructor(): WebSocketConstructor | null {
|
||||
return ctor as unknown as WebSocketConstructor;
|
||||
}
|
||||
|
||||
function headersToRecord(headers: Headers): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [key, value] of headers.entries()) {
|
||||
out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function getWebSocketReadyState(socket: WebSocketLike): number | undefined {
|
||||
const readyState = (socket as { readyState?: unknown }).readyState;
|
||||
return typeof readyState === "number" ? readyState : undefined;
|
||||
|
||||
@@ -27,6 +27,7 @@ import type {
|
||||
ToolResultMessage,
|
||||
} from "../types.js";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream.js";
|
||||
import { headersToRecord } from "../utils/headers.js";
|
||||
import { parseStreamingJson } from "../utils/json-parse.js";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
|
||||
import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.js";
|
||||
@@ -91,7 +92,10 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions", OpenA
|
||||
if (nextParams !== undefined) {
|
||||
params = nextParams as OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming;
|
||||
}
|
||||
const openaiStream = await client.chat.completions.create(params, { signal: options?.signal });
|
||||
const { data: openaiStream, response } = await client.chat.completions
|
||||
.create(params, { signal: options?.signal })
|
||||
.withResponse();
|
||||
await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model);
|
||||
stream.push({ type: "start", partial: output });
|
||||
|
||||
let currentBlock: TextContent | ThinkingContent | (ToolCall & { partialArgs?: string }) | null = null;
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
Usage,
|
||||
} from "../types.js";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream.js";
|
||||
import { headersToRecord } from "../utils/headers.js";
|
||||
import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.js";
|
||||
import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.js";
|
||||
import { buildBaseOptions, clampReasoning } from "./simple-options.js";
|
||||
@@ -96,10 +97,10 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses", OpenAIRes
|
||||
if (nextParams !== undefined) {
|
||||
params = nextParams as ResponseCreateParamsStreaming;
|
||||
}
|
||||
const openaiStream = await client.responses.create(
|
||||
params,
|
||||
options?.signal ? { signal: options.signal } : undefined,
|
||||
);
|
||||
const { data: openaiStream, response } = await client.responses
|
||||
.create(params, options?.signal ? { signal: options.signal } : undefined)
|
||||
.withResponse();
|
||||
await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model);
|
||||
stream.push({ type: "start", partial: output });
|
||||
|
||||
await processResponsesStream(openaiStream, output, stream, model, {
|
||||
@@ -178,7 +179,7 @@ function createClient(
|
||||
Object.assign(headers, copilotHeaders);
|
||||
}
|
||||
|
||||
if (sessionId && model.provider === "openai" && model.baseUrl.includes("api.openai.com")) {
|
||||
if (sessionId) {
|
||||
headers.session_id = sessionId;
|
||||
headers["x-client-request-id"] = sessionId;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ export function buildBaseOptions(model: Model<Api>, options?: SimpleStreamOption
|
||||
sessionId: options?.sessionId,
|
||||
headers: options?.headers,
|
||||
onPayload: options?.onPayload,
|
||||
onResponse: options?.onResponse,
|
||||
maxRetryDelayMs: options?.maxRetryDelayMs,
|
||||
metadata: options?.metadata,
|
||||
};
|
||||
|
||||
@@ -57,6 +57,11 @@ export type CacheRetention = "none" | "short" | "long";
|
||||
|
||||
export type Transport = "sse" | "websocket" | "auto";
|
||||
|
||||
export interface ProviderResponse {
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface StreamOptions {
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
@@ -83,6 +88,11 @@ export interface StreamOptions {
|
||||
* Return undefined to keep the payload unchanged.
|
||||
*/
|
||||
onPayload?: (payload: unknown, model: Model<Api>) => unknown | undefined | Promise<unknown | undefined>;
|
||||
/**
|
||||
* Optional callback invoked after an HTTP response is received and before
|
||||
* its body stream is consumed.
|
||||
*/
|
||||
onResponse?: (response: ProviderResponse, model: Model<Api>) => void | Promise<void>;
|
||||
/**
|
||||
* Optional custom HTTP headers to include in API requests.
|
||||
* Merged with provider defaults; can override default headers.
|
||||
|
||||
7
packages/ai/src/utils/headers.ts
Normal file
7
packages/ai/src/utils/headers.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export function headersToRecord(headers: Headers): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, value] of headers.entries()) {
|
||||
result[key] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
72
packages/ai/test/anthropic-opus-4-7-smoke.test.ts
Normal file
72
packages/ai/test/anthropic-opus-4-7-smoke.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getModel } from "../src/models.js";
|
||||
import { streamSimple } from "../src/stream.js";
|
||||
import type { Context } from "../src/types.js";
|
||||
|
||||
interface AnthropicThinkingPayload {
|
||||
thinking?: { type: string };
|
||||
output_config?: { effort?: string };
|
||||
}
|
||||
|
||||
function makeContext(): Context {
|
||||
return {
|
||||
systemPrompt: "You are a precise assistant. Follow the user's instructions exactly.",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content:
|
||||
"Compute 48291 * 7317 and 90844 - 17729, add the results, and determine whether the sum is divisible by 11. Reply with exactly this format and nothing else: sum=<sum>; divisibleBy11=<yes|no>",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe.skipIf(!process.env.ANTHROPIC_API_KEY)("Anthropic Opus 4.7 smoke", () => {
|
||||
it("streams Claude Opus 4.7 with reasoning enabled", { retry: 2, timeout: 30000 }, async () => {
|
||||
const model = getModel("anthropic", "claude-opus-4-7");
|
||||
let capturedPayload: AnthropicThinkingPayload | undefined;
|
||||
const s = streamSimple(model, makeContext(), {
|
||||
reasoning: "high",
|
||||
maxTokens: 1024,
|
||||
onPayload: (payload) => {
|
||||
capturedPayload = payload as AnthropicThinkingPayload;
|
||||
return payload;
|
||||
},
|
||||
});
|
||||
|
||||
let sawThinking = false;
|
||||
|
||||
for await (const event of s) {
|
||||
if (event.type === "thinking_start" || event.type === "thinking_delta" || event.type === "thinking_end") {
|
||||
sawThinking = true;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await s.result();
|
||||
expect(response.stopReason, response.errorMessage).toBe("stop");
|
||||
expect(response.errorMessage).toBeFalsy();
|
||||
expect(capturedPayload?.thinking).toEqual({ type: "adaptive" });
|
||||
expect(capturedPayload?.output_config).toEqual({ effort: "high" });
|
||||
expect(sawThinking).toBe(true);
|
||||
|
||||
const thinkingBlock = response.content.find((block) => block.type === "thinking");
|
||||
expect(thinkingBlock?.type).toBe("thinking");
|
||||
if (!thinkingBlock || thinkingBlock.type !== "thinking") {
|
||||
throw new Error("Expected thinking block from Claude Opus 4.7");
|
||||
}
|
||||
expect(typeof thinkingBlock.thinkingSignature).toBe("string");
|
||||
const thinkingSignature = thinkingBlock.thinkingSignature;
|
||||
if (!thinkingSignature) {
|
||||
throw new Error("Expected thinking signature from Claude Opus 4.7");
|
||||
}
|
||||
expect(thinkingSignature.length).toBeGreaterThan(0);
|
||||
|
||||
const text = response.content
|
||||
.filter((block) => block.type === "text")
|
||||
.map((block) => block.text)
|
||||
.join("")
|
||||
.trim();
|
||||
expect(text).toBe("sum=353418362; divisibleBy11=yes");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getModel } from "../src/models.js";
|
||||
import { streamSimple } from "../src/stream.js";
|
||||
import type { Context, Model } from "../src/types.js";
|
||||
import type { Context, Model, SimpleStreamOptions } from "../src/types.js";
|
||||
|
||||
interface AnthropicThinkingPayload {
|
||||
thinking?: { type: string; budget_tokens?: number };
|
||||
@@ -14,7 +14,10 @@ function makePayloadCaptureContext(): Context {
|
||||
};
|
||||
}
|
||||
|
||||
async function capturePayload(model: Model<"anthropic-messages">): Promise<AnthropicThinkingPayload> {
|
||||
async function capturePayload(
|
||||
model: Model<"anthropic-messages">,
|
||||
options?: SimpleStreamOptions,
|
||||
): Promise<AnthropicThinkingPayload> {
|
||||
let capturedPayload: AnthropicThinkingPayload | undefined;
|
||||
const payloadCaptureModel: Model<"anthropic-messages"> = {
|
||||
...model,
|
||||
@@ -22,6 +25,7 @@ async function capturePayload(model: Model<"anthropic-messages">): Promise<Anthr
|
||||
};
|
||||
|
||||
const s = streamSimple(payloadCaptureModel, makePayloadCaptureContext(), {
|
||||
...options,
|
||||
apiKey: "fake-key",
|
||||
onPayload: (payload) => {
|
||||
capturedPayload = payload as AnthropicThinkingPayload;
|
||||
@@ -113,6 +117,27 @@ describe("Anthropic thinking disable payload", () => {
|
||||
expect(payload.thinking).toEqual({ type: "disabled" });
|
||||
expect(payload.output_config).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sends thinking.type=disabled for Claude Opus 4.7 when thinking is off", async () => {
|
||||
const payload = await capturePayload(getModel("anthropic", "claude-opus-4-7"));
|
||||
|
||||
expect(payload.thinking).toEqual({ type: "disabled" });
|
||||
expect(payload.output_config).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses adaptive thinking for Claude Opus 4.7 when reasoning is enabled", async () => {
|
||||
const payload = await capturePayload(getModel("anthropic", "claude-opus-4-7"), { reasoning: "high" });
|
||||
|
||||
expect(payload.thinking).toEqual({ type: "adaptive" });
|
||||
expect(payload.output_config).toEqual({ effort: "high" });
|
||||
});
|
||||
|
||||
it("maps xhigh reasoning to effort=xhigh for Claude Opus 4.7", async () => {
|
||||
const payload = await capturePayload(getModel("anthropic", "claude-opus-4-7"), { reasoning: "xhigh" });
|
||||
|
||||
expect(payload.thinking).toEqual({ type: "adaptive" });
|
||||
expect(payload.output_config).toEqual({ effort: "xhigh" });
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!process.env.ANTHROPIC_API_KEY)("Anthropic thinking disable E2E", () => {
|
||||
|
||||
78
packages/ai/test/bedrock-thinking-payload.test.ts
Normal file
78
packages/ai/test/bedrock-thinking-payload.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getModel } from "../src/models.js";
|
||||
import { streamBedrock } from "../src/providers/amazon-bedrock.js";
|
||||
import type { Context, Model, SimpleStreamOptions } from "../src/types.js";
|
||||
|
||||
interface BedrockThinkingPayload {
|
||||
additionalModelRequestFields?: {
|
||||
thinking?: { type: string; budget_tokens?: number };
|
||||
output_config?: { effort?: string };
|
||||
anthropic_beta?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
function makeContext(): Context {
|
||||
return {
|
||||
messages: [{ role: "user", content: "Hello", timestamp: Date.now() }],
|
||||
};
|
||||
}
|
||||
|
||||
async function capturePayload(
|
||||
model: Model<"bedrock-converse-stream">,
|
||||
options?: SimpleStreamOptions,
|
||||
): Promise<BedrockThinkingPayload> {
|
||||
let capturedPayload: BedrockThinkingPayload | undefined;
|
||||
const s = streamBedrock(model, makeContext(), {
|
||||
...options,
|
||||
reasoning: options?.reasoning ?? "high",
|
||||
signal: AbortSignal.abort(),
|
||||
onPayload: (payload) => {
|
||||
capturedPayload = payload as BedrockThinkingPayload;
|
||||
return payload;
|
||||
},
|
||||
});
|
||||
|
||||
for await (const event of s) {
|
||||
if (event.type === "error") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!capturedPayload) {
|
||||
throw new Error("Expected Bedrock payload to be captured before request abort");
|
||||
}
|
||||
|
||||
return capturedPayload;
|
||||
}
|
||||
|
||||
describe("Bedrock thinking payload", () => {
|
||||
it("uses adaptive thinking for Claude Opus 4.7 when reasoning is enabled", async () => {
|
||||
const baseModel = getModel("amazon-bedrock", "global.anthropic.claude-opus-4-6-v1");
|
||||
const model: Model<"bedrock-converse-stream"> = {
|
||||
...baseModel,
|
||||
id: "global.anthropic.claude-opus-4-7-v1",
|
||||
name: "Claude Opus 4.7 (Global)",
|
||||
};
|
||||
|
||||
const payload = await capturePayload(model);
|
||||
|
||||
expect(payload.additionalModelRequestFields?.thinking).toEqual({ type: "adaptive" });
|
||||
expect(payload.additionalModelRequestFields?.output_config).toEqual({ effort: "high" });
|
||||
expect(payload.additionalModelRequestFields?.anthropic_beta).toBeUndefined();
|
||||
});
|
||||
|
||||
it("maps xhigh reasoning to effort=xhigh for Claude Opus 4.7", async () => {
|
||||
const baseModel = getModel("amazon-bedrock", "global.anthropic.claude-opus-4-6-v1");
|
||||
const model: Model<"bedrock-converse-stream"> = {
|
||||
...baseModel,
|
||||
id: "global.anthropic.claude-opus-4-7-v1",
|
||||
name: "Claude Opus 4.7 (Global)",
|
||||
};
|
||||
|
||||
const payload = await capturePayload(model, { reasoning: "xhigh" });
|
||||
|
||||
expect(payload.additionalModelRequestFields?.thinking).toEqual({ type: "adaptive" });
|
||||
expect(payload.additionalModelRequestFields?.output_config).toEqual({ effort: "xhigh" });
|
||||
expect(payload.additionalModelRequestFields?.anthropic_beta).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -24,9 +24,9 @@ vi.mock("openai", () => {
|
||||
class FakeOpenAI {
|
||||
chat = {
|
||||
completions: {
|
||||
create: async (params: unknown) => {
|
||||
create: (params: unknown) => {
|
||||
mockState.lastParams = params;
|
||||
return {
|
||||
const stream = {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
const chunks = mockState.chunks ?? [
|
||||
{
|
||||
@@ -44,6 +44,17 @@ vi.mock("openai", () => {
|
||||
}
|
||||
},
|
||||
};
|
||||
const promise = Promise.resolve(stream) as Promise<typeof stream> & {
|
||||
withResponse: () => Promise<{
|
||||
data: typeof stream;
|
||||
response: { status: number; headers: Headers };
|
||||
}>;
|
||||
};
|
||||
promise.withResponse = async () => ({
|
||||
data: stream,
|
||||
response: { status: 200, headers: new Headers() },
|
||||
});
|
||||
return promise;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { getModel } from "../src/models.js";
|
||||
import { streamOpenAIResponses } from "../src/providers/openai-responses.js";
|
||||
import type { Model } from "../src/types.js";
|
||||
|
||||
type CapturedHeaders = Headers | string[][] | Record<string, string | readonly string[]> | undefined;
|
||||
|
||||
@@ -22,6 +23,7 @@ function getHeader(headers: CapturedHeaders, name: string): string | null {
|
||||
|
||||
async function captureOpenAIResponseHeaders(
|
||||
options: Parameters<typeof streamOpenAIResponses>[2],
|
||||
model: Model<"openai-responses"> = getModel("openai", "gpt-5.4"),
|
||||
): Promise<{ sessionId: string | null; clientRequestId: string | null }> {
|
||||
const captured = { sessionId: null as string | null, clientRequestId: null as string | null };
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async (_input, init) => {
|
||||
@@ -34,7 +36,7 @@ async function captureOpenAIResponseHeaders(
|
||||
});
|
||||
|
||||
const stream = streamOpenAIResponses(
|
||||
getModel("openai", "gpt-5.4"),
|
||||
model,
|
||||
{
|
||||
systemPrompt: "sys",
|
||||
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
|
||||
@@ -95,6 +97,17 @@ describe("openai-responses provider defaults", () => {
|
||||
expect(captured).toEqual({ sessionId: "session-123", clientRequestId: "session-123" });
|
||||
});
|
||||
|
||||
it("sets cache-affinity headers for proxy OpenAI Responses requests with a sessionId", async () => {
|
||||
const proxyModel: Model<"openai-responses"> = {
|
||||
...getModel("openai", "gpt-5.4"),
|
||||
provider: "opencode",
|
||||
baseUrl: "https://proxy.example.com/v1",
|
||||
};
|
||||
const captured = await captureOpenAIResponseHeaders({ sessionId: "session-123" }, proxyModel);
|
||||
|
||||
expect(captured).toEqual({ sessionId: "session-123", clientRequestId: "session-123" });
|
||||
});
|
||||
|
||||
it("lets explicit headers override the default OpenAI cache-affinity headers", async () => {
|
||||
const captured = await captureOpenAIResponseHeaders({
|
||||
sessionId: "session-123",
|
||||
|
||||
@@ -8,6 +8,12 @@ describe("supportsXhigh", () => {
|
||||
expect(supportsXhigh(model!)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for Anthropic Opus 4.7 on anthropic-messages API", () => {
|
||||
const model = getModel("anthropic", "claude-opus-4-7");
|
||||
expect(model).toBeDefined();
|
||||
expect(supportsXhigh(model!)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for non-Opus Anthropic models", () => {
|
||||
const model = getModel("anthropic", "claude-sonnet-4-5");
|
||||
expect(model).toBeDefined();
|
||||
|
||||
@@ -2,16 +2,75 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed missing root exports for `RpcClient` and RPC protocol types from `@mariozechner/pi-coding-agent`, so ESM consumers can import them from the main package entrypoint ([#3275](https://github.com/badlogic/pi-mono/issues/3275))
|
||||
|
||||
## [0.67.6] - 2026-04-16
|
||||
|
||||
### New Features
|
||||
|
||||
- Prompt templates support an `argument-hint` frontmatter field that renders before the description in the `/` autocomplete dropdown, using `<angle>` for required and `[square]` for optional arguments. See [docs/prompt-templates.md#argument-hints](docs/prompt-templates.md#argument-hints).
|
||||
- New `after_provider_response` extension hook lets extensions inspect provider HTTP status codes and headers immediately after each response is received and before stream consumption begins. See [docs/extensions.md](docs/extensions.md).
|
||||
- Compact interactive startup header with a comma-separated view of loaded AGENTS.md files, prompt templates, skills, and extensions. Press `Ctrl+O` to toggle the expanded listing.
|
||||
- Markdown links in assistant output now render as OSC 8 hyperlinks on terminals that advertise support; unknown terminals and tmux/screen default to plain text so URLs are never silently dropped.
|
||||
|
||||
### Added
|
||||
|
||||
- Added `--no-context-files` (`-nc`) to disable `AGENTS.md` and `CLAUDE.md` context file discovery and loading ([#3253](https://github.com/badlogic/pi-mono/issues/3253))
|
||||
- Added `argument-hint` frontmatter field for prompt templates, displayed before the description in the autocomplete dropdown ([#2780](https://github.com/badlogic/pi-mono/pull/2780) by [@andresvi94](https://github.com/andresvi94))
|
||||
- Added `after_provider_response` extension hook so extensions can inspect provider HTTP status codes and headers after each provider response is received and before stream consumption begins ([#3128](https://github.com/badlogic/pi-mono/issues/3128))
|
||||
- Added OSC 8 hyperlink rendering for markdown links when the terminal advertises support ([#3248](https://github.com/badlogic/pi-mono/pull/3248) by [@ofa1](https://github.com/ofa1))
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed interactive startup header to a compact, comma-separated view of loaded AGENTS.md files, prompt templates, skills, and extensions, with `Ctrl+O` to toggle the expanded listing ([#3267](https://github.com/badlogic/pi-mono/pull/3267))
|
||||
- Tightened hyperlink capability detection to default `hyperlinks: false` for unknown terminals and force it off under tmux/screen (including nested sessions), preventing markdown link URLs from disappearing on terminals that silently swallow OSC 8 sequences ([#3248](https://github.com/badlogic/pi-mono/pull/3248))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed interactive user message rendering to keep bottom padding visible in terminals affected by OSC 133 prompt markers without adding an extra blank line before the following assistant message ([#3090](https://github.com/badlogic/pi-mono/issues/3090))
|
||||
- Fixed `--verbose` startup output to begin with expanded startup help and loaded resource listings after the compact startup header change ([#3147](https://github.com/badlogic/pi-mono/issues/3147))
|
||||
- Fixed `find` tool returning no results for path-based glob patterns such as `src/**/*.spec.ts` or `some/parent/child/**` by switching fd into full-path mode and normalizing the pattern when it contains a `/` ([#3302](https://github.com/badlogic/pi-mono/issues/3302))
|
||||
- Fixed `find` tool applying nested `.gitignore` rules across sibling directories (e.g. rules from `a/.gitignore` hiding matching files under `b/`) by dropping the manual `--ignore-file` collection and delegating to fd's hierarchical `.gitignore` handling via `--no-require-git` ([#3303](https://github.com/badlogic/pi-mono/issues/3303))
|
||||
- Fixed OpenAI Responses prompt caching for non-`api.openai.com` base URLs (OpenAI-compatible proxies such as litellm, theclawbay) by sending the `session_id` and `x-client-request-id` cache-affinity headers unconditionally when a `sessionId` is provided, matching the official Codex CLI behavior ([#3264](https://github.com/badlogic/pi-mono/pull/3264) by [@vegarsti](https://github.com/vegarsti))
|
||||
- Fixed the `preset` example extension to snapshot the active model, thinking level, and tool set on the first preset application and restore that state when cycling back to `(none)`, instead of falling back to a hardcoded default tool list ([#3272](https://github.com/badlogic/pi-mono/pull/3272) by [@stembi](https://github.com/stembi))
|
||||
|
||||
## [0.67.5] - 2026-04-16
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed Opus 4.7 adaptive thinking configuration across Anthropic and Bedrock providers by recognizing Opus 4.7 adaptive-thinking support and mapping `xhigh` reasoning to provider-supported effort values ([#3286](https://github.com/badlogic/pi-mono/pull/3286) by [@markusylisiurunen](https://github.com/markusylisiurunen))
|
||||
- Fixed Zellij `Shift+Enter` regressions by reverting the Zellij-specific Kitty keyboard query bypass and restoring the previous keyboard negotiation behavior ([#3259](https://github.com/badlogic/pi-mono/issues/3259))
|
||||
|
||||
## [0.67.4] - 2026-04-16
|
||||
|
||||
### New Features
|
||||
|
||||
- `--no-context-files` (`-nc`) disables automatic `AGENTS.md` / `CLAUDE.md` discovery when you need a clean run without project context injection. See [README.md#context-files](README.md#context-files).
|
||||
- `loadProjectContextFiles()` is now exported as a standalone utility for extensions and SDK-style integrations that need to inspect the same context-file resolution order used by the CLI. See [README.md#context-files](README.md#context-files).
|
||||
- New `after_provider_response` extension hook lets extensions inspect provider HTTP status codes and headers immediately after response creation and before stream consumption. See [docs/extensions.md](docs/extensions.md).
|
||||
|
||||
### Added
|
||||
|
||||
- Added `--no-context-files` (`-nc`) to disable `AGENTS.md` and `CLAUDE.md` context file discovery and loading ([#3253](https://github.com/badlogic/pi-mono/issues/3253))
|
||||
- Exported `loadProjectContextFiles()` as a standalone utility so extensions can discover project context files without instantiating a full `DefaultResourceLoader` ([#3142](https://github.com/badlogic/pi-mono/issues/3142))
|
||||
- Added `after_provider_response` extension hook so extensions can inspect provider HTTP status codes and headers after each provider response is received and before stream consumption begins ([#3128](https://github.com/badlogic/pi-mono/issues/3128))
|
||||
|
||||
### Changed
|
||||
|
||||
- Added `claude-opus-4-7` model for Anthropic.
|
||||
- Changed Anthropic prompt caching to add a `cache_control` breakpoint on the last tool definition, so tool schemas can be cached independently from transcript updates while preserving existing cache retention behavior ([#3260](https://github.com/badlogic/pi-mono/issues/3260))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed markdown strikethrough parsing in interactive rendering and HTML export to require strict double-tilde delimiters (`~~text~~`) with non-whitespace boundaries.
|
||||
- Fixed shutdown handling to kill tracked detached `bash` tool child processes on exit signals, preventing orphaned background processes.
|
||||
>>>>>>> 165603189b60231ed8b88274729471cee676a0c0
|
||||
- Fixed flaky `edit-tool-no-full-redraw` TUI tests by waiting for asynchronous preview and preflight error rendering instead of relying on fixed render ticks.
|
||||
- Fixed `kimi-coding` default model selection to use `kimi-for-coding` instead of `kimi-k2-thinking` ([#3242](https://github.com/badlogic/pi-mono/issues/3242))
|
||||
- Fixed `ctrl+z` on native Windows to avoid crashing interactive mode, disable the default suspend binding there, and show a status message when suspend is invoked manually ([#3191](https://github.com/badlogic/pi-mono/issues/3191))
|
||||
- Fixed `find` tool cancellation and responsiveness on broad searches by making `.gitignore` discovery and `fd` execution fully abort-aware and non-blocking ([#3148](https://github.com/badlogic/pi-mono/issues/3148))
|
||||
- Fixed `grep` broad-search stalls when `context=0` by formatting match lines from ripgrep JSON output instead of doing synchronous per-match file reads ([#3205](https://github.com/badlogic/pi-mono/issues/3205))
|
||||
|
||||
## [0.67.3] - 2026-04-15
|
||||
|
||||
@@ -31,7 +90,6 @@
|
||||
- Fixed the `plan-mode` example extension to allow `eza` in the read-only bash allowlist instead of the deprecated `exa` command ([#3240](https://github.com/badlogic/pi-mono/pull/3240) by [@rwachtler](https://github.com/rwachtler))
|
||||
- Fixed `google-vertex` API key resolution to treat `gcp-vertex-credentials` as an Application Default Credentials marker instead of a literal API key, so marker-based setups correctly fall back to ADC ([#3221](https://github.com/badlogic/pi-mono/pull/3221) by [@deepkilo](https://github.com/deepkilo))
|
||||
- Fixed RPC `prompt` to wait for prompt preflight success before emitting its single authoritative response, while still treating handled and queued prompts as success ([#3049](https://github.com/badlogic/pi-mono/issues/3049))
|
||||
- Fixed Alt keybindings inside Zellij by skipping the Kitty keyboard protocol query there and enabling xterm `modifyOtherKeys` mode 2 directly ([#3163](https://github.com/badlogic/pi-mono/issues/3163))
|
||||
- Fixed `/scoped-models` reordering to propagate into the `/model` scoped tab, preserving the user-defined scoped model order instead of re-sorting it ([#3217](https://github.com/badlogic/pi-mono/issues/3217))
|
||||
- Fixed `session_shutdown` to fire on `SIGHUP` and `SIGTERM` in interactive, print, and RPC modes so extensions can run shutdown cleanup on those signal-driven exits ([#3212](https://github.com/badlogic/pi-mono/issues/3212))
|
||||
- Fixed screenshot path parsing to handle lower case am/pm in macOS screenshot filenames ([#3194](https://github.com/badlogic/pi-mono/pull/3194) by [@jay-aye-see-kay](https://github.com/jay-aye-see-kay))
|
||||
|
||||
@@ -246,6 +246,7 @@ user sends prompt ────────────────────
|
||||
│ ├─► turn_start │ │
|
||||
│ ├─► context (can modify messages) │ │
|
||||
│ ├─► before_provider_request (can inspect or replace payload)
|
||||
│ ├─► after_provider_response (status + headers, before stream consume)
|
||||
│ │ │ │
|
||||
│ │ LLM responds, may call tools: │ │
|
||||
│ │ ├─► tool_execution_start │ │
|
||||
@@ -534,6 +535,22 @@ pi.on("before_provider_request", (event, ctx) => {
|
||||
|
||||
This is mainly useful for debugging provider serialization and cache behavior.
|
||||
|
||||
#### after_provider_response
|
||||
|
||||
Fired after an HTTP response is received and before its stream body is consumed. Handlers run in extension load order.
|
||||
|
||||
```typescript
|
||||
pi.on("after_provider_response", (event, ctx) => {
|
||||
// event.status - HTTP status code
|
||||
// event.headers - normalized response headers
|
||||
if (event.status === 429) {
|
||||
console.log("rate limited", event.headers["retry-after"]);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Header availability depends on provider and transport. Providers that abstract HTTP responses may not expose headers.
|
||||
|
||||
### Model Events
|
||||
|
||||
#### model_select
|
||||
@@ -2231,7 +2248,7 @@ All examples in [examples/extensions/](../examples/extensions/).
|
||||
| `dirty-repo-guard.ts` | Warn on dirty git repo | `on("session_before_*")`, `exec` |
|
||||
| `input-transform.ts` | Transform user input | `on("input")` |
|
||||
| `model-status.ts` | React to model changes | `on("model_select")`, `setStatus` |
|
||||
| `provider-payload.ts` | Inspect or patch provider payloads | `on("before_provider_request")` |
|
||||
| `provider-payload.ts` | Inspect payloads and provider response headers | `on("before_provider_request")`, `on("after_provider_response")` |
|
||||
| `system-prompt-header.ts` | Display system prompt info | `on("agent_start")`, `getSystemPrompt` |
|
||||
| `claude-rules.ts` | Load rules from files | `on("session_start")`, `on("before_agent_start")` |
|
||||
| `file-trigger.ts` | File watcher triggers messages | `sendMessage` |
|
||||
|
||||
@@ -30,6 +30,27 @@ Review the staged changes (`git diff --cached`). Focus on:
|
||||
|
||||
- The filename becomes the command name. `review.md` becomes `/review`.
|
||||
- `description` is optional. If missing, the first non-empty line is used.
|
||||
- `argument-hint` is optional. When set, the hint is displayed before the description in the autocomplete dropdown.
|
||||
|
||||
### Argument Hints
|
||||
|
||||
Use `argument-hint` in frontmatter to show expected arguments in autocomplete. Use `<angle brackets>` for required arguments and `[square brackets]` for optional ones:
|
||||
|
||||
```markdown
|
||||
---
|
||||
description: Review PRs from URLs with structured issue and code analysis
|
||||
argument-hint: "<PR-URL>"
|
||||
---
|
||||
```
|
||||
|
||||
This renders in the autocomplete dropdown as:
|
||||
|
||||
```
|
||||
→ pr <PR-URL> — Review PRs from URLs with structured issue and code analysis
|
||||
is <issue> — Analyze GitHub issues (bugs or feature requests)
|
||||
wr [instructions] — Finish the current task end-to-end
|
||||
cl — Audit changelog entries before release
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
|
||||
@@ -32,12 +32,6 @@ If you want `Shift+Enter` to keep working in tmux via that remap, add `ctrl+j` t
|
||||
}
|
||||
```
|
||||
|
||||
## Zellij
|
||||
|
||||
Pi detects Zellij automatically and skips the Kitty keyboard protocol query there.
|
||||
Zellij currently forwards that query to the outer terminal but still sends Alt as legacy ESC-prefixed sequences, which can break Alt keybindings if applications enable Kitty mode.
|
||||
Pi uses xterm `modifyOtherKeys` mode 2 inside Zellij instead, so no extra Zellij config is required.
|
||||
|
||||
## WezTerm
|
||||
|
||||
Create `~/.wezterm.lua`:
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "pi-extension-custom-provider",
|
||||
"version": "1.18.3",
|
||||
"version": "1.18.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pi-extension-custom-provider",
|
||||
"version": "1.18.3",
|
||||
"version": "1.18.6",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.52.0"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "pi-extension-custom-provider-anthropic",
|
||||
"private": true,
|
||||
"version": "1.18.3",
|
||||
"version": "1.18.6",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"clean": "echo 'nothing to clean'",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "pi-extension-custom-provider-gitlab-duo",
|
||||
"private": true,
|
||||
"version": "1.18.3",
|
||||
"version": "1.18.6",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"clean": "echo 'nothing to clean'",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "pi-extension-custom-provider-qwen-cli",
|
||||
"private": true,
|
||||
"version": "1.17.3",
|
||||
"version": "1.17.6",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"clean": "echo 'nothing to clean'",
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { Api, Model } from "@mariozechner/pi-ai";
|
||||
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||
import { DynamicBorder, getAgentDir } from "@mariozechner/pi-coding-agent";
|
||||
import { Container, Key, type SelectItem, SelectList, Text } from "@mariozechner/pi-tui";
|
||||
@@ -97,10 +98,17 @@ function loadPresets(cwd: string): PresetsConfig {
|
||||
return { ...globalPresets, ...projectPresets };
|
||||
}
|
||||
|
||||
interface OriginalState {
|
||||
model: Model<Api> | undefined;
|
||||
thinkingLevel: "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
||||
tools: string[];
|
||||
}
|
||||
|
||||
export default function presetExtension(pi: ExtensionAPI) {
|
||||
let presets: PresetsConfig = {};
|
||||
let activePresetName: string | undefined;
|
||||
let activePreset: Preset | undefined;
|
||||
let originalState: OriginalState | undefined;
|
||||
|
||||
// Register --preset CLI flag
|
||||
pi.registerFlag("preset", {
|
||||
@@ -112,6 +120,15 @@ export default function presetExtension(pi: ExtensionAPI) {
|
||||
* Apply a preset configuration.
|
||||
*/
|
||||
async function applyPreset(name: string, preset: Preset, ctx: ExtensionContext): Promise<boolean> {
|
||||
// Snapshot state before the first preset is applied (i.e. only when transitioning from no-preset)
|
||||
if (activePresetName === undefined) {
|
||||
originalState = {
|
||||
model: ctx.model,
|
||||
thinkingLevel: pi.getThinkingLevel(),
|
||||
tools: pi.getActiveTools(),
|
||||
};
|
||||
}
|
||||
|
||||
// Apply model if specified
|
||||
if (preset.provider && preset.model) {
|
||||
const model = ctx.modelRegistry.find(preset.provider, preset.model);
|
||||
@@ -248,10 +265,18 @@ export default function presetExtension(pi: ExtensionAPI) {
|
||||
if (!result) return;
|
||||
|
||||
if (result === "(none)") {
|
||||
// Clear preset and restore defaults
|
||||
// Clear preset and restore original state
|
||||
activePresetName = undefined;
|
||||
activePreset = undefined;
|
||||
pi.setActiveTools(["read", "bash", "edit", "write"]);
|
||||
if (originalState) {
|
||||
if (originalState.model) {
|
||||
await pi.setModel(originalState.model);
|
||||
}
|
||||
pi.setThinkingLevel(originalState.thinkingLevel);
|
||||
pi.setActiveTools(originalState.tools);
|
||||
} else {
|
||||
pi.setActiveTools(["read", "bash", "edit", "write"]);
|
||||
}
|
||||
ctx.ui.notify("Preset cleared, defaults restored", "info");
|
||||
updateStatus(ctx);
|
||||
return;
|
||||
@@ -296,7 +321,15 @@ export default function presetExtension(pi: ExtensionAPI) {
|
||||
if (nextName === "(none)") {
|
||||
activePresetName = undefined;
|
||||
activePreset = undefined;
|
||||
pi.setActiveTools(["read", "bash", "edit", "write"]);
|
||||
if (originalState) {
|
||||
if (originalState.model) {
|
||||
await pi.setModel(originalState.model);
|
||||
}
|
||||
pi.setThinkingLevel(originalState.thinkingLevel);
|
||||
pi.setActiveTools(originalState.tools);
|
||||
} else {
|
||||
pi.setActiveTools(["read", "bash", "edit", "write"]);
|
||||
}
|
||||
ctx.ui.notify("Preset cleared, defaults restored", "info");
|
||||
updateStatus(ctx);
|
||||
return;
|
||||
|
||||
@@ -11,4 +11,8 @@ export default function (pi: ExtensionAPI) {
|
||||
// Optional: replace the payload instead of only logging it.
|
||||
// return { ...event.payload, temperature: 0 };
|
||||
});
|
||||
|
||||
pi.on("after_provider_response", (event) => {
|
||||
appendFileSync(logFile, `[${event.status}] ${JSON.stringify(event.headers)}\n\n`, "utf8");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "pi-extension-with-deps",
|
||||
"version": "1.31.3",
|
||||
"version": "1.31.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pi-extension-with-deps",
|
||||
"version": "1.31.3",
|
||||
"version": "1.31.6",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "pi-extension-with-deps",
|
||||
"private": true,
|
||||
"version": "1.31.3",
|
||||
"version": "1.31.6",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"clean": "echo 'nothing to clean'",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mariozechner/pi-coding-agent",
|
||||
"version": "0.67.3",
|
||||
"version": "0.67.6",
|
||||
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
||||
"type": "module",
|
||||
"piConfig": {
|
||||
@@ -40,9 +40,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@mariozechner/jiti": "^2.6.2",
|
||||
"@mariozechner/pi-agent-core": "^0.67.3",
|
||||
"@mariozechner/pi-ai": "^0.67.3",
|
||||
"@mariozechner/pi-tui": "^0.67.3",
|
||||
"@mariozechner/pi-agent-core": "^0.67.6",
|
||||
"@mariozechner/pi-ai": "^0.67.6",
|
||||
"@mariozechner/pi-tui": "^0.67.6",
|
||||
"@silvia-odwyer/photon-node": "^0.3.4",
|
||||
"ajv": "^8.17.1",
|
||||
"chalk": "^5.5.0",
|
||||
|
||||
@@ -33,6 +33,16 @@ export type CreateAgentSessionRuntimeFactory = (options: {
|
||||
sessionStartEvent?: SessionStartEvent;
|
||||
}) => Promise<CreateAgentSessionRuntimeResult>;
|
||||
|
||||
export class SessionImportFileNotFoundError extends Error {
|
||||
readonly filePath: string;
|
||||
|
||||
constructor(filePath: string) {
|
||||
super(`File not found: ${filePath}`);
|
||||
this.name = "SessionImportFileNotFoundError";
|
||||
this.filePath = filePath;
|
||||
}
|
||||
}
|
||||
|
||||
function extractUserMessageText(content: string | Array<{ type: string; text?: string }>): string {
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
@@ -251,7 +261,7 @@ export class AgentSessionRuntime {
|
||||
async importFromJsonl(inputPath: string, cwdOverride?: string): Promise<{ cancelled: boolean }> {
|
||||
const resolvedPath = resolve(inputPath);
|
||||
if (!existsSync(resolvedPath)) {
|
||||
throw new Error(`File not found: ${resolvedPath}`);
|
||||
throw new SessionImportFileNotFoundError(resolvedPath);
|
||||
}
|
||||
|
||||
const sessionDir = this.session.sessionManager.getSessionDir();
|
||||
|
||||
@@ -1448,9 +1448,23 @@
|
||||
}
|
||||
|
||||
// Configure marked with syntax highlighting and HTML escaping for text
|
||||
const strictStrikethroughRegex = /^(~~)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/;
|
||||
|
||||
marked.use({
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
tokenizer: {
|
||||
del(src) {
|
||||
const match = strictStrikethroughRegex.exec(src);
|
||||
if (!match) return undefined;
|
||||
return {
|
||||
type: 'del',
|
||||
raw: match[0],
|
||||
text: match[2],
|
||||
tokens: this.lexer.inlineTokens(match[2])
|
||||
};
|
||||
}
|
||||
},
|
||||
renderer: {
|
||||
// Code blocks: syntax highlight, no HTML escaping
|
||||
code(token) {
|
||||
|
||||
@@ -20,6 +20,7 @@ export type {
|
||||
} from "./runner.js";
|
||||
export { ExtensionRunner } from "./runner.js";
|
||||
export type {
|
||||
AfterProviderResponseEvent,
|
||||
AgentEndEvent,
|
||||
AgentStartEvent,
|
||||
// Re-exports
|
||||
|
||||
@@ -543,6 +543,13 @@ export interface BeforeProviderRequestEvent {
|
||||
payload: unknown;
|
||||
}
|
||||
|
||||
/** Fired after a provider response is received and before the response stream is consumed. */
|
||||
export interface AfterProviderResponseEvent {
|
||||
type: "after_provider_response";
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Fired after user submits prompt but before agent loop. */
|
||||
export interface BeforeAgentStartEvent {
|
||||
type: "before_agent_start";
|
||||
@@ -863,6 +870,7 @@ export type ExtensionEvent =
|
||||
| SessionEvent
|
||||
| ContextEvent
|
||||
| BeforeProviderRequestEvent
|
||||
| AfterProviderResponseEvent
|
||||
| BeforeAgentStartEvent
|
||||
| AgentStartEvent
|
||||
| AgentEndEvent
|
||||
@@ -1010,6 +1018,7 @@ export interface ExtensionAPI {
|
||||
event: "before_provider_request",
|
||||
handler: ExtensionHandler<BeforeProviderRequestEvent, BeforeProviderRequestEventResult>,
|
||||
): void;
|
||||
on(event: "after_provider_response", handler: ExtensionHandler<AfterProviderResponseEvent>): void;
|
||||
on(event: "before_agent_start", handler: ExtensionHandler<BeforeAgentStartEvent, BeforeAgentStartEventResult>): void;
|
||||
on(event: "agent_start", handler: ExtensionHandler<AgentStartEvent>): void;
|
||||
on(event: "agent_end", handler: ExtensionHandler<AgentEndEvent>): void;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.js";
|
||||
export interface PromptTemplate {
|
||||
name: string;
|
||||
description: string;
|
||||
argumentHint?: string;
|
||||
content: string;
|
||||
sourceInfo: SourceInfo;
|
||||
filePath: string; // Absolute path to the template file
|
||||
@@ -121,6 +122,7 @@ function loadTemplateFromFile(filePath: string, sourceInfo: SourceInfo): PromptT
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
...(frontmatter["argument-hint"] && { argumentHint: frontmatter["argument-hint"] }),
|
||||
content: body,
|
||||
sourceInfo,
|
||||
filePath,
|
||||
|
||||
@@ -73,7 +73,7 @@ function loadContextFileFromDir(dir: string): { path: string; content: string }
|
||||
return null;
|
||||
}
|
||||
|
||||
function loadProjectContextFiles(
|
||||
export function loadProjectContextFiles(
|
||||
options: { cwd?: string; agentDir?: string } = {},
|
||||
): Array<{ path: string; content: string }> {
|
||||
const resolvedCwd = options.cwd ?? process.cwd();
|
||||
|
||||
@@ -314,6 +314,17 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
}
|
||||
return runner.emitBeforeProviderRequest(payload);
|
||||
},
|
||||
onResponse: async (response, _model) => {
|
||||
const runner = extensionRunnerRef.current;
|
||||
if (!runner?.hasHandlers("after_provider_response")) {
|
||||
return;
|
||||
}
|
||||
await runner.emit({
|
||||
type: "after_provider_response",
|
||||
status: response.status,
|
||||
headers: response.headers,
|
||||
});
|
||||
},
|
||||
sessionId: sessionManager.getSessionId(),
|
||||
transformContext: async (messages) => {
|
||||
const runner = extensionRunnerRef.current;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { createInterface } from "node:readline";
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import { Text } from "@mariozechner/pi-tui";
|
||||
import { type Static, Type } from "@sinclair/typebox";
|
||||
import { spawnSync } from "child_process";
|
||||
import { spawn } from "child_process";
|
||||
import { existsSync } from "fs";
|
||||
import { globSync } from "glob";
|
||||
import path from "path";
|
||||
import { keyHint } from "../../modes/interactive/components/keybinding-hints.js";
|
||||
import { ensureTool } from "../../utils/tools-manager.js";
|
||||
@@ -133,7 +133,19 @@ export function createFindToolDefinition(
|
||||
return;
|
||||
}
|
||||
|
||||
const onAbort = () => reject(new Error("Operation aborted"));
|
||||
let settled = false;
|
||||
let stopChild: (() => void) | undefined;
|
||||
const settle = (fn: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
stopChild = undefined;
|
||||
fn();
|
||||
};
|
||||
const onAbort = () => {
|
||||
stopChild?.();
|
||||
settle(() => reject(new Error("Operation aborted")));
|
||||
};
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
(async () => {
|
||||
@@ -145,19 +157,28 @@ export function createFindToolDefinition(
|
||||
// If custom operations provide glob(), use that instead of fd.
|
||||
if (customOps?.glob) {
|
||||
if (!(await ops.exists(searchPath))) {
|
||||
reject(new Error(`Path not found: ${searchPath}`));
|
||||
settle(() => reject(new Error(`Path not found: ${searchPath}`)));
|
||||
return;
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
settle(() => reject(new Error("Operation aborted")));
|
||||
return;
|
||||
}
|
||||
const results = await ops.glob(pattern, searchPath, {
|
||||
ignore: ["**/node_modules/**", "**/.git/**"],
|
||||
limit: effectiveLimit,
|
||||
});
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
if (signal?.aborted) {
|
||||
settle(() => reject(new Error("Operation aborted")));
|
||||
return;
|
||||
}
|
||||
if (results.length === 0) {
|
||||
resolve({
|
||||
content: [{ type: "text", text: "No files found matching pattern" }],
|
||||
details: undefined,
|
||||
});
|
||||
settle(() =>
|
||||
resolve({
|
||||
content: [{ type: "text", text: "No files found matching pattern" }],
|
||||
details: undefined,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -183,111 +204,150 @@ export function createFindToolDefinition(
|
||||
if (notices.length > 0) {
|
||||
resultOutput += `\n\n[${notices.join(". ")}]`;
|
||||
}
|
||||
resolve({
|
||||
content: [{ type: "text", text: resultOutput }],
|
||||
details: Object.keys(details).length > 0 ? details : undefined,
|
||||
});
|
||||
settle(() =>
|
||||
resolve({
|
||||
content: [{ type: "text", text: resultOutput }],
|
||||
details: Object.keys(details).length > 0 ? details : undefined,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Default implementation uses fd.
|
||||
const fdPath = await ensureTool("fd", true);
|
||||
if (signal?.aborted) {
|
||||
settle(() => reject(new Error("Operation aborted")));
|
||||
return;
|
||||
}
|
||||
if (!fdPath) {
|
||||
reject(new Error("fd is not available and could not be downloaded"));
|
||||
settle(() => reject(new Error("fd is not available and could not be downloaded")));
|
||||
return;
|
||||
}
|
||||
|
||||
// Build fd arguments.
|
||||
// Build fd arguments. --no-require-git makes fd apply hierarchical .gitignore
|
||||
// semantics whether or not the search path is inside a git repository, without
|
||||
// leaking sibling-directory rules the way --ignore-file (a global source) would.
|
||||
const args: string[] = [
|
||||
"--glob",
|
||||
"--color=never",
|
||||
"--hidden",
|
||||
"--no-require-git",
|
||||
"--max-results",
|
||||
String(effectiveLimit),
|
||||
];
|
||||
// Include .gitignore files from the search tree.
|
||||
const gitignoreFiles = new Set<string>();
|
||||
const rootGitignore = path.join(searchPath, ".gitignore");
|
||||
if (existsSync(rootGitignore)) gitignoreFiles.add(rootGitignore);
|
||||
try {
|
||||
const nestedGitignores = globSync("**/.gitignore", {
|
||||
cwd: searchPath,
|
||||
dot: true,
|
||||
absolute: true,
|
||||
ignore: ["**/node_modules/**", "**/.git/**"],
|
||||
});
|
||||
for (const file of nestedGitignores) gitignoreFiles.add(file);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
for (const gitignorePath of gitignoreFiles) args.push("--ignore-file", gitignorePath);
|
||||
args.push(pattern, searchPath);
|
||||
|
||||
const result = spawnSync(fdPath, args, { encoding: "utf-8", maxBuffer: 10 * 1024 * 1024 });
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
if (result.error) {
|
||||
reject(new Error(`Failed to run fd: ${result.error.message}`));
|
||||
return;
|
||||
// fd --glob matches against the basename unless --full-path is set; in --full-path
|
||||
// mode it matches against the absolute candidate path, so a path-containing
|
||||
// pattern like 'src/**/*.spec.ts' needs a leading '**/' to match anything.
|
||||
let effectivePattern = pattern;
|
||||
if (pattern.includes("/")) {
|
||||
args.push("--full-path");
|
||||
if (!pattern.startsWith("/") && !pattern.startsWith("**/") && pattern !== "**") {
|
||||
effectivePattern = `**/${pattern}`;
|
||||
}
|
||||
}
|
||||
args.push(effectivePattern, searchPath);
|
||||
|
||||
const output = result.stdout?.trim() || "";
|
||||
if (result.status !== 0) {
|
||||
const errorMsg = result.stderr?.trim() || `fd exited with code ${result.status}`;
|
||||
if (!output) {
|
||||
reject(new Error(errorMsg));
|
||||
const child = spawn(fdPath, args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||
const rl = createInterface({ input: child.stdout });
|
||||
let stderr = "";
|
||||
const lines: string[] = [];
|
||||
|
||||
stopChild = () => {
|
||||
if (!child.killed) {
|
||||
child.kill();
|
||||
}
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
rl.close();
|
||||
};
|
||||
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
rl.on("line", (line) => {
|
||||
lines.push(line);
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
cleanup();
|
||||
settle(() => reject(new Error(`Failed to run fd: ${error.message}`)));
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
cleanup();
|
||||
if (signal?.aborted) {
|
||||
settle(() => reject(new Error("Operation aborted")));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!output) {
|
||||
resolve({
|
||||
content: [{ type: "text", text: "No files found matching pattern" }],
|
||||
details: undefined,
|
||||
});
|
||||
const output = lines.join("\n");
|
||||
if (code !== 0) {
|
||||
const errorMsg = stderr.trim() || `fd exited with code ${code}`;
|
||||
if (!output) {
|
||||
settle(() => reject(new Error(errorMsg)));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!output) {
|
||||
settle(() =>
|
||||
resolve({
|
||||
content: [{ type: "text", text: "No files found matching pattern" }],
|
||||
details: undefined,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const relativized: string[] = [];
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.replace(/\r$/, "").trim();
|
||||
if (!line) continue;
|
||||
const hadTrailingSlash = line.endsWith("/") || line.endsWith("\\");
|
||||
let relativePath = line;
|
||||
if (line.startsWith(searchPath)) {
|
||||
relativePath = line.slice(searchPath.length + 1);
|
||||
} else {
|
||||
relativePath = path.relative(searchPath, line);
|
||||
}
|
||||
if (hadTrailingSlash && !relativePath.endsWith("/")) relativePath += "/";
|
||||
relativized.push(toPosixPath(relativePath));
|
||||
}
|
||||
|
||||
const resultLimitReached = relativized.length >= effectiveLimit;
|
||||
const rawOutput = relativized.join("\n");
|
||||
const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
|
||||
let resultOutput = truncation.content;
|
||||
const details: FindToolDetails = {};
|
||||
const notices: string[] = [];
|
||||
if (resultLimitReached) {
|
||||
notices.push(
|
||||
`${effectiveLimit} results limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`,
|
||||
);
|
||||
details.resultLimitReached = effectiveLimit;
|
||||
}
|
||||
if (truncation.truncated) {
|
||||
notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
|
||||
details.truncation = truncation;
|
||||
}
|
||||
if (notices.length > 0) {
|
||||
resultOutput += `\n\n[${notices.join(". ")}]`;
|
||||
}
|
||||
settle(() =>
|
||||
resolve({
|
||||
content: [{ type: "text", text: resultOutput }],
|
||||
details: Object.keys(details).length > 0 ? details : undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
} catch (e) {
|
||||
if (signal?.aborted) {
|
||||
settle(() => reject(new Error("Operation aborted")));
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = output.split("\n");
|
||||
const relativized: string[] = [];
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.replace(/\r$/, "").trim();
|
||||
if (!line) continue;
|
||||
const hadTrailingSlash = line.endsWith("/") || line.endsWith("\\");
|
||||
let relativePath = line;
|
||||
if (line.startsWith(searchPath)) {
|
||||
relativePath = line.slice(searchPath.length + 1);
|
||||
} else {
|
||||
relativePath = path.relative(searchPath, line);
|
||||
}
|
||||
if (hadTrailingSlash && !relativePath.endsWith("/")) relativePath += "/";
|
||||
relativized.push(toPosixPath(relativePath));
|
||||
}
|
||||
|
||||
const resultLimitReached = relativized.length >= effectiveLimit;
|
||||
const rawOutput = relativized.join("\n");
|
||||
const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
|
||||
let resultOutput = truncation.content;
|
||||
const details: FindToolDetails = {};
|
||||
const notices: string[] = [];
|
||||
if (resultLimitReached) {
|
||||
notices.push(
|
||||
`${effectiveLimit} results limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`,
|
||||
);
|
||||
details.resultLimitReached = effectiveLimit;
|
||||
}
|
||||
if (truncation.truncated) {
|
||||
notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
|
||||
details.truncation = truncation;
|
||||
}
|
||||
if (notices.length > 0) {
|
||||
resultOutput += `\n\n[${notices.join(". ")}]`;
|
||||
}
|
||||
resolve({
|
||||
content: [{ type: "text", text: resultOutput }],
|
||||
details: Object.keys(details).length > 0 ? details : undefined,
|
||||
});
|
||||
} catch (e: any) {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
reject(e);
|
||||
const error = e instanceof Error ? e : new Error(String(e));
|
||||
settle(() => reject(error));
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
@@ -267,7 +267,7 @@ export function createGrepToolDefinition(
|
||||
};
|
||||
|
||||
// Collect matches during streaming, then format them after rg exits.
|
||||
const matches: Array<{ filePath: string; lineNumber: number }> = [];
|
||||
const matches: Array<{ filePath: string; lineNumber: number; lineText?: string }> = [];
|
||||
rl.on("line", (line) => {
|
||||
if (!line.trim() || matchCount >= effectiveLimit) return;
|
||||
let event: any;
|
||||
@@ -280,7 +280,9 @@ export function createGrepToolDefinition(
|
||||
matchCount++;
|
||||
const filePath = event.data?.path?.text;
|
||||
const lineNumber = event.data?.line_number;
|
||||
if (filePath && typeof lineNumber === "number") matches.push({ filePath, lineNumber });
|
||||
const lineText = event.data?.lines?.text;
|
||||
if (filePath && typeof lineNumber === "number")
|
||||
matches.push({ filePath, lineNumber, lineText });
|
||||
if (matchCount >= effectiveLimit) {
|
||||
matchLimitReached = true;
|
||||
stopChild(true);
|
||||
@@ -312,8 +314,19 @@ export function createGrepToolDefinition(
|
||||
|
||||
// Format matches after streaming finishes so custom readFile() backends can be async.
|
||||
for (const match of matches) {
|
||||
const block = await formatBlock(match.filePath, match.lineNumber);
|
||||
outputLines.push(...block);
|
||||
if (contextValue === 0 && match.lineText !== undefined) {
|
||||
const relativePath = formatPath(match.filePath);
|
||||
const sanitized = match.lineText
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\r/g, "")
|
||||
.replace(/\n$/, "");
|
||||
const { text: truncatedText, wasTruncated } = truncateLine(sanitized);
|
||||
if (wasTruncated) linesTruncated = true;
|
||||
outputLines.push(`${relativePath}:${match.lineNumber}: ${truncatedText}`);
|
||||
} else {
|
||||
const block = await formatBlock(match.filePath, match.lineNumber);
|
||||
outputLines.push(...block);
|
||||
}
|
||||
}
|
||||
|
||||
const rawOutput = outputLines.join("\n");
|
||||
|
||||
@@ -153,7 +153,7 @@ export type {
|
||||
} from "./core/package-manager.js";
|
||||
export { DefaultPackageManager } from "./core/package-manager.js";
|
||||
export type { ResourceCollision, ResourceDiagnostic, ResourceLoader } from "./core/resource-loader.js";
|
||||
export { DefaultResourceLoader } from "./core/resource-loader.js";
|
||||
export { DefaultResourceLoader, loadProjectContextFiles } from "./core/resource-loader.js";
|
||||
// SDK for programmatic usage
|
||||
export {
|
||||
AgentSessionRuntime,
|
||||
@@ -296,7 +296,14 @@ export { type MainOptions, main } from "./main.js";
|
||||
export {
|
||||
InteractiveMode,
|
||||
type InteractiveModeOptions,
|
||||
type ModelInfo,
|
||||
type PrintModeOptions,
|
||||
RpcClient,
|
||||
type RpcClientOptions,
|
||||
type RpcCommand,
|
||||
type RpcEventListener,
|
||||
type RpcResponse,
|
||||
type RpcSessionState,
|
||||
runPrintMode,
|
||||
runRpcMode,
|
||||
} from "./modes/index.js";
|
||||
|
||||
@@ -47,7 +47,7 @@ import {
|
||||
VERSION,
|
||||
} from "../../config.js";
|
||||
import { type AgentSession, type AgentSessionEvent, parseSkillBlock } from "../../core/agent-session.js";
|
||||
import type { AgentSessionRuntime } from "../../core/agent-session-runtime.js";
|
||||
import { type AgentSessionRuntime, SessionImportFileNotFoundError } from "../../core/agent-session-runtime.js";
|
||||
import type {
|
||||
ExtensionContext,
|
||||
ExtensionRunner,
|
||||
@@ -58,7 +58,7 @@ import type {
|
||||
import { FooterDataProvider, type ReadonlyFooterDataProvider } from "../../core/footer-data-provider.js";
|
||||
import { type AppKeybinding, KeybindingsManager } from "../../core/keybindings.js";
|
||||
import { createCompactionSummaryMessage } from "../../core/messages.js";
|
||||
import { findExactModelReferenceMatch, resolveModelScope } from "../../core/model-resolver.js";
|
||||
import { defaultModelPerProvider, findExactModelReferenceMatch, resolveModelScope } from "../../core/model-resolver.js";
|
||||
import { DefaultPackageManager } from "../../core/package-manager.js";
|
||||
import type { ResourceDiagnostic } from "../../core/resource-loader.js";
|
||||
import { formatMissingSessionCwdPrompt, MissingSessionCwdError } from "../../core/session-cwd.js";
|
||||
@@ -126,6 +126,22 @@ function isExpandable(obj: unknown): obj is Expandable {
|
||||
return typeof obj === "object" && obj !== null && "setExpanded" in obj && typeof obj.setExpanded === "function";
|
||||
}
|
||||
|
||||
class ExpandableText extends Text implements Expandable {
|
||||
constructor(
|
||||
private readonly getCollapsedText: () => string,
|
||||
private readonly getExpandedText: () => string,
|
||||
expanded = false,
|
||||
paddingX = 0,
|
||||
paddingY = 0,
|
||||
) {
|
||||
super(expanded ? getExpandedText() : getCollapsedText(), paddingX, paddingY);
|
||||
}
|
||||
|
||||
setExpanded(expanded: boolean): void {
|
||||
this.setText(expanded ? this.getExpandedText() : this.getCollapsedText());
|
||||
}
|
||||
}
|
||||
|
||||
type CompactionQueuedMessage = {
|
||||
text: string;
|
||||
mode: "steer" | "followUp";
|
||||
@@ -143,6 +159,14 @@ function isTruthyEnvFlag(value: string | undefined): boolean {
|
||||
return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes";
|
||||
}
|
||||
|
||||
function isUnknownModel(model: Model<any> | undefined): boolean {
|
||||
return !!model && model.provider === "unknown" && model.id === "unknown" && model.api === "unknown";
|
||||
}
|
||||
|
||||
function hasDefaultModelProvider(providerId: string): providerId is keyof typeof defaultModelPerProvider {
|
||||
return providerId in defaultModelPerProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for InteractiveMode initialization.
|
||||
*/
|
||||
@@ -408,6 +432,7 @@ export class InteractiveMode {
|
||||
const templateCommands: SlashCommand[] = this.session.promptTemplates.map((cmd) => ({
|
||||
name: cmd.name,
|
||||
description: this.prefixAutocompleteDescription(cmd.description, cmd.sourceInfo),
|
||||
...(cmd.argumentHint && { argumentHint: cmd.argumentHint }),
|
||||
}));
|
||||
|
||||
// Convert extension commands to SlashCommand format
|
||||
@@ -499,7 +524,7 @@ export class InteractiveMode {
|
||||
// Build startup instructions using keybinding hint helpers
|
||||
const hint = (keybinding: AppKeybinding, description: string) => keyHint(keybinding, description);
|
||||
|
||||
const instructions = [
|
||||
const expandedInstructions = [
|
||||
hint("app.interrupt", "to interrupt"),
|
||||
hint("app.clear", "to clear"),
|
||||
rawKeyHint(`${keyText("app.clear")} twice`, "to exit"),
|
||||
@@ -520,11 +545,28 @@ export class InteractiveMode {
|
||||
hint("app.clipboard.pasteImage", "to paste image"),
|
||||
rawKeyHint("drop files", "to attach"),
|
||||
].join("\n");
|
||||
const compactInstructions = [
|
||||
hint("app.interrupt", "interrupt"),
|
||||
rawKeyHint(`${keyText("app.clear")}/${keyText("app.exit")}`, "clear/exit"),
|
||||
rawKeyHint("/", "commands"),
|
||||
rawKeyHint("!", "bash"),
|
||||
hint("app.tools.expand", "more"),
|
||||
].join(theme.fg("muted", " · "));
|
||||
const compactOnboarding = theme.fg(
|
||||
"dim",
|
||||
`Press ${keyText("app.tools.expand")} to show full startup help and loaded resources.`,
|
||||
);
|
||||
const onboarding = theme.fg(
|
||||
"dim",
|
||||
`Pi can explain its own features and look up its docs. Ask it how to use or extend Pi.`,
|
||||
);
|
||||
this.builtInHeader = new Text(`${logo}\n${instructions}\n\n${onboarding}`, 1, 0);
|
||||
this.builtInHeader = new ExpandableText(
|
||||
() => `${logo}\n${compactInstructions}\n${compactOnboarding}\n\n${onboarding}`,
|
||||
() => `${logo}\n${expandedInstructions}\n\n${onboarding}`,
|
||||
this.getStartupExpansionState(),
|
||||
1,
|
||||
0,
|
||||
);
|
||||
|
||||
// Setup UI layout
|
||||
this.headerContainer.addChild(new Spacer(1));
|
||||
@@ -836,6 +878,27 @@ export class InteractiveMode {
|
||||
return result;
|
||||
}
|
||||
|
||||
private formatContextPath(p: string): string {
|
||||
const cwd = path.resolve(this.sessionManager.getCwd());
|
||||
const absolutePath = path.isAbsolute(p) ? path.resolve(p) : path.resolve(cwd, p);
|
||||
const relativePath = path.relative(cwd, absolutePath);
|
||||
const isInsideCwd =
|
||||
relativePath === "" ||
|
||||
(!relativePath.startsWith("..") &&
|
||||
!relativePath.startsWith(`..${path.sep}`) &&
|
||||
!path.isAbsolute(relativePath));
|
||||
|
||||
if (isInsideCwd) {
|
||||
return relativePath || ".";
|
||||
}
|
||||
|
||||
return this.formatDisplayPath(absolutePath);
|
||||
}
|
||||
|
||||
private getStartupExpansionState(): boolean {
|
||||
return this.options.verbose || this.toolOutputExpanded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a short path relative to the package root for display.
|
||||
*/
|
||||
@@ -1057,6 +1120,38 @@ export class InteractiveMode {
|
||||
}
|
||||
|
||||
const sectionHeader = (name: string, color: ThemeColor = "mdHeading") => theme.fg(color, `[${name}]`);
|
||||
const compactPath = (resourcePath: string, sourceInfo?: SourceInfo): string => {
|
||||
const shortPath = this.getShortPath(resourcePath, sourceInfo);
|
||||
const normalizedPath = shortPath.replace(/\\/g, "/");
|
||||
const segments = normalizedPath.split("/").filter((segment) => segment.length > 0 && segment !== "~");
|
||||
if (segments.length > 0) {
|
||||
return segments[segments.length - 1]!;
|
||||
}
|
||||
return shortPath;
|
||||
};
|
||||
const formatCompactList = (items: string[], options?: { sort?: boolean }): string => {
|
||||
const labels = items.map((item) => item.trim()).filter((item) => item.length > 0);
|
||||
if (options?.sort !== false) {
|
||||
labels.sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
return theme.fg("dim", ` ${labels.join(", ")}`);
|
||||
};
|
||||
const addLoadedSection = (
|
||||
name: string,
|
||||
collapsedBody: string,
|
||||
expandedBody = collapsedBody,
|
||||
color: ThemeColor = "mdHeading",
|
||||
): void => {
|
||||
const section = new ExpandableText(
|
||||
() => `${sectionHeader(name, color)}\n${collapsedBody}`,
|
||||
() => `${sectionHeader(name, color)}\n${expandedBody}`,
|
||||
this.getStartupExpansionState(),
|
||||
0,
|
||||
0,
|
||||
);
|
||||
this.chatContainer.addChild(section);
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
};
|
||||
|
||||
const skillsResult = this.session.resourceLoader.getSkills();
|
||||
const promptsResult = this.session.resourceLoader.getPrompts();
|
||||
@@ -1096,8 +1191,11 @@ export class InteractiveMode {
|
||||
const contextList = contextFiles
|
||||
.map((f) => theme.fg("dim", ` ${this.formatDisplayPath(f.path)}`))
|
||||
.join("\n");
|
||||
this.chatContainer.addChild(new Text(`${sectionHeader("Context")}\n${contextList}`, 0, 0));
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
const contextCompactList = formatCompactList(
|
||||
contextFiles.map((contextFile) => this.formatContextPath(contextFile.path)),
|
||||
{ sort: false },
|
||||
);
|
||||
addLoadedSection("Context", contextCompactList, contextList);
|
||||
}
|
||||
|
||||
const skills = skillsResult.skills;
|
||||
@@ -1109,8 +1207,8 @@ export class InteractiveMode {
|
||||
formatPath: (item) => this.formatDisplayPath(item.path),
|
||||
formatPackagePath: (item) => this.getShortPath(item.path, item.sourceInfo),
|
||||
});
|
||||
this.chatContainer.addChild(new Text(`${sectionHeader("Skills")}\n${skillList}`, 0, 0));
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
const skillCompactList = formatCompactList(skills.map((skill) => skill.name));
|
||||
addLoadedSection("Skills", skillCompactList, skillList);
|
||||
}
|
||||
|
||||
const templates = this.session.promptTemplates;
|
||||
@@ -1129,8 +1227,8 @@ export class InteractiveMode {
|
||||
return template ? `/${template.name}` : this.formatDisplayPath(item.path);
|
||||
},
|
||||
});
|
||||
this.chatContainer.addChild(new Text(`${sectionHeader("Prompts")}\n${templateList}`, 0, 0));
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
const promptCompactList = formatCompactList(templates.map((template) => `/${template.name}`));
|
||||
addLoadedSection("Prompts", promptCompactList, templateList);
|
||||
}
|
||||
|
||||
if (extensions.length > 0) {
|
||||
@@ -1139,8 +1237,10 @@ export class InteractiveMode {
|
||||
formatPath: (item) => this.formatDisplayPath(item.path),
|
||||
formatPackagePath: (item) => this.getShortPath(item.path, item.sourceInfo),
|
||||
});
|
||||
this.chatContainer.addChild(new Text(`${sectionHeader("Extensions", "mdHeading")}\n${extList}`, 0, 0));
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
const extensionCompactList = formatCompactList(
|
||||
extensions.map((extension) => compactPath(extension.path, extension.sourceInfo)),
|
||||
);
|
||||
addLoadedSection("Extensions", extensionCompactList, extList, "mdHeading");
|
||||
}
|
||||
|
||||
// Show loaded themes (excluding built-in)
|
||||
@@ -1157,8 +1257,12 @@ export class InteractiveMode {
|
||||
formatPath: (item) => this.formatDisplayPath(item.path),
|
||||
formatPackagePath: (item) => this.getShortPath(item.path, item.sourceInfo),
|
||||
});
|
||||
this.chatContainer.addChild(new Text(`${sectionHeader("Themes")}\n${themeList}`, 0, 0));
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
const themeCompactList = formatCompactList(
|
||||
customThemes.map(
|
||||
(loadedTheme) => loadedTheme.name ?? compactPath(loadedTheme.sourcePath!, loadedTheme.sourceInfo),
|
||||
),
|
||||
);
|
||||
addLoadedSection("Themes", themeCompactList, themeList);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1607,6 +1711,9 @@ export class InteractiveMode {
|
||||
if (factory) {
|
||||
// Create and add custom header
|
||||
this.customHeader = factory(this.ui, theme);
|
||||
if (isExpandable(this.customHeader)) {
|
||||
this.customHeader.setExpanded(this.toolOutputExpanded);
|
||||
}
|
||||
if (index !== -1) {
|
||||
this.headerContainer.children[index] = this.customHeader;
|
||||
} else {
|
||||
@@ -1616,6 +1723,9 @@ export class InteractiveMode {
|
||||
} else {
|
||||
// Restore built-in header
|
||||
this.customHeader = undefined;
|
||||
if (isExpandable(this.builtInHeader)) {
|
||||
this.builtInHeader.setExpanded(this.toolOutputExpanded);
|
||||
}
|
||||
if (index !== -1) {
|
||||
this.headerContainer.children[index] = this.builtInHeader;
|
||||
}
|
||||
@@ -2168,12 +2278,12 @@ export class InteractiveMode {
|
||||
await this.handleModelCommand(searchTerm);
|
||||
return;
|
||||
}
|
||||
if (text.startsWith("/export")) {
|
||||
if (text === "/export" || text.startsWith("/export ")) {
|
||||
await this.handleExportCommand(text);
|
||||
this.editor.setText("");
|
||||
return;
|
||||
}
|
||||
if (text.startsWith("/import")) {
|
||||
if (text === "/import" || text.startsWith("/import ")) {
|
||||
await this.handleImportCommand(text);
|
||||
this.editor.setText("");
|
||||
return;
|
||||
@@ -3080,6 +3190,10 @@ export class InteractiveMode {
|
||||
|
||||
private setToolsExpanded(expanded: boolean): void {
|
||||
this.toolOutputExpanded = expanded;
|
||||
const activeHeader = this.customHeader ?? this.builtInHeader;
|
||||
if (isExpandable(activeHeader)) {
|
||||
activeHeader.setExpanded(expanded);
|
||||
}
|
||||
for (const child of this.chatContainer.children) {
|
||||
if (isExpandable(child)) {
|
||||
child.setExpanded(expanded);
|
||||
@@ -4023,6 +4137,7 @@ export class InteractiveMode {
|
||||
private async showLoginDialog(providerId: string): Promise<void> {
|
||||
const providerInfo = this.session.modelRegistry.authStorage.getOAuthProviders().find((p) => p.id === providerId);
|
||||
const providerName = providerInfo?.name || providerId;
|
||||
const previousModel = this.session.model;
|
||||
|
||||
// Providers that use callback servers (can paste redirect URL)
|
||||
const usesCallbackServer = providerInfo?.usesCallbackServer ?? false;
|
||||
@@ -4098,8 +4213,50 @@ export class InteractiveMode {
|
||||
// Success
|
||||
restoreEditor();
|
||||
this.session.modelRegistry.refresh();
|
||||
|
||||
let selectedModel: Model<any> | undefined;
|
||||
let selectionError: string | undefined;
|
||||
if (isUnknownModel(previousModel)) {
|
||||
const availableModels = this.session.modelRegistry.getAvailable();
|
||||
const providerModels = availableModels.filter((model) => model.provider === providerId);
|
||||
if (!hasDefaultModelProvider(providerId)) {
|
||||
selectionError = `Logged in to ${providerName}, but no default model is configured for provider "${providerId}". Use /model to select a model.`;
|
||||
} else if (providerModels.length === 0) {
|
||||
selectionError = `Logged in to ${providerName}, but no models are available for that provider. Use /model to select a model.`;
|
||||
} else {
|
||||
const defaultModelId = defaultModelPerProvider[providerId];
|
||||
selectedModel = providerModels.find((model) => model.id === defaultModelId);
|
||||
if (!selectedModel) {
|
||||
selectionError = `Logged in to ${providerName}, but its default model "${defaultModelId}" is not available. Use /model to select a model.`;
|
||||
} else {
|
||||
try {
|
||||
await this.session.setModel(selectedModel);
|
||||
} catch (error: unknown) {
|
||||
selectedModel = undefined;
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
selectionError = `Logged in to ${providerName}, but selecting its default model failed: ${errorMessage}. Use /model to select a model.`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.updateAvailableProviderCount();
|
||||
this.showStatus(`Logged in to ${providerName}. Credentials saved to ${getAuthPath()}`);
|
||||
this.footer.invalidate();
|
||||
this.updateEditorBorderColor();
|
||||
if (selectedModel) {
|
||||
this.showStatus(
|
||||
`Logged in to ${providerName}. Selected ${selectedModel.id}. Credentials saved to ${getAuthPath()}`,
|
||||
);
|
||||
void this.maybeWarnAboutAnthropicSubscriptionAuth(selectedModel);
|
||||
this.checkDaxnutsEasterEgg(selectedModel);
|
||||
} else {
|
||||
this.showStatus(`Logged in to ${providerName}. Credentials saved to ${getAuthPath()}`);
|
||||
if (selectionError) {
|
||||
this.showError(selectionError);
|
||||
} else {
|
||||
void this.maybeWarnAboutAnthropicSubscriptionAuth();
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
restoreEditor();
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
@@ -4152,6 +4309,10 @@ export class InteractiveMode {
|
||||
try {
|
||||
await this.session.reload();
|
||||
this.keybindings.reload();
|
||||
const activeHeader = this.customHeader ?? this.builtInHeader;
|
||||
if (isExpandable(activeHeader)) {
|
||||
activeHeader.setExpanded(this.toolOutputExpanded);
|
||||
}
|
||||
setRegisteredThemes(this.session.resourceLoader.getThemes().themes);
|
||||
this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();
|
||||
const themeName = this.settingsManager.getTheme();
|
||||
@@ -4192,8 +4353,7 @@ export class InteractiveMode {
|
||||
}
|
||||
|
||||
private async handleExportCommand(text: string): Promise<void> {
|
||||
const parts = text.split(/\s+/);
|
||||
const outputPath = parts.length > 1 ? parts[1] : undefined;
|
||||
const outputPath = this.getPathCommandArgument(text, "/export");
|
||||
|
||||
try {
|
||||
if (outputPath?.endsWith(".jsonl")) {
|
||||
@@ -4208,13 +4368,41 @@ export class InteractiveMode {
|
||||
}
|
||||
}
|
||||
|
||||
private getPathCommandArgument(text: string, command: "/export" | "/import"): string | undefined {
|
||||
if (text === command) {
|
||||
return undefined;
|
||||
}
|
||||
if (!text.startsWith(`${command} `)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const argsString = text.slice(command.length + 1).trimStart();
|
||||
if (!argsString) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const firstChar = argsString[0];
|
||||
if (firstChar === '"' || firstChar === "'") {
|
||||
const closingQuoteIndex = argsString.indexOf(firstChar, 1);
|
||||
if (closingQuoteIndex < 0) {
|
||||
return undefined;
|
||||
}
|
||||
return argsString.slice(1, closingQuoteIndex);
|
||||
}
|
||||
|
||||
const firstWhitespaceIndex = argsString.search(/\s/);
|
||||
if (firstWhitespaceIndex < 0) {
|
||||
return argsString;
|
||||
}
|
||||
return argsString.slice(0, firstWhitespaceIndex);
|
||||
}
|
||||
|
||||
private async handleImportCommand(text: string): Promise<void> {
|
||||
const parts = text.split(/\s+/);
|
||||
if (parts.length < 2 || !parts[1]) {
|
||||
const inputPath = this.getPathCommandArgument(text, "/import");
|
||||
if (!inputPath) {
|
||||
this.showError("Usage: /import <path.jsonl>");
|
||||
return;
|
||||
}
|
||||
const inputPath = parts[1];
|
||||
|
||||
const confirmed = await this.showExtensionConfirm("Import session", `Replace current session with ${inputPath}?`);
|
||||
if (!confirmed) {
|
||||
@@ -4253,6 +4441,10 @@ export class InteractiveMode {
|
||||
this.showStatus(`Session imported from: ${inputPath}`);
|
||||
return;
|
||||
}
|
||||
if (error instanceof SessionImportFileNotFoundError) {
|
||||
this.showError(`Failed to import session: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
await this.handleFatalRuntimeError("Failed to import session", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { SessionImportFileNotFoundError } from "../src/core/agent-session-runtime.js";
|
||||
import { InteractiveMode } from "../src/modes/interactive/interactive-mode.js";
|
||||
|
||||
type PathCommand = "/export" | "/import";
|
||||
|
||||
type InteractiveModePrototype = {
|
||||
getPathCommandArgument(this: unknown, text: string, command: PathCommand): string | undefined;
|
||||
handleImportCommand(this: ImportCommandContext, text: string): Promise<void>;
|
||||
};
|
||||
|
||||
type ImportCommandContext = {
|
||||
loadingAnimation?: { stop: () => void };
|
||||
statusContainer: { clear: () => void };
|
||||
runtimeHost: { importFromJsonl: (inputPath: string, cwdOverride?: string) => Promise<{ cancelled: boolean }> };
|
||||
showError: (message: string) => void;
|
||||
showStatus: (message: string) => void;
|
||||
showExtensionConfirm: (title: string, message: string) => Promise<boolean>;
|
||||
handleRuntimeSessionChange: () => Promise<void>;
|
||||
renderCurrentSessionState: () => void;
|
||||
handleFatalRuntimeError: (prefix: string, error: unknown) => Promise<never>;
|
||||
promptForMissingSessionCwd: (error: unknown) => Promise<string | undefined>;
|
||||
getPathCommandArgument: (text: string, command: PathCommand) => string | undefined;
|
||||
};
|
||||
|
||||
const interactiveModePrototype = InteractiveMode.prototype as unknown as InteractiveModePrototype;
|
||||
|
||||
describe("InteractiveMode /import parsing", () => {
|
||||
it("strips quotes from /import path arguments", () => {
|
||||
expect(interactiveModePrototype.getPathCommandArgument('/import "path/to/session.jsonl"', "/import")).toBe(
|
||||
"path/to/session.jsonl",
|
||||
);
|
||||
expect(
|
||||
interactiveModePrototype.getPathCommandArgument('/import "path with spaces/session.jsonl"', "/import"),
|
||||
).toBe("path with spaces/session.jsonl");
|
||||
});
|
||||
|
||||
it("preserves apostrophes in unquoted /import path arguments", () => {
|
||||
expect(interactiveModePrototype.getPathCommandArgument("/import john's/session.jsonl", "/import")).toBe(
|
||||
"john's/session.jsonl",
|
||||
);
|
||||
});
|
||||
|
||||
it("enforces command token boundaries", () => {
|
||||
expect(interactiveModePrototype.getPathCommandArgument("/important /tmp/session.jsonl", "/import")).toBe(
|
||||
undefined,
|
||||
);
|
||||
expect(interactiveModePrototype.getPathCommandArgument("/exporter out.html", "/export")).toBe(undefined);
|
||||
expect(interactiveModePrototype.getPathCommandArgument("/import /tmp/session.jsonl", "/import")).toBe(
|
||||
"/tmp/session.jsonl",
|
||||
);
|
||||
});
|
||||
|
||||
it("passes unquoted path to runtimeHost.importFromJsonl", async () => {
|
||||
const importFromJsonl = vi.fn(async () => ({ cancelled: false }));
|
||||
const showExtensionConfirm = vi.fn(async () => true);
|
||||
const showStatus = vi.fn();
|
||||
const showError = vi.fn();
|
||||
|
||||
const context: ImportCommandContext = {
|
||||
statusContainer: { clear: vi.fn() },
|
||||
runtimeHost: { importFromJsonl },
|
||||
showError,
|
||||
showStatus,
|
||||
showExtensionConfirm,
|
||||
handleRuntimeSessionChange: vi.fn(async () => {}),
|
||||
renderCurrentSessionState: vi.fn(),
|
||||
handleFatalRuntimeError: vi.fn(async () => {
|
||||
throw new Error("unexpected fatal error");
|
||||
}),
|
||||
promptForMissingSessionCwd: vi.fn(async () => undefined),
|
||||
getPathCommandArgument: interactiveModePrototype.getPathCommandArgument,
|
||||
};
|
||||
|
||||
await interactiveModePrototype.handleImportCommand.call(context, '/import "path/to/session.jsonl"');
|
||||
|
||||
expect(showExtensionConfirm).toHaveBeenCalledWith(
|
||||
"Import session",
|
||||
"Replace current session with path/to/session.jsonl?",
|
||||
);
|
||||
expect(importFromJsonl).toHaveBeenCalledWith("path/to/session.jsonl");
|
||||
expect(showError).not.toHaveBeenCalled();
|
||||
expect(showStatus).toHaveBeenCalledWith("Session imported from: path/to/session.jsonl");
|
||||
});
|
||||
|
||||
it("passes unquoted apostrophe path to runtimeHost.importFromJsonl unchanged", async () => {
|
||||
const importFromJsonl = vi.fn(async () => ({ cancelled: false }));
|
||||
const showExtensionConfirm = vi.fn(async () => true);
|
||||
const showStatus = vi.fn();
|
||||
const showError = vi.fn();
|
||||
|
||||
const context: ImportCommandContext = {
|
||||
statusContainer: { clear: vi.fn() },
|
||||
runtimeHost: { importFromJsonl },
|
||||
showError,
|
||||
showStatus,
|
||||
showExtensionConfirm,
|
||||
handleRuntimeSessionChange: vi.fn(async () => {}),
|
||||
renderCurrentSessionState: vi.fn(),
|
||||
handleFatalRuntimeError: vi.fn(async () => {
|
||||
throw new Error("unexpected fatal error");
|
||||
}),
|
||||
promptForMissingSessionCwd: vi.fn(async () => undefined),
|
||||
getPathCommandArgument: interactiveModePrototype.getPathCommandArgument,
|
||||
};
|
||||
|
||||
await interactiveModePrototype.handleImportCommand.call(context, "/import john's/session.jsonl");
|
||||
|
||||
expect(importFromJsonl).toHaveBeenCalledWith("john's/session.jsonl");
|
||||
expect(showError).not.toHaveBeenCalled();
|
||||
expect(showStatus).toHaveBeenCalledWith("Session imported from: john's/session.jsonl");
|
||||
});
|
||||
|
||||
it("shows a non-fatal error when /import path does not exist", async () => {
|
||||
const importFromJsonl = vi.fn(async () => {
|
||||
throw new SessionImportFileNotFoundError("/tmp/missing-session.jsonl");
|
||||
});
|
||||
const showExtensionConfirm = vi.fn(async () => true);
|
||||
const showStatus = vi.fn();
|
||||
const showError = vi.fn();
|
||||
const handleFatalRuntimeError = vi.fn(async () => {
|
||||
throw new Error("unexpected fatal error");
|
||||
});
|
||||
|
||||
const context: ImportCommandContext = {
|
||||
statusContainer: { clear: vi.fn() },
|
||||
runtimeHost: { importFromJsonl },
|
||||
showError,
|
||||
showStatus,
|
||||
showExtensionConfirm,
|
||||
handleRuntimeSessionChange: vi.fn(async () => {}),
|
||||
renderCurrentSessionState: vi.fn(),
|
||||
handleFatalRuntimeError,
|
||||
promptForMissingSessionCwd: vi.fn(async () => undefined),
|
||||
getPathCommandArgument: interactiveModePrototype.getPathCommandArgument,
|
||||
};
|
||||
|
||||
await interactiveModePrototype.handleImportCommand.call(context, "/import /tmp/missing-session.jsonl");
|
||||
|
||||
expect(showError).toHaveBeenCalledWith("Failed to import session: File not found: /tmp/missing-session.jsonl");
|
||||
expect(showStatus).not.toHaveBeenCalled();
|
||||
expect(handleFatalRuntimeError).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { homedir } from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { Container } from "@mariozechner/pi-tui";
|
||||
import { beforeAll, describe, expect, test, vi } from "vitest";
|
||||
import { InteractiveMode } from "../src/modes/interactive/interactive-mode.js";
|
||||
@@ -60,6 +62,27 @@ describe("InteractiveMode.showStatus", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("InteractiveMode.setToolsExpanded", () => {
|
||||
test("applies expansion state to the active header and chat entries", () => {
|
||||
const header = { setExpanded: vi.fn() };
|
||||
const chatChild = { setExpanded: vi.fn() };
|
||||
const fakeThis: any = {
|
||||
toolOutputExpanded: false,
|
||||
customHeader: undefined,
|
||||
builtInHeader: header,
|
||||
chatContainer: { children: [chatChild] },
|
||||
ui: { requestRender: vi.fn() },
|
||||
};
|
||||
|
||||
(InteractiveMode as any).prototype.setToolsExpanded.call(fakeThis, true);
|
||||
|
||||
expect(fakeThis.toolOutputExpanded).toBe(true);
|
||||
expect(header.setExpanded).toHaveBeenCalledWith(true);
|
||||
expect(chatChild.setExpanded).toHaveBeenCalledWith(true);
|
||||
expect(fakeThis.ui.requestRender).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("InteractiveMode.createExtensionUIContext setTheme", () => {
|
||||
test("persists theme changes to settings manager", () => {
|
||||
initTheme("dark");
|
||||
@@ -116,31 +139,41 @@ describe("InteractiveMode.showLoadedResources", () => {
|
||||
function createShowLoadedResourcesThis(options: {
|
||||
quietStartup: boolean;
|
||||
verbose?: boolean;
|
||||
skills?: Array<{ filePath: string }>;
|
||||
toolOutputExpanded?: boolean;
|
||||
cwd?: string;
|
||||
contextFiles?: Array<{ path: string; content?: string }>;
|
||||
extensions?: Array<{ path: string }>;
|
||||
skills?: Array<{ filePath: string; name: string }>;
|
||||
skillDiagnostics?: Array<{ type: "warning" | "error" | "collision"; message: string }>;
|
||||
}) {
|
||||
const fakeThis: any = {
|
||||
options: { verbose: options.verbose ?? false },
|
||||
toolOutputExpanded: options.toolOutputExpanded ?? false,
|
||||
chatContainer: new Container(),
|
||||
settingsManager: {
|
||||
getQuietStartup: () => options.quietStartup,
|
||||
},
|
||||
sessionManager: {
|
||||
getCwd: () => options.cwd ?? "/tmp/project",
|
||||
},
|
||||
session: {
|
||||
promptTemplates: [],
|
||||
extensionRunner: undefined,
|
||||
resourceLoader: {
|
||||
getPathMetadata: () => new Map(),
|
||||
getAgentsFiles: () => ({ agentsFiles: [] }),
|
||||
getAgentsFiles: () => ({ agentsFiles: options.contextFiles ?? [] }),
|
||||
getSkills: () => ({
|
||||
skills: options.skills ?? [],
|
||||
diagnostics: options.skillDiagnostics ?? [],
|
||||
}),
|
||||
getPrompts: () => ({ prompts: [], diagnostics: [] }),
|
||||
getExtensions: () => ({ extensions: [], errors: [], runtime: {} }),
|
||||
getExtensions: () => ({ extensions: options.extensions ?? [], errors: [], runtime: {} }),
|
||||
getThemes: () => ({ themes: [], diagnostics: [] }),
|
||||
},
|
||||
},
|
||||
formatDisplayPath: (p: string) => p,
|
||||
formatDisplayPath: (p: string) => (InteractiveMode as any).prototype.formatDisplayPath.call(fakeThis, p),
|
||||
formatContextPath: (p: string) => (InteractiveMode as any).prototype.formatContextPath.call(fakeThis, p),
|
||||
getStartupExpansionState: () => (InteractiveMode as any).prototype.getStartupExpansionState.call(fakeThis),
|
||||
buildScopeGroups: () => [],
|
||||
formatScopeGroups: () => "resource-list",
|
||||
getShortPath: (p: string) => p,
|
||||
@@ -151,10 +184,117 @@ describe("InteractiveMode.showLoadedResources", () => {
|
||||
return fakeThis;
|
||||
}
|
||||
|
||||
test("shows a compact resource listing by default", () => {
|
||||
const fakeThis = createShowLoadedResourcesThis({
|
||||
quietStartup: false,
|
||||
skills: [{ filePath: "/tmp/skill/SKILL.md", name: "commit" }],
|
||||
});
|
||||
|
||||
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, {
|
||||
force: false,
|
||||
});
|
||||
|
||||
const output = renderAll(fakeThis.chatContainer);
|
||||
expect(output).toContain("[Skills]");
|
||||
expect(output).toContain("commit");
|
||||
expect(output).not.toContain("resource-list");
|
||||
});
|
||||
|
||||
test("shows full resource listing when expanded", () => {
|
||||
const fakeThis = createShowLoadedResourcesThis({
|
||||
quietStartup: false,
|
||||
toolOutputExpanded: true,
|
||||
skills: [{ filePath: "/tmp/skill/SKILL.md", name: "commit" }],
|
||||
});
|
||||
|
||||
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, {
|
||||
force: false,
|
||||
});
|
||||
|
||||
const output = renderAll(fakeThis.chatContainer);
|
||||
expect(output).toContain("[Skills]");
|
||||
expect(output).toContain("resource-list");
|
||||
expect(output).not.toContain("commit");
|
||||
});
|
||||
|
||||
test("shows full resource listing on verbose startup even when tool output is collapsed", () => {
|
||||
const fakeThis = createShowLoadedResourcesThis({
|
||||
quietStartup: true,
|
||||
verbose: true,
|
||||
toolOutputExpanded: false,
|
||||
skills: [{ filePath: "/tmp/skill/SKILL.md", name: "commit" }],
|
||||
});
|
||||
|
||||
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, {
|
||||
force: false,
|
||||
});
|
||||
|
||||
const output = renderAll(fakeThis.chatContainer);
|
||||
expect(output).toContain("[Skills]");
|
||||
expect(output).toContain("resource-list");
|
||||
expect(output).not.toContain("commit");
|
||||
});
|
||||
|
||||
test("abbreviates extensions in compact listing", () => {
|
||||
const fakeThis = createShowLoadedResourcesThis({
|
||||
quietStartup: false,
|
||||
extensions: [{ path: "/tmp/extensions/answer.ts" }, { path: "/tmp/extensions/btw.ts" }],
|
||||
});
|
||||
|
||||
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, {
|
||||
force: false,
|
||||
});
|
||||
|
||||
const output = renderAll(fakeThis.chatContainer);
|
||||
expect(output).toContain("[Extensions]");
|
||||
expect(output).toContain("answer.ts, btw.ts");
|
||||
expect(output).not.toContain("extensions/answer.ts");
|
||||
});
|
||||
|
||||
test("shows context paths relative to cwd while preserving full external paths", () => {
|
||||
const home = homedir();
|
||||
const cwd = path.join(home, "Development", "pi-mono");
|
||||
const fakeThis = createShowLoadedResourcesThis({
|
||||
quietStartup: false,
|
||||
cwd,
|
||||
contextFiles: [{ path: path.join(home, ".pi", "agent", "AGENTS.md") }, { path: path.join(cwd, "AGENTS.md") }],
|
||||
});
|
||||
|
||||
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, {
|
||||
force: false,
|
||||
});
|
||||
|
||||
const output = renderAll(fakeThis.chatContainer).replace(/\\/g, "/");
|
||||
expect(output).toContain("[Context]");
|
||||
expect(output).toContain("~/.pi/agent/AGENTS.md, AGENTS.md");
|
||||
expect(output).not.toContain(`${cwd.replace(/\\/g, "/")}/AGENTS.md`);
|
||||
});
|
||||
|
||||
test("shows full context paths when expanded", () => {
|
||||
const home = homedir();
|
||||
const cwd = path.join(home, "Development", "pi-mono");
|
||||
const fakeThis = createShowLoadedResourcesThis({
|
||||
quietStartup: false,
|
||||
toolOutputExpanded: true,
|
||||
cwd,
|
||||
contextFiles: [{ path: path.join(home, ".pi", "agent", "AGENTS.md") }, { path: path.join(cwd, "AGENTS.md") }],
|
||||
});
|
||||
|
||||
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, {
|
||||
force: false,
|
||||
});
|
||||
|
||||
const output = renderAll(fakeThis.chatContainer).replace(/\\/g, "/");
|
||||
expect(output).toContain("[Context]");
|
||||
expect(output).toContain("~/.pi/agent/AGENTS.md");
|
||||
expect(output).toContain("~/Development/pi-mono/AGENTS.md");
|
||||
expect(output).not.toContain("~/.pi/agent/AGENTS.md, AGENTS.md");
|
||||
});
|
||||
|
||||
test("does not show verbose listing on quiet startup during reload", () => {
|
||||
const fakeThis = createShowLoadedResourcesThis({
|
||||
quietStartup: true,
|
||||
skills: [{ filePath: "/tmp/skill/SKILL.md" }],
|
||||
skills: [{ filePath: "/tmp/skill/SKILL.md", name: "commit" }],
|
||||
});
|
||||
|
||||
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, {
|
||||
@@ -169,7 +309,7 @@ describe("InteractiveMode.showLoadedResources", () => {
|
||||
test("still shows diagnostics on quiet startup when requested", () => {
|
||||
const fakeThis = createShowLoadedResourcesThis({
|
||||
quietStartup: true,
|
||||
skills: [{ filePath: "/tmp/skill/SKILL.md" }],
|
||||
skills: [{ filePath: "/tmp/skill/SKILL.md", name: "commit" }],
|
||||
skillDiagnostics: [{ type: "warning", message: "duplicate skill name" }],
|
||||
});
|
||||
|
||||
|
||||
@@ -8,8 +8,11 @@
|
||||
* - Edge cases and integration between parsing and substitution
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { parseCommandArgs, substituteArgs } from "../src/core/prompt-templates.js";
|
||||
import { mkdirSync, rmSync, writeFileSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { afterAll, describe, expect, test } from "vitest";
|
||||
import { loadPromptTemplates, parseCommandArgs, substituteArgs } from "../src/core/prompt-templates.js";
|
||||
|
||||
// ============================================================================
|
||||
// substituteArgs
|
||||
@@ -379,3 +382,123 @@ describe("parseCommandArgs + substituteArgs integration", () => {
|
||||
expect(substituteArgs(template1, args)).toBe(substituteArgs(template2, args));
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// loadPromptTemplates - argument-hint frontmatter
|
||||
// ============================================================================
|
||||
|
||||
describe("loadPromptTemplates - argument-hint", () => {
|
||||
const testDir = join(tmpdir(), `pi-test-prompts-${Date.now()}`);
|
||||
|
||||
function writeTemplate(name: string, content: string) {
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
writeFileSync(join(testDir, `${name}.md`), content);
|
||||
}
|
||||
|
||||
test("should parse required argument-hint from frontmatter", () => {
|
||||
writeTemplate(
|
||||
"pr",
|
||||
`---
|
||||
description: Review PRs from URLs with structured issue and code analysis
|
||||
argument-hint: "<PR-URL>"
|
||||
---
|
||||
You are given one or more GitHub PR URLs: $@`,
|
||||
);
|
||||
|
||||
const templates = loadPromptTemplates({
|
||||
promptPaths: [testDir],
|
||||
includeDefaults: false,
|
||||
});
|
||||
|
||||
const pr = templates.find((t) => t.name === "pr");
|
||||
expect(pr).toBeDefined();
|
||||
expect(pr!.argumentHint).toBe("<PR-URL>");
|
||||
expect(pr!.description).toBe("Review PRs from URLs with structured issue and code analysis");
|
||||
});
|
||||
|
||||
test("should parse optional argument-hint from frontmatter", () => {
|
||||
writeTemplate(
|
||||
"wr",
|
||||
`---
|
||||
description: Finish the current task end-to-end with changelog, commit, and push
|
||||
argument-hint: "[instructions]"
|
||||
---
|
||||
Wrap it. Additional instructions: $ARGUMENTS`,
|
||||
);
|
||||
|
||||
const templates = loadPromptTemplates({
|
||||
promptPaths: [testDir],
|
||||
includeDefaults: false,
|
||||
});
|
||||
|
||||
const wr = templates.find((t) => t.name === "wr");
|
||||
expect(wr).toBeDefined();
|
||||
expect(wr!.argumentHint).toBe("[instructions]");
|
||||
expect(wr!.description).toBe("Finish the current task end-to-end with changelog, commit, and push");
|
||||
});
|
||||
|
||||
test("should leave argumentHint undefined when not specified", () => {
|
||||
writeTemplate(
|
||||
"cl",
|
||||
`---
|
||||
description: Audit changelog entries before release
|
||||
---
|
||||
Audit changelog entries for all commits since the last release.`,
|
||||
);
|
||||
|
||||
const templates = loadPromptTemplates({
|
||||
promptPaths: [testDir],
|
||||
includeDefaults: false,
|
||||
});
|
||||
|
||||
const cl = templates.find((t) => t.name === "cl");
|
||||
expect(cl).toBeDefined();
|
||||
expect(cl!.argumentHint).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should ignore empty argument-hint", () => {
|
||||
writeTemplate(
|
||||
"empty-hint",
|
||||
`---
|
||||
description: A command with empty hint
|
||||
argument-hint: ""
|
||||
---
|
||||
Do something`,
|
||||
);
|
||||
|
||||
const templates = loadPromptTemplates({
|
||||
promptPaths: [testDir],
|
||||
includeDefaults: false,
|
||||
});
|
||||
|
||||
const tmpl = templates.find((t) => t.name === "empty-hint");
|
||||
expect(tmpl).toBeDefined();
|
||||
expect(tmpl!.argumentHint).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should preserve argument-hint with special characters", () => {
|
||||
writeTemplate(
|
||||
"is",
|
||||
`---
|
||||
description: Analyze GitHub issues (bugs or feature requests)
|
||||
argument-hint: "<issue>"
|
||||
---
|
||||
Analyze GitHub issue(s): $ARGUMENTS`,
|
||||
);
|
||||
|
||||
const templates = loadPromptTemplates({
|
||||
promptPaths: [testDir],
|
||||
includeDefaults: false,
|
||||
});
|
||||
|
||||
const is = templates.find((t) => t.name === "is");
|
||||
expect(is).toBeDefined();
|
||||
expect(is!.argumentHint).toBe("<issue>");
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
try {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
} catch {}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -142,6 +142,17 @@ export async function createHarness(options: HarnessOptions = {}): Promise<Harne
|
||||
}
|
||||
return runner.emitBeforeProviderRequest(payload);
|
||||
},
|
||||
onResponse: async (response) => {
|
||||
const runner = extensionRunnerRef.current;
|
||||
if (!runner?.hasHandlers("after_provider_response")) {
|
||||
return;
|
||||
}
|
||||
await runner.emit({
|
||||
type: "after_provider_response",
|
||||
status: response.status,
|
||||
headers: response.headers,
|
||||
});
|
||||
},
|
||||
transformContext: async (messages: AgentMessage[]) => {
|
||||
const runner = extensionRunnerRef.current;
|
||||
if (!runner) return messages;
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createFindToolDefinition } from "../../../src/core/tools/find.js";
|
||||
|
||||
/**
|
||||
* Regression test for https://github.com/badlogic/pi-mono/issues/3302
|
||||
*
|
||||
* The `find` tool advertises glob patterns like `src/**\/*.spec.ts`, but the
|
||||
* default fd-backed implementation used `fd --glob <pattern>` without
|
||||
* `--full-path`, which makes fd match only against the basename. Any pattern
|
||||
* containing a `/` therefore silently returned no matches.
|
||||
*
|
||||
* The fix switches fd into full-path mode when the pattern contains a `/`
|
||||
* and prepends `**\/` so the pattern can match against the absolute candidate
|
||||
* path that fd feeds to the matcher.
|
||||
*/
|
||||
describe("issue #3302 find returns no results for path-based glob patterns", () => {
|
||||
let tempRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempRoot = mkdtempSync(join(tmpdir(), "pi-3302-"));
|
||||
mkdirSync(join(tempRoot, "some", "parent", "child"), { recursive: true });
|
||||
mkdirSync(join(tempRoot, "src", "foo", "bar"), { recursive: true });
|
||||
writeFileSync(join(tempRoot, "some", "parent", "child", "file.ext"), "");
|
||||
writeFileSync(join(tempRoot, "some", "parent", "child", "test.spec.ts"), "");
|
||||
writeFileSync(join(tempRoot, "src", "foo", "bar", "example.spec.ts"), "");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function runFind(pattern: string): Promise<string[]> {
|
||||
const def = createFindToolDefinition(tempRoot);
|
||||
// The find tool implementation does not touch ctx; pass a minimal stub.
|
||||
const ctx = {} as Parameters<typeof def.execute>[4];
|
||||
const result = (await def.execute("call-1", { pattern }, undefined, undefined, ctx)) as {
|
||||
content: Array<{ type: string; text?: string }>;
|
||||
};
|
||||
const text = result.content[0]?.text ?? "";
|
||||
if (text === "No files found matching pattern") return [];
|
||||
return text
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 0 && !l.startsWith("["));
|
||||
}
|
||||
|
||||
it("basename pattern still matches (regression-safe)", async () => {
|
||||
const files = await runFind("*.spec.ts");
|
||||
expect(files.sort()).toEqual(["some/parent/child/test.spec.ts", "src/foo/bar/example.spec.ts"]);
|
||||
});
|
||||
|
||||
it("directory-prefixed pattern with ** tail matches subtree", async () => {
|
||||
const files = await runFind("some/parent/child/**");
|
||||
// Matches files (and possibly directories) under the subtree. Assert the two files are present.
|
||||
expect(files).toContain("some/parent/child/file.ext");
|
||||
expect(files).toContain("some/parent/child/test.spec.ts");
|
||||
});
|
||||
|
||||
it("leading ** wildcard with path segments matches", async () => {
|
||||
const files = await runFind("**/parent/child/*");
|
||||
expect(files.sort()).toContain("some/parent/child/file.ext");
|
||||
expect(files.sort()).toContain("some/parent/child/test.spec.ts");
|
||||
});
|
||||
|
||||
it("src/**/*.spec.ts matches nested spec file", async () => {
|
||||
const files = await runFind("src/**/*.spec.ts");
|
||||
expect(files).toEqual(["src/foo/bar/example.spec.ts"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createFindToolDefinition } from "../../../src/core/tools/find.js";
|
||||
|
||||
/**
|
||||
* Regression test for https://github.com/badlogic/pi-mono/issues/3303
|
||||
*
|
||||
* The `find` tool previously collected every `.gitignore` under the search
|
||||
* path and passed them to `fd` via `--ignore-file`. fd treats `--ignore-file`
|
||||
* entries as a single global ignore source, so rules from `a/.gitignore`
|
||||
* also filtered files under sibling `b/`. The fix switches to fd's
|
||||
* hierarchical `.gitignore` handling via `--no-require-git` and drops the
|
||||
* manual collection.
|
||||
*/
|
||||
describe("issue #3303 nested .gitignore rules leak into sibling directories", () => {
|
||||
let tempRoot: string;
|
||||
|
||||
async function runFind(pattern: string): Promise<string[]> {
|
||||
const def = createFindToolDefinition(tempRoot);
|
||||
const ctx = {} as Parameters<typeof def.execute>[4];
|
||||
const result = (await def.execute("call-1", { pattern }, undefined, undefined, ctx)) as {
|
||||
content: Array<{ type: string; text?: string }>;
|
||||
};
|
||||
const text = result.content[0]?.text ?? "";
|
||||
if (text === "No files found matching pattern") return [];
|
||||
return text
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 0 && !l.startsWith("["))
|
||||
.sort();
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
if (tempRoot) rmSync(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("flat sibling case", () => {
|
||||
beforeEach(() => {
|
||||
tempRoot = mkdtempSync(join(tmpdir(), "pi-3303-flat-"));
|
||||
mkdirSync(join(tempRoot, "a"), { recursive: true });
|
||||
mkdirSync(join(tempRoot, "b"), { recursive: true });
|
||||
writeFileSync(join(tempRoot, "a", ".gitignore"), "ignored.txt\n");
|
||||
writeFileSync(join(tempRoot, "a", "ignored.txt"), "");
|
||||
writeFileSync(join(tempRoot, "a", "kept.txt"), "");
|
||||
writeFileSync(join(tempRoot, "b", "ignored.txt"), "");
|
||||
writeFileSync(join(tempRoot, "b", "kept.txt"), "");
|
||||
writeFileSync(join(tempRoot, "root.txt"), "");
|
||||
});
|
||||
|
||||
it("applies a/.gitignore only inside a/ and leaves b/ untouched", async () => {
|
||||
const files = await runFind("**/*.txt");
|
||||
expect(files).toEqual(["a/kept.txt", "b/ignored.txt", "b/kept.txt", "root.txt"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deeply nested case", () => {
|
||||
beforeEach(() => {
|
||||
tempRoot = mkdtempSync(join(tmpdir(), "pi-3303-deep-"));
|
||||
mkdirSync(join(tempRoot, "a", "deep"), { recursive: true });
|
||||
mkdirSync(join(tempRoot, "b"), { recursive: true });
|
||||
writeFileSync(join(tempRoot, "a", ".gitignore"), "ignored.txt\n");
|
||||
writeFileSync(join(tempRoot, "a", "deep", ".gitignore"), "secret.txt\n");
|
||||
writeFileSync(join(tempRoot, "a", "ignored.txt"), "");
|
||||
writeFileSync(join(tempRoot, "a", "kept.txt"), "");
|
||||
writeFileSync(join(tempRoot, "a", "deep", "ignored.txt"), "");
|
||||
writeFileSync(join(tempRoot, "a", "deep", "secret.txt"), "");
|
||||
writeFileSync(join(tempRoot, "a", "deep", "kept.txt"), "");
|
||||
writeFileSync(join(tempRoot, "b", "ignored.txt"), "");
|
||||
writeFileSync(join(tempRoot, "b", "kept.txt"), "");
|
||||
writeFileSync(join(tempRoot, "root.txt"), "");
|
||||
});
|
||||
|
||||
it("scopes each .gitignore to its own subtree", async () => {
|
||||
const files = await runFind("**/*.txt");
|
||||
// a/.gitignore ignores 'ignored.txt' within a/ and a/deep/.
|
||||
// a/deep/.gitignore additionally ignores 'secret.txt' within a/deep/.
|
||||
// b/ is untouched by either.
|
||||
expect(files).toEqual(["a/deep/kept.txt", "a/kept.txt", "b/ignored.txt", "b/kept.txt", "root.txt"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -557,6 +557,15 @@ describe("Coding Agent Tools", () => {
|
||||
expect(output).toContain("kept.txt");
|
||||
expect(output).not.toContain("ignored.txt");
|
||||
});
|
||||
|
||||
it("should surface fd glob parse errors", async () => {
|
||||
await expect(
|
||||
findTool.execute("test-call-15", {
|
||||
pattern: "[",
|
||||
path: testDir,
|
||||
}),
|
||||
).rejects.toThrow(/error parsing glob|fd exited with code 1|fd error/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ls tool", () => {
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.67.6] - 2026-04-16
|
||||
|
||||
## [0.67.5] - 2026-04-16
|
||||
|
||||
## [0.67.4] - 2026-04-16
|
||||
|
||||
## [0.67.3] - 2026-04-15
|
||||
|
||||
## [0.67.2] - 2026-04-14
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mariozechner/pi-mom",
|
||||
"version": "0.67.3",
|
||||
"version": "0.67.6",
|
||||
"description": "Slack bot that delegates messages to the pi coding agent",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -20,9 +20,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sandbox-runtime": "^0.0.16",
|
||||
"@mariozechner/pi-agent-core": "^0.67.3",
|
||||
"@mariozechner/pi-ai": "^0.67.3",
|
||||
"@mariozechner/pi-coding-agent": "^0.67.3",
|
||||
"@mariozechner/pi-agent-core": "^0.67.6",
|
||||
"@mariozechner/pi-ai": "^0.67.6",
|
||||
"@mariozechner/pi-coding-agent": "^0.67.6",
|
||||
"@sinclair/typebox": "^0.34.0",
|
||||
"@slack/socket-mode": "^2.0.0",
|
||||
"@slack/web-api": "^7.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mariozechner/pi",
|
||||
"version": "0.67.3",
|
||||
"version": "0.67.6",
|
||||
"description": "CLI tool for managing vLLM deployments on GPU pods",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -33,7 +33,7 @@
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mariozechner/pi-agent-core": "^0.67.3",
|
||||
"@mariozechner/pi-agent-core": "^0.67.6",
|
||||
"chalk": "^5.5.0"
|
||||
},
|
||||
"devDependencies": {}
|
||||
|
||||
@@ -2,11 +2,28 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.67.3] - 2026-04-15
|
||||
## [0.67.6] - 2026-04-16
|
||||
|
||||
### Added
|
||||
|
||||
- Added OSC 8 hyperlink rendering for markdown links when the terminal advertises support. Introduces a public `hyperlink(text, url)` helper and a `setCapabilities()` test override in `packages/tui` ([#3248](https://github.com/badlogic/pi-mono/pull/3248) by [@ofa1](https://github.com/ofa1)).
|
||||
- Added `argumentHint` to `SlashCommand` interface, displayed before the description in the autocomplete dropdown ([#2780](https://github.com/badlogic/pi-mono/pull/2780) by [@andresvi94](https://github.com/andresvi94))
|
||||
|
||||
### Changed
|
||||
|
||||
- Tightened `detectCapabilities()` to default `hyperlinks: false` for unknown terminals and to force `hyperlinks: false` under tmux/screen (including nested sessions where the outer terminal would otherwise advertise OSC 8). Prevents markdown link URLs from disappearing on terminals that silently swallow OSC 8 sequences ([#3248](https://github.com/badlogic/pi-mono/pull/3248)).
|
||||
|
||||
## [0.67.5] - 2026-04-16
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed Alt keybindings inside Zellij by skipping the Kitty keyboard protocol query there and enabling xterm `modifyOtherKeys` mode 2 directly ([#3163](https://github.com/badlogic/pi-mono/issues/3163))
|
||||
- Fixed Zellij `Shift+Enter` regressions by reverting the Zellij-specific Kitty keyboard query bypass and restoring the previous keyboard negotiation behavior ([#3259](https://github.com/badlogic/pi-mono/issues/3259))
|
||||
|
||||
## [0.67.4] - 2026-04-16
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed markdown strikethrough parsing to require strict double-tilde delimiters (`~~text~~`) with non-whitespace boundaries, preventing accidental strikethrough from loose tilde usage.
|
||||
|
||||
## [0.67.2] - 2026-04-14
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mariozechner/pi-tui",
|
||||
"version": "0.67.3",
|
||||
"version": "0.67.6",
|
||||
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -223,6 +223,7 @@ type Awaitable<T> = T | Promise<T>;
|
||||
export interface SlashCommand {
|
||||
name: string;
|
||||
description?: string;
|
||||
argumentHint?: string;
|
||||
// Function to get argument completions for this command
|
||||
// Returns null if no argument completion is available
|
||||
getArgumentCompletions?(argumentPrefix: string): Awaitable<AutocompleteItem[] | null>;
|
||||
@@ -303,11 +304,17 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
||||
|
||||
if (spaceIndex === -1) {
|
||||
const prefix = textBeforeCursor.slice(1);
|
||||
const commandItems = this.commands.map((cmd) => ({
|
||||
name: "name" in cmd ? cmd.name : cmd.value,
|
||||
label: "name" in cmd ? cmd.name : cmd.label,
|
||||
description: cmd.description,
|
||||
}));
|
||||
const commandItems = this.commands.map((cmd) => {
|
||||
const name = "name" in cmd ? cmd.name : cmd.value;
|
||||
const hint = "argumentHint" in cmd && cmd.argumentHint ? cmd.argumentHint : undefined;
|
||||
const desc = cmd.description ?? "";
|
||||
const fullDesc = hint ? (desc ? `${hint} — ${desc}` : hint) : desc;
|
||||
return {
|
||||
name,
|
||||
label: name,
|
||||
description: fullDesc || undefined,
|
||||
};
|
||||
});
|
||||
|
||||
const filtered = fuzzyFilter(commandItems, prefix, (item) => item.name).map((item) => ({
|
||||
value: item.name,
|
||||
|
||||
@@ -1,8 +1,32 @@
|
||||
import { marked, type Token } from "marked";
|
||||
import { isImageLine } from "../terminal-image.js";
|
||||
import { Marked, type Token, Tokenizer, type Tokens } from "marked";
|
||||
import { getCapabilities, hyperlink, isImageLine } from "../terminal-image.js";
|
||||
import type { Component } from "../tui.js";
|
||||
import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "../utils.js";
|
||||
|
||||
const STRICT_STRIKETHROUGH_REGEX = /^(~~)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/;
|
||||
|
||||
class StrictStrikethroughTokenizer extends Tokenizer {
|
||||
override del(src: string): Tokens.Del | undefined {
|
||||
const match = STRICT_STRIKETHROUGH_REGEX.exec(src);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const text = match[2];
|
||||
return {
|
||||
type: "del",
|
||||
raw: match[0],
|
||||
text,
|
||||
tokens: this.lexer.inlineTokens(text),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const markdownParser = new Marked();
|
||||
markdownParser.setOptions({
|
||||
tokenizer: new StrictStrikethroughTokenizer(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Default text styling for markdown content.
|
||||
* Applied to all text unless overridden by markdown formatting.
|
||||
@@ -112,7 +136,7 @@ export class Markdown implements Component {
|
||||
const normalizedText = this.text.replace(/\t/g, " ");
|
||||
|
||||
// Parse markdown to HTML-like tokens
|
||||
const tokens = marked.lexer(normalizedText);
|
||||
const tokens = markdownParser.lexer(normalizedText);
|
||||
|
||||
// Convert tokens to styled terminal output
|
||||
const renderedLines: string[] = [];
|
||||
@@ -464,18 +488,22 @@ export class Markdown implements Component {
|
||||
|
||||
case "link": {
|
||||
const linkText = this.renderInlineTokens(token.tokens || [], resolvedStyleContext);
|
||||
// If link text matches href, only show the link once
|
||||
// Compare raw text (token.text) not styled text (linkText) since linkText has ANSI codes
|
||||
// For mailto: links, strip the prefix before comparing (autolinked emails have
|
||||
// text="foo@bar.com" but href="mailto:foo@bar.com")
|
||||
const hrefForComparison = token.href.startsWith("mailto:") ? token.href.slice(7) : token.href;
|
||||
if (token.text === token.href || token.text === hrefForComparison) {
|
||||
result += this.theme.link(this.theme.underline(linkText)) + stylePrefix;
|
||||
const styledLink = this.theme.link(this.theme.underline(linkText));
|
||||
if (getCapabilities().hyperlinks) {
|
||||
// OSC 8: render as a clickable hyperlink. The URL is not printed inline,
|
||||
// so we always show only the link text regardless of whether it matches href.
|
||||
result += hyperlink(styledLink, token.href) + stylePrefix;
|
||||
} else {
|
||||
result +=
|
||||
this.theme.link(this.theme.underline(linkText)) +
|
||||
this.theme.linkUrl(` (${token.href})`) +
|
||||
stylePrefix;
|
||||
// Fallback: print URL in parentheses when text differs from href.
|
||||
// Compare raw token.text (not styled) against href for the equality check.
|
||||
// For mailto: links strip the prefix (autolinked emails use text="foo@bar.com"
|
||||
// but href="mailto:foo@bar.com").
|
||||
const hrefForComparison = token.href.startsWith("mailto:") ? token.href.slice(7) : token.href;
|
||||
if (token.text === token.href || token.text === hrefForComparison) {
|
||||
result += styledLink + stylePrefix;
|
||||
} else {
|
||||
result += styledLink + this.theme.linkUrl(` (${token.href})`) + stylePrefix;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -78,12 +78,14 @@ export {
|
||||
getJpegDimensions,
|
||||
getPngDimensions,
|
||||
getWebpDimensions,
|
||||
hyperlink,
|
||||
type ImageDimensions,
|
||||
type ImageProtocol,
|
||||
type ImageRenderOptions,
|
||||
imageFallback,
|
||||
renderImage,
|
||||
resetCapabilitiesCache,
|
||||
setCapabilities,
|
||||
setCellDimensions,
|
||||
type TerminalCapabilities,
|
||||
} from "./terminal-image.js";
|
||||
|
||||
@@ -42,6 +42,16 @@ export function detectCapabilities(): TerminalCapabilities {
|
||||
const term = process.env.TERM?.toLowerCase() || "";
|
||||
const colorTerm = process.env.COLORTERM?.toLowerCase() || "";
|
||||
|
||||
// tmux and screen swallow OSC 8 by default (passthrough is opt-in and wraps
|
||||
// sequences differently). Force hyperlinks off whenever we detect them, even
|
||||
// when the outer terminal would otherwise support OSC 8. Image protocols are
|
||||
// also unreliable under tmux/screen, so leave `images: null` for safety.
|
||||
const inTmuxOrScreen = !!process.env.TMUX || term.startsWith("tmux") || term.startsWith("screen");
|
||||
if (inTmuxOrScreen) {
|
||||
const trueColor = colorTerm === "truecolor" || colorTerm === "24bit";
|
||||
return { images: null, trueColor, hyperlinks: false };
|
||||
}
|
||||
|
||||
if (process.env.KITTY_WINDOW_ID || termProgram === "kitty") {
|
||||
return { images: "kitty", trueColor: true, hyperlinks: true };
|
||||
}
|
||||
@@ -66,8 +76,12 @@ export function detectCapabilities(): TerminalCapabilities {
|
||||
return { images: null, trueColor: true, hyperlinks: true };
|
||||
}
|
||||
|
||||
// Unknown terminal: be conservative. OSC 8 is rendered invisibly as "just
|
||||
// text" on terminals that swallow it, which means the URL disappears from
|
||||
// the rendered output. Default to the legacy `text (url)` behavior unless we
|
||||
// have positively identified a hyperlink-capable terminal above.
|
||||
const trueColor = colorTerm === "truecolor" || colorTerm === "24bit";
|
||||
return { images: null, trueColor, hyperlinks: true };
|
||||
return { images: null, trueColor, hyperlinks: false };
|
||||
}
|
||||
|
||||
export function getCapabilities(): TerminalCapabilities {
|
||||
@@ -81,6 +95,11 @@ export function resetCapabilitiesCache(): void {
|
||||
cachedCapabilities = null;
|
||||
}
|
||||
|
||||
/** Override the cached capabilities. Useful in tests to exercise both code paths. */
|
||||
export function setCapabilities(caps: TerminalCapabilities): void {
|
||||
cachedCapabilities = caps;
|
||||
}
|
||||
|
||||
const KITTY_PREFIX = "\x1b_G";
|
||||
const ITERM2_PREFIX = "\x1b]1337;File=";
|
||||
|
||||
@@ -372,6 +391,20 @@ export function renderImage(
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap text in an OSC 8 hyperlink sequence.
|
||||
* The text is rendered as a clickable hyperlink in terminals that support OSC 8
|
||||
* (Ghostty, Kitty, WezTerm, iTerm2, VSCode, and others).
|
||||
* In terminals that do not support OSC 8, the escape sequences are ignored
|
||||
* and only the plain text is displayed.
|
||||
*
|
||||
* @param text - The visible text to display
|
||||
* @param url - The URL to link to
|
||||
*/
|
||||
export function hyperlink(text: string, url: string): string {
|
||||
return `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\`;
|
||||
}
|
||||
|
||||
export function imageFallback(mimeType: string, dimensions?: ImageDimensions, filename?: string): string {
|
||||
const parts: string[] = [];
|
||||
if (filename) parts.push(filename);
|
||||
|
||||
@@ -184,17 +184,6 @@ export class ProcessTerminal implements Terminal {
|
||||
private queryAndEnableKittyProtocol(): void {
|
||||
this.setupStdinBuffer();
|
||||
process.stdin.on("data", this.stdinDataHandler!);
|
||||
|
||||
// Zellij forwards the Kitty query to the outer terminal, which can make
|
||||
// Pi enable its Kitty parser even though Zellij still sends Alt as
|
||||
// legacy ESC-prefixed sequences. Skip the Kitty query there and use
|
||||
// modifyOtherKeys directly instead.
|
||||
if (process.env.ZELLIJ) {
|
||||
process.stdout.write("\x1b[>4;2m");
|
||||
this._modifyOtherKeysActive = true;
|
||||
return;
|
||||
}
|
||||
|
||||
process.stdout.write("\x1b[?u");
|
||||
setTimeout(() => {
|
||||
if (!this._kittyProtocolActive && !this._modifyOtherKeysActive) {
|
||||
|
||||
@@ -311,8 +311,16 @@ class AnsiCodeTracker {
|
||||
private strikethrough = false;
|
||||
private fgColor: string | null = null; // Stores the full code like "31" or "38;5;240"
|
||||
private bgColor: string | null = null; // Stores the full code like "41" or "48;5;240"
|
||||
private activeHyperlink: string | null = null; // Active OSC 8 hyperlink URL, or null
|
||||
|
||||
process(ansiCode: string): void {
|
||||
// OSC 8 hyperlink: \x1b]8;;<url>\x1b\\ (open) or \x1b]8;;\x1b\\ (close)
|
||||
if (ansiCode.startsWith("\x1b]8;")) {
|
||||
const m = ansiCode.match(/^\x1b\]8;[^;]*;([^\x1b\x07]*)/);
|
||||
this.activeHyperlink = m?.[1] ? m[1] : null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ansiCode.endsWith("m")) {
|
||||
return;
|
||||
}
|
||||
@@ -447,11 +455,13 @@ class AnsiCodeTracker {
|
||||
this.strikethrough = false;
|
||||
this.fgColor = null;
|
||||
this.bgColor = null;
|
||||
// SGR reset does not affect OSC 8 hyperlink state
|
||||
}
|
||||
|
||||
/** Clear all state for reuse. */
|
||||
clear(): void {
|
||||
this.reset();
|
||||
this.activeHyperlink = null;
|
||||
}
|
||||
|
||||
getActiveCodes(): string {
|
||||
@@ -467,8 +477,11 @@ class AnsiCodeTracker {
|
||||
if (this.fgColor) codes.push(this.fgColor);
|
||||
if (this.bgColor) codes.push(this.bgColor);
|
||||
|
||||
if (codes.length === 0) return "";
|
||||
return `\x1b[${codes.join(";")}m`;
|
||||
let result = codes.length > 0 ? `\x1b[${codes.join(";")}m` : "";
|
||||
if (this.activeHyperlink) {
|
||||
result += `\x1b]8;;${this.activeHyperlink}\x1b\\`;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
hasActiveCodes(): boolean {
|
||||
@@ -482,22 +495,26 @@ class AnsiCodeTracker {
|
||||
this.hidden ||
|
||||
this.strikethrough ||
|
||||
this.fgColor !== null ||
|
||||
this.bgColor !== null
|
||||
this.bgColor !== null ||
|
||||
this.activeHyperlink !== null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get reset codes for attributes that need to be turned off at line end,
|
||||
* specifically underline which bleeds into padding.
|
||||
* Returns empty string if no problematic attributes are active.
|
||||
* Get reset codes for attributes that need to be turned off at line end.
|
||||
* Underline must be closed to prevent bleeding into padding.
|
||||
* Active OSC 8 hyperlinks must be closed and re-opened on the next line.
|
||||
* Returns empty string if no attributes need closing.
|
||||
*/
|
||||
getLineEndReset(): string {
|
||||
// Only underline causes visual bleeding into padding
|
||||
// Other attributes like colors don't visually bleed to padding
|
||||
let result = "";
|
||||
if (this.underline) {
|
||||
return "\x1b[24m"; // Underline off only
|
||||
result += "\x1b[24m"; // Underline off only
|
||||
}
|
||||
return "";
|
||||
if (this.activeHyperlink) {
|
||||
result += "\x1b]8;;\x1b\\"; // Close hyperlink; re-opened at line start via getActiveCodes()
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { afterEach, describe, it } from "node:test";
|
||||
import type { Terminal as XtermTerminalType } from "@xterm/headless";
|
||||
import { Chalk } from "chalk";
|
||||
import { Markdown } from "../src/components/markdown.js";
|
||||
import { resetCapabilitiesCache, setCapabilities } from "../src/terminal-image.js";
|
||||
import { type Component, TUI } from "../src/tui.js";
|
||||
import { defaultMarkdownTheme } from "./test-themes.js";
|
||||
import { VirtualTerminal } from "./virtual-terminal.js";
|
||||
@@ -325,6 +326,8 @@ describe("Markdown component", () => {
|
||||
});
|
||||
|
||||
it("should wrap long unbroken tokens inside table cells (not only at line start)", () => {
|
||||
// Pin to no-hyperlinks so width checks work on plain text without OSC 8 sequences.
|
||||
setCapabilities({ images: null, trueColor: false, hyperlinks: false });
|
||||
const url = "https://example.com/this/is/a/very/long/url/that/should/wrap";
|
||||
const markdown = new Markdown(
|
||||
`| Value |
|
||||
@@ -337,6 +340,7 @@ describe("Markdown component", () => {
|
||||
|
||||
const width = 30;
|
||||
const lines = markdown.render(width);
|
||||
resetCapabilitiesCache();
|
||||
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd());
|
||||
|
||||
for (const line of plainLines) {
|
||||
@@ -1061,8 +1065,39 @@ bar`,
|
||||
});
|
||||
});
|
||||
|
||||
describe("Strikethrough syntax", () => {
|
||||
it("should render ~~text~~ as strikethrough", () => {
|
||||
const markdown = new Markdown("Use ~~strikethrough~~ here", 0, 0, defaultMarkdownTheme);
|
||||
|
||||
const lines = markdown.render(80);
|
||||
const joinedOutput = lines.join("\n");
|
||||
const joinedPlain = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "")).join(" ");
|
||||
|
||||
assert.ok(joinedOutput.includes("\x1b[9m"), "Should apply strikethrough styling");
|
||||
assert.ok(joinedPlain.includes("strikethrough"), "Should include struck text content");
|
||||
assert.ok(!joinedPlain.includes("~~strikethrough~~"), "Should not render delimiters as text");
|
||||
});
|
||||
|
||||
it("should keep ~text~ as plain text", () => {
|
||||
const markdown = new Markdown("Use ~strikethrough~ literally", 0, 0, defaultMarkdownTheme);
|
||||
|
||||
const lines = markdown.render(80);
|
||||
const joinedOutput = lines.join("\n");
|
||||
const joinedPlain = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "")).join(" ");
|
||||
|
||||
assert.ok(joinedPlain.includes("~strikethrough~"), "Single-tilde delimiters should remain visible");
|
||||
assert.ok(!joinedOutput.includes("\x1b[9m"), "Single-tilde text should not use strikethrough styling");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Links", () => {
|
||||
afterEach(() => {
|
||||
resetCapabilitiesCache();
|
||||
});
|
||||
|
||||
it("should not duplicate URL for autolinked emails", () => {
|
||||
// Hyperlinks capability does not affect the mailto: display check.
|
||||
setCapabilities({ images: null, trueColor: false, hyperlinks: false });
|
||||
const markdown = new Markdown("Contact user@example.com for help", 0, 0, defaultMarkdownTheme);
|
||||
|
||||
const lines = markdown.render(80);
|
||||
@@ -1075,6 +1110,7 @@ bar`,
|
||||
});
|
||||
|
||||
it("should not duplicate URL for bare URLs", () => {
|
||||
setCapabilities({ images: null, trueColor: false, hyperlinks: false });
|
||||
const markdown = new Markdown("Visit https://example.com for more", 0, 0, defaultMarkdownTheme);
|
||||
|
||||
const lines = markdown.render(80);
|
||||
@@ -1086,29 +1122,79 @@ bar`,
|
||||
assert.strictEqual(urlCount, 1, "URL should appear exactly once");
|
||||
});
|
||||
|
||||
it("should show URL for explicit markdown links with different text", () => {
|
||||
it("should show URL in parentheses when hyperlinks are not supported", () => {
|
||||
setCapabilities({ images: null, trueColor: false, hyperlinks: false });
|
||||
const markdown = new Markdown("[click here](https://example.com)", 0, 0, defaultMarkdownTheme);
|
||||
|
||||
const lines = markdown.render(80);
|
||||
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, ""));
|
||||
const joinedPlain = plainLines.join(" ");
|
||||
|
||||
// Should show both link text and URL
|
||||
assert.ok(joinedPlain.includes("click here"), "Should contain link text");
|
||||
assert.ok(joinedPlain.includes("(https://example.com)"), "Should show URL in parentheses");
|
||||
});
|
||||
|
||||
it("should show URL for explicit mailto links with different text", () => {
|
||||
it("should show mailto URL in parentheses when hyperlinks are not supported", () => {
|
||||
setCapabilities({ images: null, trueColor: false, hyperlinks: false });
|
||||
const markdown = new Markdown("[Email me](mailto:test@example.com)", 0, 0, defaultMarkdownTheme);
|
||||
|
||||
const lines = markdown.render(80);
|
||||
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, ""));
|
||||
const joinedPlain = plainLines.join(" ");
|
||||
|
||||
// Should show both link text and mailto URL
|
||||
assert.ok(joinedPlain.includes("Email me"), "Should contain link text");
|
||||
assert.ok(joinedPlain.includes("(mailto:test@example.com)"), "Should show mailto URL in parentheses");
|
||||
});
|
||||
|
||||
it("should emit OSC 8 hyperlink sequence when terminal supports hyperlinks", () => {
|
||||
setCapabilities({ images: null, trueColor: false, hyperlinks: true });
|
||||
const markdown = new Markdown("[click here](https://example.com)", 0, 0, defaultMarkdownTheme);
|
||||
|
||||
const lines = markdown.render(80);
|
||||
const joined = lines.join("");
|
||||
|
||||
// OSC 8 open: ESC ] 8 ; ; <url> ESC \
|
||||
assert.ok(joined.includes("\x1b]8;;https://example.com\x1b\\"), "Should contain OSC 8 open sequence");
|
||||
// OSC 8 close: ESC ] 8 ; ; ESC \
|
||||
assert.ok(joined.includes("\x1b]8;;\x1b\\"), "Should contain OSC 8 close sequence");
|
||||
// Visible text is present
|
||||
const plainLines = lines.map((line) => line.replace(/\x1b[^a-zA-Z]*[a-zA-Z]|\x1b\].*?\x1b\\/g, ""));
|
||||
assert.ok(plainLines.join("").includes("click here"), "Should contain link text");
|
||||
// URL is NOT printed inline as plain text
|
||||
const rawPlain = lines.map((line) =>
|
||||
line.replace(/\x1b\]8;;[^\x1b]*\x1b\\/g, "").replace(/\x1b\[[0-9;]*m/g, ""),
|
||||
);
|
||||
assert.ok(!rawPlain.join("").includes("(https://example.com)"), "URL should not appear inline in parentheses");
|
||||
});
|
||||
|
||||
it("should use OSC 8 for mailto links when terminal supports hyperlinks", () => {
|
||||
setCapabilities({ images: null, trueColor: false, hyperlinks: true });
|
||||
const markdown = new Markdown("[Email me](mailto:test@example.com)", 0, 0, defaultMarkdownTheme);
|
||||
|
||||
const lines = markdown.render(80);
|
||||
const joined = lines.join("");
|
||||
|
||||
assert.ok(
|
||||
joined.includes("\x1b]8;;mailto:test@example.com\x1b\\"),
|
||||
"Should contain OSC 8 open with mailto URL",
|
||||
);
|
||||
assert.ok(joined.includes("\x1b]8;;\x1b\\"), "Should contain OSC 8 close sequence");
|
||||
});
|
||||
|
||||
it("should use OSC 8 for bare URLs when terminal supports hyperlinks", () => {
|
||||
setCapabilities({ images: null, trueColor: false, hyperlinks: true });
|
||||
const markdown = new Markdown("Visit https://example.com for more", 0, 0, defaultMarkdownTheme);
|
||||
|
||||
const lines = markdown.render(80);
|
||||
const joined = lines.join("");
|
||||
|
||||
assert.ok(joined.includes("\x1b]8;;https://example.com\x1b\\"), "Should contain OSC 8 hyperlink");
|
||||
// URL should not also appear as raw parenthetical text
|
||||
const rawPlain = lines.map((line) =>
|
||||
line.replace(/\x1b\]8;;[^\x1b]*\x1b\\/g, "").replace(/\x1b\[[0-9;]*m/g, ""),
|
||||
);
|
||||
assert.ok(!rawPlain.join("").includes("(https://example.com)"), "URL should not appear twice");
|
||||
});
|
||||
});
|
||||
|
||||
describe("HTML-like tags in text", () => {
|
||||
|
||||
@@ -4,7 +4,38 @@
|
||||
|
||||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { isImageLine } from "../src/terminal-image.js";
|
||||
import { detectCapabilities, hyperlink, isImageLine } from "../src/terminal-image.js";
|
||||
|
||||
const ENV_KEYS = [
|
||||
"TERM",
|
||||
"TERM_PROGRAM",
|
||||
"COLORTERM",
|
||||
"TMUX",
|
||||
"KITTY_WINDOW_ID",
|
||||
"GHOSTTY_RESOURCES_DIR",
|
||||
"WEZTERM_PANE",
|
||||
"ITERM_SESSION_ID",
|
||||
] as const;
|
||||
|
||||
function withEnv(overrides: Record<string, string | undefined>, fn: () => void): void {
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
for (const key of ENV_KEYS) {
|
||||
saved[key] = process.env[key];
|
||||
delete process.env[key];
|
||||
}
|
||||
try {
|
||||
for (const [k, v] of Object.entries(overrides)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
fn();
|
||||
} finally {
|
||||
for (const key of ENV_KEYS) {
|
||||
if (saved[key] === undefined) delete process.env[key];
|
||||
else process.env[key] = saved[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("isImageLine", () => {
|
||||
describe("iTerm2 image protocol", () => {
|
||||
@@ -151,3 +182,98 @@ describe("isImageLine", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectCapabilities", () => {
|
||||
it("defaults to hyperlinks: false for unknown terminals", () => {
|
||||
withEnv({}, () => {
|
||||
const caps = detectCapabilities();
|
||||
assert.strictEqual(caps.hyperlinks, false);
|
||||
assert.strictEqual(caps.images, null);
|
||||
});
|
||||
});
|
||||
|
||||
it("forces hyperlinks: false under tmux even if outer terminal supports OSC 8", () => {
|
||||
withEnv({ TMUX: "/tmp/tmux-1000/default,1234,0", TERM_PROGRAM: "ghostty" }, () => {
|
||||
const caps = detectCapabilities();
|
||||
assert.strictEqual(caps.hyperlinks, false);
|
||||
assert.strictEqual(caps.images, null);
|
||||
});
|
||||
});
|
||||
|
||||
it("forces hyperlinks: false when TERM starts with 'tmux'", () => {
|
||||
withEnv({ TERM: "tmux-256color", TERM_PROGRAM: "iterm.app" }, () => {
|
||||
const caps = detectCapabilities();
|
||||
assert.strictEqual(caps.hyperlinks, false);
|
||||
assert.strictEqual(caps.images, null);
|
||||
});
|
||||
});
|
||||
|
||||
it("forces hyperlinks: false when TERM starts with 'screen'", () => {
|
||||
withEnv({ TERM: "screen-256color" }, () => {
|
||||
const caps = detectCapabilities();
|
||||
assert.strictEqual(caps.hyperlinks, false);
|
||||
assert.strictEqual(caps.images, null);
|
||||
});
|
||||
});
|
||||
|
||||
it("enables hyperlinks for Ghostty", () => {
|
||||
withEnv({ TERM_PROGRAM: "ghostty" }, () => {
|
||||
const caps = detectCapabilities();
|
||||
assert.strictEqual(caps.hyperlinks, true);
|
||||
});
|
||||
});
|
||||
|
||||
it("enables hyperlinks for Kitty", () => {
|
||||
withEnv({ KITTY_WINDOW_ID: "1" }, () => {
|
||||
const caps = detectCapabilities();
|
||||
assert.strictEqual(caps.hyperlinks, true);
|
||||
});
|
||||
});
|
||||
|
||||
it("enables hyperlinks for WezTerm", () => {
|
||||
withEnv({ WEZTERM_PANE: "0" }, () => {
|
||||
const caps = detectCapabilities();
|
||||
assert.strictEqual(caps.hyperlinks, true);
|
||||
});
|
||||
});
|
||||
|
||||
it("enables hyperlinks for iTerm2", () => {
|
||||
withEnv({ TERM_PROGRAM: "iterm.app" }, () => {
|
||||
const caps = detectCapabilities();
|
||||
assert.strictEqual(caps.hyperlinks, true);
|
||||
});
|
||||
});
|
||||
|
||||
it("enables hyperlinks for VSCode", () => {
|
||||
withEnv({ TERM_PROGRAM: "vscode" }, () => {
|
||||
const caps = detectCapabilities();
|
||||
assert.strictEqual(caps.hyperlinks, true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("hyperlink", () => {
|
||||
it("wraps text in OSC 8 open and close sequences", () => {
|
||||
const result = hyperlink("click me", "https://example.com");
|
||||
assert.strictEqual(result, "\x1b]8;;https://example.com\x1b\\click me\x1b]8;;\x1b\\");
|
||||
});
|
||||
|
||||
it("preserves ANSI styling inside the hyperlink", () => {
|
||||
const styled = "\x1b[4m\x1b[34mclick me\x1b[0m";
|
||||
const result = hyperlink(styled, "https://example.com");
|
||||
assert.ok(result.startsWith("\x1b]8;;https://example.com\x1b\\"));
|
||||
assert.ok(result.includes(styled));
|
||||
assert.ok(result.endsWith("\x1b]8;;\x1b\\"));
|
||||
});
|
||||
|
||||
it("works with empty text", () => {
|
||||
const result = hyperlink("", "https://example.com");
|
||||
assert.strictEqual(result, "\x1b]8;;https://example.com\x1b\\\x1b]8;;\x1b\\");
|
||||
});
|
||||
|
||||
it("works with file:// URIs", () => {
|
||||
const result = hyperlink("README.md", "file:///home/user/README.md");
|
||||
assert.ok(result.includes("file:///home/user/README.md"));
|
||||
assert.ok(result.includes("README.md"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { ProcessTerminal } from "../src/terminal.js";
|
||||
|
||||
function withEnv(name: string, value: string | undefined, fn: () => void): void {
|
||||
const previous = process.env[name];
|
||||
if (value === undefined) delete process.env[name];
|
||||
else process.env[name] = value;
|
||||
try {
|
||||
fn();
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env[name];
|
||||
else process.env[name] = previous;
|
||||
}
|
||||
}
|
||||
|
||||
describe("ProcessTerminal", () => {
|
||||
it("should skip the Kitty query inside Zellij and enable modifyOtherKeys immediately", () => {
|
||||
const terminal = new ProcessTerminal();
|
||||
const writes: string[] = [];
|
||||
const stdinOnCalls: Array<{ event: string | symbol; listener: (...args: unknown[]) => void }> = [];
|
||||
const stdinRemoveCalls: Array<{ event: string | symbol; listener: (...args: unknown[]) => void }> = [];
|
||||
const stdoutRemoveCalls: Array<{ event: string | symbol; listener: (...args: unknown[]) => void }> = [];
|
||||
|
||||
const originalStdoutWrite = process.stdout.write;
|
||||
const originalStdinOn = process.stdin.on;
|
||||
const originalStdinRemoveListener = process.stdin.removeListener;
|
||||
const originalStdinPause = process.stdin.pause;
|
||||
const originalStdoutRemoveListener = process.stdout.removeListener;
|
||||
|
||||
process.stdout.write = ((chunk: string | Uint8Array) => {
|
||||
writes.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"));
|
||||
return true;
|
||||
}) as typeof process.stdout.write;
|
||||
process.stdin.on = ((event: string | symbol, listener: (...args: unknown[]) => void) => {
|
||||
stdinOnCalls.push({ event, listener });
|
||||
return process.stdin;
|
||||
}) as typeof process.stdin.on;
|
||||
process.stdin.removeListener = ((event: string | symbol, listener: (...args: unknown[]) => void) => {
|
||||
stdinRemoveCalls.push({ event, listener });
|
||||
return process.stdin;
|
||||
}) as typeof process.stdin.removeListener;
|
||||
process.stdin.pause = (() => process.stdin) as typeof process.stdin.pause;
|
||||
process.stdout.removeListener = ((event: string | symbol, listener: (...args: unknown[]) => void) => {
|
||||
stdoutRemoveCalls.push({ event, listener });
|
||||
return process.stdout;
|
||||
}) as typeof process.stdout.removeListener;
|
||||
|
||||
try {
|
||||
withEnv("ZELLIJ", "1", () => {
|
||||
(
|
||||
terminal as unknown as {
|
||||
queryAndEnableKittyProtocol(): void;
|
||||
}
|
||||
).queryAndEnableKittyProtocol();
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(writes, ["\x1b[>4;2m"]);
|
||||
assert.strictEqual(stdinOnCalls.length, 1);
|
||||
assert.strictEqual(stdinOnCalls[0]?.event, "data");
|
||||
|
||||
terminal.stop();
|
||||
|
||||
assert.deepStrictEqual(writes, ["\x1b[>4;2m", "\x1b[?2004l", "\x1b[>4;0m"]);
|
||||
assert.strictEqual(stdinRemoveCalls.length, 1);
|
||||
assert.strictEqual(stdinRemoveCalls[0]?.event, "data");
|
||||
assert.strictEqual(stdoutRemoveCalls.length, 0);
|
||||
} finally {
|
||||
process.stdout.write = originalStdoutWrite;
|
||||
process.stdin.on = originalStdinOn;
|
||||
process.stdin.removeListener = originalStdinRemoveListener;
|
||||
process.stdin.pause = originalStdinPause;
|
||||
process.stdout.removeListener = originalStdoutRemoveListener;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -150,3 +150,58 @@ describe("wrapTextWithAnsi", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("wrapTextWithAnsi with OSC 8 hyperlinks", () => {
|
||||
it("re-emits OSC 8 open at the start of continuation lines", () => {
|
||||
// A hyperlink whose text is long enough to wrap
|
||||
const url = "https://example.com";
|
||||
// OSC 8 open + text that is 10 visible chars + OSC 8 close
|
||||
const input = `\x1b]8;;${url}\x1b\\0123456789\x1b]8;;\x1b\\`;
|
||||
const lines = wrapTextWithAnsi(input, 6);
|
||||
|
||||
// Every line that contains visible text from inside the hyperlink
|
||||
// should start with the OSC 8 open sequence (or be preceded by it).
|
||||
for (const line of lines) {
|
||||
// If the line has visible content it must begin with the OSC 8 re-open
|
||||
// OR it is the line where the close appeared with no following content.
|
||||
const stripped = line.replace(/\x1b\]8;;[^\x1b\x07]*\x1b\\/g, "").replace(/\x1b\[[0-9;]*m/g, "");
|
||||
if (stripped.trim().length > 0) {
|
||||
assert.ok(
|
||||
line.startsWith(`\x1b]8;;${url}\x1b\\`) || line.includes(`\x1b]8;;${url}\x1b\\`),
|
||||
`Line "${line}" has visible text but no OSC 8 re-open`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("closes OSC 8 before each line break", () => {
|
||||
const url = "https://example.com";
|
||||
const input = `\x1b]8;;${url}\x1b\\0123456789\x1b]8;;\x1b\\`;
|
||||
const lines = wrapTextWithAnsi(input, 6);
|
||||
|
||||
for (let i = 0; i < lines.length - 1; i++) {
|
||||
const line = lines[i];
|
||||
// Every non-final line that is inside a hyperlink should end with the close
|
||||
if (line.includes(`\x1b]8;;${url}\x1b\\`)) {
|
||||
assert.ok(
|
||||
line.endsWith("\x1b]8;;\x1b\\"),
|
||||
`Non-final line "${line}" is inside a hyperlink but does not close it`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("does not emit OSC 8 sequences on lines that are outside the hyperlink", () => {
|
||||
const url = "https://example.com";
|
||||
const input = `before \x1b]8;;${url}\x1b\\link\x1b]8;;\x1b\\ after`;
|
||||
const lines = wrapTextWithAnsi(input, 80);
|
||||
|
||||
// With width 80 everything fits on one line; there should be exactly one
|
||||
// OSC 8 open and one OSC 8 close.
|
||||
assert.strictEqual(lines.length, 1);
|
||||
const openCount = (lines[0].match(/\x1b\]8;;https:[^\x1b]+\x1b\\/g) ?? []).length;
|
||||
const closeCount = (lines[0].match(/\x1b\]8;;\x1b\\/g) ?? []).length;
|
||||
assert.strictEqual(openCount, 1);
|
||||
assert.strictEqual(closeCount, 1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.67.6] - 2026-04-16
|
||||
|
||||
## [0.67.5] - 2026-04-16
|
||||
|
||||
## [0.67.4] - 2026-04-16
|
||||
|
||||
## [0.67.3] - 2026-04-15
|
||||
|
||||
## [0.67.2] - 2026-04-14
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pi-web-ui-example",
|
||||
"version": "1.55.3",
|
||||
"version": "1.55.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mariozechner/pi-web-ui",
|
||||
"version": "0.67.3",
|
||||
"version": "0.67.6",
|
||||
"description": "Reusable web UI components for AI chat interfaces powered by @mariozechner/pi-ai",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
@@ -18,8 +18,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@lmstudio/sdk": "^1.5.0",
|
||||
"@mariozechner/pi-ai": "^0.67.3",
|
||||
"@mariozechner/pi-tui": "^0.67.3",
|
||||
"@mariozechner/pi-ai": "^0.67.6",
|
||||
"@mariozechner/pi-tui": "^0.67.6",
|
||||
"docx-preview": "^0.3.7",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide": "^0.544.0",
|
||||
|
||||
Reference in New Issue
Block a user