diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index 9c9fc5e3..0513093f 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -231,3 +231,9 @@ psoukie pr vastxie pr ItsumoSeito pr + +davidlifschitz pr + +vdxz pr + +dangooddd pr diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index 622888b3..4a081581 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -51,41 +51,37 @@ jobs: run: | VERSION="${RELEASE_TAG}" VERSION="${VERSION#v}" # Remove 'v' prefix - - # Extract changelog section for this version - cd packages/coding-agent - awk "/^## \[${VERSION}\]/{flag=1; next} /^## \[/{flag=0} flag" CHANGELOG.md > /tmp/release-notes.md - - # If empty, use a default message - if [ ! -s /tmp/release-notes.md ]; then - echo "Release ${VERSION}" > /tmp/release-notes.md - fi + node scripts/release-notes.mjs extract --version "${VERSION}" --tag "${RELEASE_TAG}" --out /tmp/release-notes.md - name: Create GitHub Release and upload binaries env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | cd packages/coding-agent/binaries - - # Create release with changelog notes (or update if exists) - gh release create "${RELEASE_TAG}" \ - --title "${RELEASE_TAG}" \ - --notes-file /tmp/release-notes.md \ - pi-darwin-arm64.tar.gz \ - pi-darwin-x64.tar.gz \ - pi-linux-x64.tar.gz \ - pi-linux-arm64.tar.gz \ - pi-windows-x64.zip \ - pi-windows-arm64.zip \ - 2>/dev/null || \ - gh release upload "${RELEASE_TAG}" \ - pi-darwin-arm64.tar.gz \ - pi-darwin-x64.tar.gz \ - pi-linux-x64.tar.gz \ - pi-linux-arm64.tar.gz \ - pi-windows-x64.zip \ - pi-windows-arm64.zip \ - --clobber + + if gh release view "${RELEASE_TAG}" >/dev/null 2>&1; then + gh release edit "${RELEASE_TAG}" \ + --title "${RELEASE_TAG}" \ + --notes-file /tmp/release-notes.md + gh release upload "${RELEASE_TAG}" \ + pi-darwin-arm64.tar.gz \ + pi-darwin-x64.tar.gz \ + pi-linux-x64.tar.gz \ + pi-linux-arm64.tar.gz \ + pi-windows-x64.zip \ + pi-windows-arm64.zip \ + --clobber + else + gh release create "${RELEASE_TAG}" \ + --title "${RELEASE_TAG}" \ + --notes-file /tmp/release-notes.md \ + pi-darwin-arm64.tar.gz \ + pi-darwin-x64.tar.gz \ + pi-linux-x64.tar.gz \ + pi-linux-arm64.tar.gz \ + pi-windows-x64.zip \ + pi-windows-arm64.zip + fi publish-npm: runs-on: ubuntu-latest diff --git a/package-lock.json b/package-lock.json index 1f0ebbc0..b32c59fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6170,10 +6170,10 @@ }, "packages/agent": { "name": "@earendil-works/pi-agent-core", - "version": "0.78.1", + "version": "0.79.1", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.78.1", + "@earendil-works/pi-ai": "^0.79.1", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -6207,7 +6207,7 @@ }, "packages/ai": { "name": "@earendil-works/pi-ai", - "version": "0.78.1", + "version": "0.79.1", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -6252,12 +6252,12 @@ }, "packages/coding-agent": { "name": "@earendil-works/pi-coding-agent", - "version": "0.78.1", + "version": "0.79.1", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.78.1", - "@earendil-works/pi-ai": "^0.78.1", - "@earendil-works/pi-tui": "^0.78.1", + "@earendil-works/pi-agent-core": "^0.79.1", + "@earendil-works/pi-ai": "^0.79.1", + "@earendil-works/pi-tui": "^0.79.1", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -6296,32 +6296,32 @@ }, "packages/coding-agent/examples/extensions/custom-provider-anthropic": { "name": "pi-extension-custom-provider-anthropic", - "version": "0.78.1", + "version": "0.79.1", "dependencies": { "@anthropic-ai/sdk": "0.52.0" } }, "packages/coding-agent/examples/extensions/custom-provider-gitlab-duo": { "name": "pi-extension-custom-provider-gitlab-duo", - "version": "0.78.1" + "version": "0.79.1" }, "packages/coding-agent/examples/extensions/gondolin": { "name": "pi-extension-gondolin", - "version": "0.78.1", + "version": "0.79.1", "dependencies": { "@earendil-works/gondolin": "0.12.0" } }, "packages/coding-agent/examples/extensions/sandbox": { "name": "pi-extension-sandbox", - "version": "1.8.1", + "version": "1.9.1", "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.26" } }, "packages/coding-agent/examples/extensions/with-deps": { "name": "pi-extension-with-deps", - "version": "0.78.1", + "version": "0.79.1", "dependencies": { "ms": "2.1.3" }, @@ -6357,7 +6357,7 @@ }, "packages/tui": { "name": "@earendil-works/pi-tui", - "version": "0.78.1", + "version": "0.79.1", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", diff --git a/package.json b/package.json index ed9dbec9..c88d7123 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "release:patch": "node scripts/release.mjs patch", "release:minor": "node scripts/release.mjs minor", "release:major": "node scripts/release.mjs major", + "release:fix-links": "node scripts/release-notes.mjs fix-github-releases", "prepare": "husky" }, "devDependencies": { diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index bdfa5474..c1c02515 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +## [0.79.1] - 2026-06-09 + +## [0.79.0] - 2026-06-08 + ### Fixed - Fixed the compaction summarization system prompt to use neutral AI assistant wording for non-coding agents ([#5401](https://github.com/earendil-works/pi/issues/5401)). diff --git a/packages/agent/package.json b/packages/agent/package.json index fc4a5478..afd0368c 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-agent-core", - "version": "0.78.1", + "version": "0.79.1", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "type": "module", "main": "./dist/index.js", @@ -29,7 +29,7 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@earendil-works/pi-ai": "^0.78.1", + "@earendil-works/pi-ai": "^0.79.1", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index b72f4718..5af75b1e 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,30 @@ ## [Unreleased] +- When Amazon Bedrock rejects an unsupported data retention mode, the error now links the AWS data retention documentation ([#5561](https://github.com/earendil-works/pi/pull/5561) by [@unexge](https://github.com/unexge)). + +### Fixed + +- Fixed Claude Fable 5 thinking-off requests to omit Anthropic's unsupported `thinking.type: "disabled"` payload ([#5567](https://github.com/earendil-works/pi/pull/5567) by [@tmustier](https://github.com/tmustier)). + +## [0.79.1] - 2026-06-09 + +### Added + +- Added Claude Fable 5 to Anthropic and Amazon Bedrock model metadata, with adaptive thinking and `xhigh` effort support. + +### Fixed + +- Fixed Amazon Bedrock inference profile ARN region resolution to prefer the ARN's embedded region over `AWS_REGION` ([#5527](https://github.com/earendil-works/pi/pull/5527) by [@AJM10565](https://github.com/AJM10565)). +- Fixed z.ai thinking-off requests to send the provider's `thinking: { type: "disabled" }` compatibility parameter ([#5330](https://github.com/earendil-works/pi/issues/5330)). +- Fixed OpenCode completions model metadata to send explicit `maxTokens` as `max_tokens` ([#5331](https://github.com/earendil-works/pi/issues/5331)). +- Fixed Moonshot Kimi thinking-off requests to send the provider's `thinking: { type: "disabled" }` compatibility parameter ([#5531](https://github.com/earendil-works/pi/issues/5531)). +- Fixed Azure OpenAI Responses requests to disable server-side response storage ([#5530](https://github.com/earendil-works/pi/issues/5530)). +- Fixed Azure GPT-5.4 and GPT-5.5 context window metadata to 1,050,000 tokens, matching Azure Foundry deployments instead of OpenAI's 272k limit ([#5559](https://github.com/earendil-works/pi/issues/5559)). +- Fixed OpenAI and Azure GPT-5 Pro `maxTokens` metadata to 128,000, correcting an upstream value that duplicated the 272,000 input sub-limit as the output limit ([#5559](https://github.com/earendil-works/pi/issues/5559)). + +## [0.79.0] - 2026-06-08 + ### Fixed - Fixed OpenAI Responses custom providers to honor `compat.supportsDeveloperRole: false` for reasoning models ([#5456](https://github.com/earendil-works/pi/issues/5456)). diff --git a/packages/ai/package.json b/packages/ai/package.json index e62c370d..9a54de2f 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-ai", - "version": "0.78.1", + "version": "0.79.1", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index d10feb12..5e5fff94 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -92,7 +92,6 @@ const TOGETHER_TOGGLE_REASONING_EFFORT_COMPAT: OpenAICompletionsCompat = { }; const TOGETHER_REASONING_ONLY_MODELS = new Set([ "deepseek-ai/DeepSeek-R1", - "MiniMaxAI/MiniMax-M2.5", "MiniMaxAI/MiniMax-M2.7", ]); const TOGETHER_REASONING_EFFORT_MODELS = new Set(["openai/gpt-oss-20b", "openai/gpt-oss-120b"]); @@ -233,7 +232,8 @@ function isAnthropicAdaptiveThinkingModel(modelId: string): boolean { modelId.includes("opus-4-8") || modelId.includes("opus-4.8") || modelId.includes("sonnet-4-6") || - modelId.includes("sonnet-4.6") + modelId.includes("sonnet-4.6") || + modelId.includes("fable-5") ); } @@ -295,6 +295,12 @@ function applyThinkingLevelMetadata(model: Model): void { ) { mergeThinkingLevelMap(model, { xhigh: "xhigh" }); } + if ( + (model.api === "anthropic-messages" || model.api === "bedrock-converse-stream") && + model.id.includes("fable-5") + ) { + mergeThinkingLevelMap(model, { off: null, xhigh: "xhigh" }); + } if (model.api === "anthropic-messages" && isAnthropicAdaptiveThinkingModel(model.id)) { mergeAnthropicMessagesCompat(model, { forceAdaptiveThinking: true }); } @@ -1058,6 +1064,10 @@ async function loadModelsDevData(): Promise[]> { } } + if (api === "openai-completions") { + compat = { ...(compat ?? {}), maxTokensField: "max_tokens" }; + } + models.push({ id: modelId, name: m.name || modelId, @@ -1216,6 +1226,7 @@ async function loadModelsDevData(): Promise[]> { supportsReasoningEffort: false, maxTokensField: "max_tokens", supportsStrictMode: false, + thinkingFormat: "deepseek", }; for (const { key, provider, baseUrl } of moonshotVariants) { @@ -1355,6 +1366,11 @@ async function generateModels() { candidate.contextWindow = 272000; candidate.maxTokens = 128000; } + // models.dev reports gpt-5-pro output as 272000 (a duplicate of the input sub-limit); + // the actual max output is 128000. Also propagates to the derived Azure clone. + if (candidate.provider === "openai" && candidate.id === "gpt-5-pro") { + candidate.maxTokens = 128000; + } // Keep selected OpenRouter model metadata stable until upstream settles. if (candidate.provider === "openrouter" && candidate.id === "moonshotai/kimi-k2.5") { candidate.cost.input = 0.41; @@ -2053,6 +2069,12 @@ async function generateModels() { ]; allModels.push(...vertexModels); + // Azure Foundry deploys these with larger context windows than OpenAI's own API, + // which caps gpt-5.4/gpt-5.5 at 272k. See models-sold-directly-by-azure docs. + const AZURE_CONTEXT_WINDOW_OVERRIDES: Record = { + "gpt-5.4": 1050000, + "gpt-5.5": 1050000, + }; const azureOpenAiModels: Model[] = allModels .filter((model) => model.provider === "openai" && model.api === "openai-responses") .map((model) => ({ @@ -2060,6 +2082,7 @@ async function generateModels() { api: "azure-openai-responses", provider: "azure-openai-responses", baseUrl: "", + contextWindow: AZURE_CONTEXT_WINDOW_OVERRIDES[model.id] ?? model.contextWindow, })); allModels.push(...azureOpenAiModels); diff --git a/packages/ai/src/image-models.generated.ts b/packages/ai/src/image-models.generated.ts index 3402e613..09c74180 100644 --- a/packages/ai/src/image-models.generated.ts +++ b/packages/ai/src/image-models.generated.ts @@ -440,6 +440,36 @@ export const IMAGE_MODELS = { cacheWrite: 0, }, } satisfies ImagesModel<"openrouter-images">, + "sourceful/riverflow-v2.5-fast": { + id: "sourceful/riverflow-v2.5-fast", + name: "Sourceful: Riverflow V2.5 Fast", + api: "openrouter-images", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + input: ["text", "image"], + output: ["image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + } satisfies ImagesModel<"openrouter-images">, + "sourceful/riverflow-v2.5-pro": { + id: "sourceful/riverflow-v2.5-pro", + name: "Sourceful: Riverflow V2.5 Pro", + api: "openrouter-images", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + input: ["text", "image"], + output: ["image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + } satisfies ImagesModel<"openrouter-images">, "x-ai/grok-imagine-image-quality": { id: "x-ai/grok-imagine-image-quality", name: "xAI: Grok Imagine Image Quality", diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 32966490..57da60bc 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -11,7 +11,7 @@ export const MODELS = { api: "bedrock-converse-stream", provider: "amazon-bedrock", baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { input: 0.33, @@ -350,6 +350,24 @@ export const MODELS = { contextWindow: 163840, maxTokens: 81920, } satisfies Model<"bedrock-converse-stream">, + "eu.anthropic.claude-fable-5": { + id: "eu.anthropic.claude-fable-5", + name: "Claude Fable 5 (EU)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.eu-central-1.amazonaws.com", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 11, + output: 55, + cacheRead: 1.1, + cacheWrite: 13.75, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"bedrock-converse-stream">, "eu.anthropic.claude-haiku-4-5-20251001-v1:0": { id: "eu.anthropic.claude-haiku-4-5-20251001-v1:0", name: "Claude Haiku 4.5 (EU)", @@ -472,6 +490,24 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 64000, } satisfies Model<"bedrock-converse-stream">, + "global.anthropic.claude-fable-5": { + id: "global.anthropic.claude-fable-5", + name: "Claude Fable 5 (Global)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 10, + output: 50, + cacheRead: 1, + cacheWrite: 12.5, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"bedrock-converse-stream">, "global.anthropic.claude-haiku-4-5-20251001-v1:0": { id: "global.anthropic.claude-haiku-4-5-20251001-v1:0", name: "Claude Haiku 4.5 (Global)", @@ -1089,13 +1125,66 @@ export const MODELS = { contextWindow: 262144, maxTokens: 131072, } satisfies Model<"bedrock-converse-stream">, + "openai.gpt-5.4": { + id: "openai.gpt-5.4", + name: "GPT-5.4", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 2.75, + output: 16.5, + cacheRead: 0.275, + cacheWrite: 0, + }, + contextWindow: 272000, + maxTokens: 128000, + } satisfies Model<"bedrock-converse-stream">, + "openai.gpt-5.5": { + id: "openai.gpt-5.5", + name: "GPT-5.5", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 5.5, + output: 33, + cacheRead: 0.55, + cacheWrite: 0, + }, + contextWindow: 272000, + maxTokens: 128000, + } satisfies Model<"bedrock-converse-stream">, + "openai.gpt-oss-120b": { + id: "openai.gpt-oss-120b", + name: "gpt-oss-120b", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text"], + cost: { + input: 0.15, + output: 0.6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"bedrock-converse-stream">, "openai.gpt-oss-120b-1:0": { id: "openai.gpt-oss-120b-1:0", name: "gpt-oss-120b", api: "bedrock-converse-stream", provider: "amazon-bedrock", baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", - reasoning: false, + reasoning: true, input: ["text"], cost: { input: 0.15, @@ -1106,13 +1195,30 @@ export const MODELS = { contextWindow: 128000, maxTokens: 16384, } satisfies Model<"bedrock-converse-stream">, + "openai.gpt-oss-20b": { + id: "openai.gpt-oss-20b", + name: "gpt-oss-20b", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text"], + cost: { + input: 0.07, + output: 0.3, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"bedrock-converse-stream">, "openai.gpt-oss-20b-1:0": { id: "openai.gpt-oss-20b-1:0", name: "gpt-oss-20b", api: "bedrock-converse-stream", provider: "amazon-bedrock", baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", - reasoning: false, + reasoning: true, input: ["text"], cost: { input: 0.07, @@ -1276,6 +1382,24 @@ export const MODELS = { contextWindow: 262000, maxTokens: 262000, } satisfies Model<"bedrock-converse-stream">, + "us.anthropic.claude-fable-5": { + id: "us.anthropic.claude-fable-5", + name: "Claude Fable 5 (US)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 10, + output: 50, + cacheRead: 1, + cacheWrite: 12.5, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"bedrock-converse-stream">, "us.anthropic.claude-haiku-4-5-20251001-v1:0": { id: "us.anthropic.claude-haiku-4-5-20251001-v1:0", name: "Claude Haiku 4.5 (US)", @@ -1746,6 +1870,25 @@ export const MODELS = { contextWindow: 200000, maxTokens: 4096, } satisfies Model<"anthropic-messages">, + "claude-fable-5": { + id: "claude-fable-5", + name: "Claude Fable 5", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + compat: {"forceAdaptiveThinking":true}, + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 10, + output: 50, + cacheRead: 1, + cacheWrite: 12.5, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, "claude-haiku-4-5": { id: "claude-haiku-4-5", name: "Claude Haiku 4.5 (latest)", @@ -2303,7 +2446,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 400000, - maxTokens: 272000, + maxTokens: 128000, } satisfies Model<"azure-openai-responses">, "gpt-5.1": { id: "gpt-5.1", @@ -2536,7 +2679,7 @@ export const MODELS = { cacheRead: 0.25, cacheWrite: 0, }, - contextWindow: 272000, + contextWindow: 1050000, maxTokens: 128000, } satisfies Model<"azure-openai-responses">, "gpt-5.4-mini": { @@ -2608,7 +2751,7 @@ export const MODELS = { cacheRead: 0.5, cacheWrite: 0, }, - contextWindow: 272000, + contextWindow: 1050000, maxTokens: 128000, } satisfies Model<"azure-openai-responses">, "gpt-5.5-pro": { @@ -2922,6 +3065,25 @@ export const MODELS = { contextWindow: 200000, maxTokens: 8192, } satisfies Model<"anthropic-messages">, + "claude-fable-5": { + id: "claude-fable-5", + name: "Claude Fable 5", + api: "anthropic-messages", + provider: "cloudflare-ai-gateway", + baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/anthropic", + compat: {"forceAdaptiveThinking":true}, + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 10, + output: 50, + cacheRead: 1, + cacheWrite: 12.5, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, "claude-haiku-4-5": { id: "claude-haiku-4-5", name: "Claude Haiku 4.5 (latest)", @@ -3890,6 +4052,24 @@ export const MODELS = { contextWindow: 202800, maxTokens: 131072, } satisfies Model<"anthropic-messages">, + "accounts/fireworks/routers/kimi-k2p6-fast": { + id: "accounts/fireworks/routers/kimi-k2p6-fast", + name: "Kimi K2.6 Fast", + api: "anthropic-messages", + provider: "fireworks", + baseUrl: "https://api.fireworks.ai/inference", + compat: {"sendSessionAffinityHeaders":true,"supportsEagerToolInputStreaming":false,"supportsCacheControlOnTools":false,"supportsLongCacheRetention":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 2, + output: 8, + cacheRead: 0.3, + cacheWrite: 0, + }, + contextWindow: 262000, + maxTokens: 262000, + } satisfies Model<"anthropic-messages">, "accounts/fireworks/routers/kimi-k2p6-turbo": { id: "accounts/fireworks/routers/kimi-k2p6-turbo", name: "Kimi K2.6 Turbo", @@ -6022,11 +6202,11 @@ export const MODELS = { api: "mistral-conversations", provider: "mistral", baseUrl: "https://api.mistral.ai", - reasoning: true, + reasoning: false, input: ["text", "image"], cost: { - input: 1.5, - output: 7.5, + input: 0.4, + output: 2, cacheRead: 0, cacheWrite: 0, }, @@ -6211,7 +6391,7 @@ export const MODELS = { api: "openai-completions", provider: "moonshotai", baseUrl: "https://api.moonshot.ai/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, reasoning: false, input: ["text"], cost: { @@ -6229,7 +6409,7 @@ export const MODELS = { api: "openai-completions", provider: "moonshotai", baseUrl: "https://api.moonshot.ai/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, reasoning: false, input: ["text"], cost: { @@ -6247,7 +6427,7 @@ export const MODELS = { api: "openai-completions", provider: "moonshotai", baseUrl: "https://api.moonshot.ai/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, reasoning: true, input: ["text"], cost: { @@ -6265,7 +6445,7 @@ export const MODELS = { api: "openai-completions", provider: "moonshotai", baseUrl: "https://api.moonshot.ai/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, reasoning: true, input: ["text"], cost: { @@ -6283,7 +6463,7 @@ export const MODELS = { api: "openai-completions", provider: "moonshotai", baseUrl: "https://api.moonshot.ai/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, reasoning: false, input: ["text"], cost: { @@ -6301,7 +6481,7 @@ export const MODELS = { api: "openai-completions", provider: "moonshotai", baseUrl: "https://api.moonshot.ai/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, reasoning: true, input: ["text", "image"], cost: { @@ -6319,7 +6499,7 @@ export const MODELS = { api: "openai-completions", provider: "moonshotai", baseUrl: "https://api.moonshot.ai/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, reasoning: true, input: ["text", "image"], cost: { @@ -6339,7 +6519,7 @@ export const MODELS = { api: "openai-completions", provider: "moonshotai-cn", baseUrl: "https://api.moonshot.cn/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, reasoning: false, input: ["text"], cost: { @@ -6357,7 +6537,7 @@ export const MODELS = { api: "openai-completions", provider: "moonshotai-cn", baseUrl: "https://api.moonshot.cn/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, reasoning: false, input: ["text"], cost: { @@ -6375,7 +6555,7 @@ export const MODELS = { api: "openai-completions", provider: "moonshotai-cn", baseUrl: "https://api.moonshot.cn/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, reasoning: true, input: ["text"], cost: { @@ -6393,7 +6573,7 @@ export const MODELS = { api: "openai-completions", provider: "moonshotai-cn", baseUrl: "https://api.moonshot.cn/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, reasoning: true, input: ["text"], cost: { @@ -6411,7 +6591,7 @@ export const MODELS = { api: "openai-completions", provider: "moonshotai-cn", baseUrl: "https://api.moonshot.cn/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, reasoning: false, input: ["text"], cost: { @@ -6429,7 +6609,7 @@ export const MODELS = { api: "openai-completions", provider: "moonshotai-cn", baseUrl: "https://api.moonshot.cn/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, reasoning: true, input: ["text", "image"], cost: { @@ -6447,7 +6627,7 @@ export const MODELS = { api: "openai-completions", provider: "moonshotai-cn", baseUrl: "https://api.moonshot.cn/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, reasoning: true, input: ["text", "image"], cost: { @@ -6708,6 +6888,25 @@ export const MODELS = { contextWindow: 262144, maxTokens: 262144, } satisfies Model<"openai-completions">, + "nvidia/nemotron-3-ultra-550b-a55b": { + id: "nvidia/nemotron-3-ultra-550b-a55b", + name: "Nemotron 3 Ultra 550B A55B", + api: "openai-completions", + provider: "nvidia", + baseUrl: "https://integrate.api.nvidia.com/v1", + headers: {"NVCF-POLL-SECONDS":"3600"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0.5, + output: 2.5, + cacheRead: 0.15, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 65536, + } satisfies Model<"openai-completions">, "nvidia/nvidia-nemotron-nano-9b-v2": { id: "nvidia/nvidia-nemotron-nano-9b-v2", name: "nvidia-nemotron-nano-9b-v2", @@ -7119,7 +7318,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 400000, - maxTokens: 272000, + maxTokens: 128000, } satisfies Model<"openai-responses">, "gpt-5.1": { id: "gpt-5.1", @@ -7663,6 +7862,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", + compat: {"maxTokensField":"max_tokens"}, reasoning: true, input: ["text"], cost: { @@ -7674,6 +7874,25 @@ export const MODELS = { contextWindow: 200000, maxTokens: 32000, } satisfies Model<"openai-completions">, + "claude-fable-5": { + id: "claude-fable-5", + name: "Claude Fable 5", + api: "anthropic-messages", + provider: "opencode", + baseUrl: "https://opencode.ai/zen", + compat: {"forceAdaptiveThinking":true}, + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 10, + output: 50, + cacheRead: 1, + cacheWrite: 12.5, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, "claude-haiku-4-5": { id: "claude-haiku-4-5", name: "Claude Haiku 4.5", @@ -7840,7 +8059,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", - compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + compat: {"maxTokensField":"max_tokens","requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, reasoning: true, thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"}, input: ["text"], @@ -7859,7 +8078,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", - compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + compat: {"maxTokensField":"max_tokens","requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, reasoning: true, thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"}, input: ["text"], @@ -7932,6 +8151,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", + compat: {"maxTokensField":"max_tokens"}, reasoning: true, input: ["text"], cost: { @@ -7949,6 +8169,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", + compat: {"maxTokensField":"max_tokens"}, reasoning: true, input: ["text"], cost: { @@ -8254,7 +8475,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", - compat: {"supportsReasoningEffort":false}, + compat: {"supportsReasoningEffort":false,"maxTokensField":"max_tokens"}, reasoning: true, thinkingLevelMap: {"off":null,"minimal":null,"low":null,"medium":null}, input: ["text", "image"], @@ -8273,6 +8494,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", + compat: {"maxTokensField":"max_tokens"}, reasoning: true, input: ["text", "image"], cost: { @@ -8290,7 +8512,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", - compat: {"thinkingFormat":"deepseek","supportsReasoningEffort":false}, + compat: {"thinkingFormat":"deepseek","supportsReasoningEffort":false,"maxTokensField":"max_tokens"}, reasoning: true, input: ["text", "image"], cost: { @@ -8308,6 +8530,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", + compat: {"maxTokensField":"max_tokens"}, reasoning: true, input: ["text", "image"], cost: { @@ -8325,6 +8548,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", + compat: {"maxTokensField":"max_tokens"}, reasoning: true, input: ["text"], cost: { @@ -8342,6 +8566,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", + compat: {"maxTokensField":"max_tokens"}, reasoning: true, input: ["text"], cost: { @@ -8353,29 +8578,13 @@ export const MODELS = { contextWindow: 204800, maxTokens: 131072, } satisfies Model<"openai-completions">, - "minimax-m3-free": { - id: "minimax-m3-free", - name: "MiniMax M3 Free", - api: "anthropic-messages", - provider: "opencode", - baseUrl: "https://opencode.ai/zen", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 200000, - maxTokens: 32000, - } satisfies Model<"anthropic-messages">, "nemotron-3-ultra-free": { id: "nemotron-3-ultra-free", name: "Nemotron 3 Ultra Free", api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", + compat: {"maxTokensField":"max_tokens"}, reasoning: true, input: ["text"], cost: { @@ -8387,6 +8596,24 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 128000, } satisfies Model<"openai-completions">, + "north-mini-code-free": { + id: "north-mini-code-free", + name: "North Mini Code Free", + api: "openai-completions", + provider: "opencode", + baseUrl: "https://opencode.ai/zen/v1", + compat: {"maxTokensField":"max_tokens"}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 64000, + } satisfies Model<"openai-completions">, "qwen3.5-plus": { id: "qwen3.5-plus", name: "Qwen3.5 Plus", @@ -8429,7 +8656,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode-go", baseUrl: "https://opencode.ai/zen/go/v1", - compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + compat: {"maxTokensField":"max_tokens","requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, reasoning: true, thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"}, input: ["text"], @@ -8448,7 +8675,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode-go", baseUrl: "https://opencode.ai/zen/go/v1", - compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + compat: {"maxTokensField":"max_tokens","requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, reasoning: true, thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"}, input: ["text"], @@ -8467,6 +8694,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode-go", baseUrl: "https://opencode.ai/zen/go/v1", + compat: {"maxTokensField":"max_tokens"}, reasoning: true, input: ["text"], cost: { @@ -8484,6 +8712,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode-go", baseUrl: "https://opencode.ai/zen/go/v1", + compat: {"maxTokensField":"max_tokens"}, reasoning: true, input: ["text"], cost: { @@ -8501,6 +8730,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode-go", baseUrl: "https://opencode.ai/zen/go/v1", + compat: {"maxTokensField":"max_tokens"}, reasoning: true, input: ["text", "image"], cost: { @@ -8518,7 +8748,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode-go", baseUrl: "https://opencode.ai/zen/go/v1", - compat: {"thinkingFormat":"deepseek","supportsReasoningEffort":false}, + compat: {"thinkingFormat":"deepseek","supportsReasoningEffort":false,"maxTokensField":"max_tokens"}, reasoning: true, thinkingLevelMap: {"minimal":null,"low":null,"medium":null}, input: ["text", "image"], @@ -8537,6 +8767,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode-go", baseUrl: "https://opencode.ai/zen/go/v1", + compat: {"maxTokensField":"max_tokens"}, reasoning: true, input: ["text", "image"], cost: { @@ -8554,6 +8785,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode-go", baseUrl: "https://opencode.ai/zen/go/v1", + compat: {"maxTokensField":"max_tokens"}, reasoning: true, input: ["text"], cost: { @@ -8588,6 +8820,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode-go", baseUrl: "https://opencode.ai/zen/go/v1", + compat: {"maxTokensField":"max_tokens"}, reasoning: true, input: ["text"], cost: { @@ -8608,9 +8841,9 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.6, - output: 2.4, - cacheRead: 0.12, + input: 0.3, + output: 1.2, + cacheRead: 0.06, cacheWrite: 0, }, contextWindow: 512000, @@ -8622,7 +8855,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode-go", baseUrl: "https://opencode.ai/zen/go/v1", - compat: {"thinkingFormat":"qwen"}, + compat: {"thinkingFormat":"qwen","maxTokensField":"max_tokens"}, reasoning: true, input: ["text", "image"], cost: { @@ -8631,7 +8864,7 @@ export const MODELS = { cacheRead: 0.05, cacheWrite: 0.625, }, - contextWindow: 262144, + contextWindow: 1000000, maxTokens: 65536, } satisfies Model<"openai-completions">, "qwen3.7-max": { @@ -8665,7 +8898,7 @@ export const MODELS = { cacheRead: 0.04, cacheWrite: 0.5, }, - contextWindow: 262144, + contextWindow: 1000000, maxTokens: 65536, } satisfies Model<"anthropic-messages">, }, @@ -8806,6 +9039,23 @@ export const MODELS = { contextWindow: 200000, maxTokens: 8192, } satisfies Model<"openai-completions">, + "anthropic/claude-fable-5": { + id: "anthropic/claude-fable-5", + name: "Anthropic: Claude Fable 5", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 10, + output: 50, + cacheRead: 1, + cacheWrite: 12.5, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, "anthropic/claude-haiku-4.5": { id: "anthropic/claude-haiku-4.5", name: "Anthropic: Claude Haiku 4.5", @@ -9101,23 +9351,6 @@ export const MODELS = { contextWindow: 2000000, maxTokens: 30000, } satisfies Model<"openai-completions">, - "baidu/ernie-4.5-vl-28b-a3b": { - id: "baidu/ernie-4.5-vl-28b-a3b", - name: "Baidu: ERNIE 4.5 VL 28B A3B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.14, - output: 0.56, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8000, - } satisfies Model<"openai-completions">, "bytedance-seed/seed-1.6": { id: "bytedance-seed/seed-1.6", name: "ByteDance Seed: Seed 1.6", @@ -9624,8 +9857,8 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.04, - output: 0.13, + input: 0.049999999999999996, + output: 0.15, cacheRead: 0, cacheWrite: 0, }, @@ -9693,12 +9926,12 @@ export const MODELS = { input: ["text", "image"], cost: { input: 0.12, - output: 0.37, - cacheRead: 0, + output: 0.36, + cacheRead: 0.09, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 16384, + maxTokens: 8192, } satisfies Model<"openai-completions">, "google/gemma-4-31b-it:free": { id: "google/gemma-4-31b-it:free", @@ -9847,7 +10080,7 @@ export const MODELS = { input: ["text"], cost: { input: 0.02, - output: 0.049999999999999996, + output: 0.03, cacheRead: 0, cacheWrite: 0, }, @@ -9914,7 +10147,7 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.08, + input: 0.09999999999999999, output: 0.3, cacheRead: 0, cacheWrite: 0, @@ -9983,8 +10216,8 @@ export const MODELS = { input: ["text"], cost: { input: 0.15, - output: 1.15, - cacheRead: 0, + output: 0.8999999999999999, + cacheRead: 0.049999999999999996, cacheWrite: 0, }, contextWindow: 204800, @@ -9999,9 +10232,9 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.27899999999999997, - output: 1.2, - cacheRead: 0, + input: 0.27, + output: 1.08, + cacheRead: 0.054, cacheWrite: 0, }, contextWindow: 204800, @@ -10391,13 +10624,13 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.684, - output: 3.42, - cacheRead: 0.144, + input: 0.6799999999999999, + output: 3.41, + cacheRead: 0.33999999999999997, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 262144, + maxTokens: 262142, } satisfies Model<"openai-completions">, "moonshotai/kimi-k2.6:free": { id: "moonshotai/kimi-k2.6:free", @@ -10417,22 +10650,22 @@ export const MODELS = { contextWindow: 262144, maxTokens: 4096, } satisfies Model<"openai-completions">, - "nex-agi/deepseek-v3.1-nex-n1": { - id: "nex-agi/deepseek-v3.1-nex-n1", - name: "Nex AGI: DeepSeek V3.1 Nex N1", + "nex-agi/nex-n2-pro:free": { + id: "nex-agi/nex-n2-pro:free", + name: "Nex AGI: Nex-N2-Pro (free)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], + reasoning: true, + input: ["text", "image"], cost: { - input: 0.135, - output: 0.5, + input: 0, + output: 0, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, - maxTokens: 163840, + contextWindow: 262144, + maxTokens: 262144, } satisfies Model<"openai-completions">, "nvidia/llama-3.3-nemotron-super-49b-v1.5": { id: "nvidia/llama-3.3-nemotron-super-49b-v1.5", @@ -10443,7 +10676,7 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.09999999999999999, + input: 0.39999999999999997, output: 0.39999999999999997, cacheRead: 0, cacheWrite: 0, @@ -10689,23 +10922,6 @@ export const MODELS = { contextWindow: 8191, maxTokens: 4096, } satisfies Model<"openai-completions">, - "openai/gpt-4-1106-preview": { - id: "openai/gpt-4-1106-preview", - name: "OpenAI: GPT-4 Turbo (older v1106)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 10, - output: 30, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "openai/gpt-4-turbo": { id: "openai/gpt-4-turbo", name: "OpenAI: GPT-4 Turbo", @@ -11781,7 +11997,7 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.071, + input: 0.09, output: 0.09999999999999999, cacheRead: 0, cacheWrite: 0, @@ -11815,13 +12031,13 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.09, - output: 0.44999999999999996, + input: 0.12, + output: 0.5, cacheRead: 0, cacheWrite: 0, }, contextWindow: 131072, - maxTokens: 20000, + maxTokens: 16384, } satisfies Model<"openai-completions">, "qwen/qwen3-30b-a3b-instruct-2507": { id: "qwen/qwen3-30b-a3b-instruct-2507", @@ -12274,13 +12490,13 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.04, + input: 0.09999999999999999, output: 0.15, cacheRead: 0, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 81920, + maxTokens: 262144, } satisfies Model<"openai-completions">, "qwen/qwen3.5-flash-02-23": { id: "qwen/qwen3.5-flash-02-23", @@ -12342,13 +12558,13 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.29, - output: 3.1999999999999997, + input: 0.28900000000000003, + output: 2.4, cacheRead: 0, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 262140, + maxTokens: 131072, } satisfies Model<"openai-completions">, "qwen/qwen3.6-35b-a3b": { id: "qwen/qwen3.6-35b-a3b", @@ -12486,23 +12702,6 @@ export const MODELS = { contextWindow: 256000, maxTokens: 128000, } satisfies Model<"openai-completions">, - "sao10k/l3-euryale-70b": { - id: "sao10k/l3-euryale-70b", - name: "Sao10k: Llama 3 Euryale 70B v2.1", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 1.48, - output: 1.48, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 8192, - maxTokens: 8192, - } satisfies Model<"openai-completions">, "sao10k/l3.1-euryale-70b": { id: "sao10k/l3.1-euryale-70b", name: "Sao10K: Llama 3.1 Euryale 70B v2.2", @@ -12945,6 +13144,23 @@ export const MODELS = { contextWindow: 202752, maxTokens: 131072, } satisfies Model<"openai-completions">, + "~anthropic/claude-fable-latest": { + id: "~anthropic/claude-fable-latest", + name: "Anthropic: Claude Fable Latest", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 10, + output: 50, + cacheRead: 1, + cacheWrite: 12.5, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, "~anthropic/claude-haiku-latest": { id: "~anthropic/claude-haiku-latest", name: "Anthropic Claude Haiku Latest", @@ -13039,13 +13255,13 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.684, - output: 3.42, - cacheRead: 0.144, + input: 0.6799999999999999, + output: 3.41, + cacheRead: 0.33999999999999997, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 262144, + maxTokens: 262142, } satisfies Model<"openai-completions">, "~openai/gpt-latest": { id: "~openai/gpt-latest", @@ -13089,9 +13305,9 @@ export const MODELS = { api: "openai-completions", provider: "together", baseUrl: "https://api.together.ai/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false,"thinkingFormat":"together"}, reasoning: true, - thinkingLevelMap: {"off":null,"minimal":null,"low":null,"medium":null}, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null}, input: ["text"], cost: { input: 0.3, @@ -13236,7 +13452,7 @@ export const MODELS = { } satisfies Model<"openai-completions">, "deepseek-ai/DeepSeek-V3": { id: "deepseek-ai/DeepSeek-V3", - name: "DeepSeek V3", + name: "DeepSeek-V3", api: "openai-completions", provider: "together", baseUrl: "https://api.together.ai/v1", @@ -13384,6 +13600,25 @@ export const MODELS = { contextWindow: 262144, maxTokens: 131000, } satisfies Model<"openai-completions">, + "nvidia/nemotron-3-ultra-550b-a55b": { + id: "nvidia/nemotron-3-ultra-550b-a55b", + name: "Nemotron 3 Ultra 550B A55B", + api: "openai-completions", + provider: "together", + baseUrl: "https://api.together.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false,"thinkingFormat":"together"}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null}, + input: ["text"], + cost: { + input: 0.6, + output: 3.6, + cacheRead: 0.2, + cacheWrite: 0, + }, + contextWindow: 512300, + maxTokens: 512300, + } satisfies Model<"openai-completions">, "openai/gpt-oss-120b": { id: "openai/gpt-oss-120b", name: "GPT OSS 120B", @@ -13467,8 +13702,8 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.08, - output: 0.29, + input: 0.12, + output: 0.5, cacheRead: 0, cacheWrite: 0, }, @@ -13832,6 +14067,25 @@ export const MODELS = { contextWindow: 200000, maxTokens: 8192, } satisfies Model<"anthropic-messages">, + "anthropic/claude-fable-5": { + id: "anthropic/claude-fable-5", + name: "Claude Fable 5", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + compat: {"forceAdaptiveThinking":true}, + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 10, + output: 50, + cacheRead: 1, + cacheWrite: 12.5, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, "anthropic/claude-haiku-4.5": { id: "anthropic/claude-haiku-4.5", name: "Claude Haiku 4.5", @@ -14213,40 +14467,6 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 384000, } satisfies Model<"anthropic-messages">, - "google/gemini-2.0-flash": { - id: "google/gemini-2.0-flash", - name: "Gemini 2.0 Flash", - api: "anthropic-messages", - provider: "vercel-ai-gateway", - baseUrl: "https://ai-gateway.vercel.sh", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.15, - output: 0.6, - cacheRead: 0.024999999999999998, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 8192, - } satisfies Model<"anthropic-messages">, - "google/gemini-2.0-flash-lite": { - id: "google/gemini-2.0-flash-lite", - name: "Gemini 2.0 Flash Lite", - api: "anthropic-messages", - provider: "vercel-ai-gateway", - baseUrl: "https://ai-gateway.vercel.sh", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.075, - output: 0.3, - cacheRead: 0.02, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 8192, - } satisfies Model<"anthropic-messages">, "google/gemini-2.5-flash": { id: "google/gemini-2.5-flash", name: "Gemini 2.5 Flash", @@ -15089,12 +15309,12 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.5, - output: 2.5, - cacheRead: 0.15, + input: 0.6, + output: 2.4, + cacheRead: 0.12, cacheWrite: 0, }, - contextWindow: 262144, + contextWindow: 1000000, maxTokens: 65000, } satisfies Model<"anthropic-messages">, "nvidia/nemotron-nano-12b-v2-vl": { @@ -16366,7 +16586,7 @@ export const MODELS = { cacheRead: 0.2, cacheWrite: 0, }, - contextWindow: 2000000, + contextWindow: 1000000, maxTokens: 30000, } satisfies Model<"openai-completions">, "grok-4.20-0309-reasoning": { @@ -16383,7 +16603,7 @@ export const MODELS = { cacheRead: 0.2, cacheWrite: 0, }, - contextWindow: 2000000, + contextWindow: 1000000, maxTokens: 30000, } satisfies Model<"openai-completions">, "grok-4.3": { diff --git a/packages/ai/src/providers/amazon-bedrock.ts b/packages/ai/src/providers/amazon-bedrock.ts index 0de0ea6e..a4ace1c2 100644 --- a/packages/ai/src/providers/amazon-bedrock.ts +++ b/packages/ai/src/providers/amazon-bedrock.ts @@ -143,10 +143,13 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt // 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. - // When AWS_PROFILE is set, we leave region undefined so the SDK can - // resovle it from aws profile configs. Otherwise fall back to us-east-1. - if (configuredRegion) { + // Region resolution: ARN-embedded > explicit option > env vars > SDK default chain. + // When the model ID is an inference profile ARN, extract the region from it. + // This avoids conflicts with AWS_REGION set for other services. + const arnRegionMatch = model.id.match(/^arn:aws(?:-[a-z0-9-]+)?:bedrock:([a-z0-9-]+):/); + if (arnRegionMatch) { + config.region = arnRegionMatch[1]; + } else if (configuredRegion) { config.region = configuredRegion; } else if (endpointRegion && useExplicitEndpoint) { config.region = endpointRegion; @@ -287,6 +290,13 @@ const BEDROCK_ERROR_PREFIXES: Record = { ServiceUnavailableException: "Service unavailable", }; +/** + * Some models reject the account/profile's configured Bedrock data retention mode + * (e.g. "data retention mode 'default' is not available for this model"). Point + * users at the AWS docs explaining how to configure a supported mode. + */ +const BEDROCK_DATA_RETENTION_DOCS_URL = "https://docs.aws.amazon.com/bedrock/latest/userguide/data-retention.html"; + /** * Format a Bedrock error with a human-readable prefix. * AWS SDK exceptions (both from `client.send()` and from stream event items) @@ -296,11 +306,14 @@ const BEDROCK_ERROR_PREFIXES: Record = { */ function formatBedrockError(error: unknown): string { const message = error instanceof Error ? error.message : JSON.stringify(error); + const dataRetentionHint = /data retention mode/i.test(message) + ? ` See ${BEDROCK_DATA_RETENTION_DOCS_URL} for supported data retention modes.` + : ""; if (error instanceof BedrockRuntimeServiceException) { const prefix = BEDROCK_ERROR_PREFIXES[error.name] ?? error.name; - return `${prefix}: ${message}`; + return `${prefix}: ${message}${dataRetentionHint}`; } - return message; + return `${message}${dataRetentionHint}`; } /** @@ -525,13 +538,18 @@ function getModelMatchCandidates(modelId: string, modelName?: string): string[] function supportsAdaptiveThinking(modelId: string, modelName?: string): boolean { const candidates = getModelMatchCandidates(modelId, modelName); return candidates.some( - (s) => s.includes("opus-4-6") || s.includes("opus-4-7") || s.includes("opus-4-8") || s.includes("sonnet-4-6"), + (s) => + s.includes("opus-4-6") || + s.includes("opus-4-7") || + s.includes("opus-4-8") || + s.includes("sonnet-4-6") || + s.includes("fable-5"), ); } function supportsNativeXhighEffort(model: Model<"bedrock-converse-stream">): boolean { const candidates = getModelMatchCandidates(model.id, model.name); - return candidates.some((s) => s.includes("opus-4-7") || s.includes("opus-4-8")); + return candidates.some((s) => s.includes("opus-4-7") || s.includes("opus-4-8") || s.includes("fable-5")); } function mapThinkingLevelToEffort( diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 56b82d8c..efe4bd75 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -200,7 +200,7 @@ export interface AnthropicOptions extends StreamOptions { * Effort level for adaptive thinking models. * Controls how much thinking Claude allocates: * - "max": Always thinks with no constraints (Opus 4.6 only) - * - "xhigh": Highest reasoning level (Opus 4.7) + * - "xhigh": Highest reasoning level (Opus 4.7+, Fable 5) * - "high": Always thinks, deep reasoning * - "medium": Moderate thinking, may skip for simple queries * - "low": Minimal thinking, skips for simple tasks @@ -711,7 +711,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti /** * Map ThinkingLevel to Anthropic effort levels for adaptive thinking. - * Note: effort "max" is only valid on Opus 4.6, while Opus 4.7 supports "xhigh". + * Note: effort "max" is only valid on Opus 4.6, while Opus 4.7+ and Fable 5 support "xhigh". */ function mapThinkingLevelToEffort( model: Model<"anthropic-messages">, @@ -972,7 +972,7 @@ function buildParams( display, }; } - } else if (options?.thinkingEnabled === false) { + } else if (options?.thinkingEnabled === false && model.thinkingLevelMap?.off !== null) { params.thinking = { type: "disabled" }; } } diff --git a/packages/ai/src/providers/azure-openai-responses.ts b/packages/ai/src/providers/azure-openai-responses.ts index 921a482e..ecb4c7e6 100644 --- a/packages/ai/src/providers/azure-openai-responses.ts +++ b/packages/ai/src/providers/azure-openai-responses.ts @@ -256,6 +256,7 @@ function buildParams( input: messages, stream: true, prompt_cache_key: clampOpenAIPromptCacheKey(options?.sessionId), + store: false, }; if (options?.maxTokens) { diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index 9fb3a357..0f87c897 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -554,7 +554,8 @@ function buildParams( } if (compat.thinkingFormat === "zai" && model.reasoning) { - (params as any).enable_thinking = !!options?.reasoningEffort; + const zaiParams = params as typeof params & { thinking?: { type: "enabled" | "disabled" } }; + zaiParams.thinking = { type: options?.reasoningEffort ? "enabled" : "disabled" }; } else if (compat.thinkingFormat === "qwen" && model.reasoning) { (params as any).enable_thinking = !!options?.reasoningEffort; } else if (compat.thinkingFormat === "qwen-chat-template" && model.reasoning) { diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index e14c493f..4d7b4738 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -426,7 +426,7 @@ export interface OpenAICompletionsCompat { requiresThinkingAsText?: boolean; /** Whether all replayed assistant messages must include an empty reasoning_content field when reasoning is enabled. Default: auto-detected from URL. */ requiresReasoningContentOnAssistantMessages?: boolean; - /** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "openrouter" uses reasoning: { effort }, "deepseek" uses thinking: { type } plus reasoning_effort when supported, "together" uses reasoning: { enabled } plus reasoning_effort when supported, "zai" uses top-level enable_thinking: boolean, "qwen" uses top-level enable_thinking: boolean, "qwen-chat-template" uses chat_template_kwargs.enable_thinking, "string-thinking" uses top-level thinking: string, and "ant-ling" uses reasoning: { effort } only when the mapped effort is non-null. Default: "openai". */ + /** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "openrouter" uses reasoning: { effort }, "deepseek" uses thinking: { type } plus reasoning_effort when supported, "together" uses reasoning: { enabled } plus reasoning_effort when supported, "zai" uses thinking: { type }, "qwen" uses top-level enable_thinking: boolean, "qwen-chat-template" uses chat_template_kwargs.enable_thinking, "string-thinking" uses top-level thinking: string, and "ant-ling" uses reasoning: { effort } only when the mapped effort is non-null. Default: "openai". */ thinkingFormat?: | "openai" | "openrouter" diff --git a/packages/ai/test/anthropic-adaptive-thinking-models.test.ts b/packages/ai/test/anthropic-adaptive-thinking-models.test.ts index 9386ce0b..da042e2f 100644 --- a/packages/ai/test/anthropic-adaptive-thinking-models.test.ts +++ b/packages/ai/test/anthropic-adaptive-thinking-models.test.ts @@ -3,8 +3,11 @@ import { getModels, getProviders } from "../src/models.ts"; import type { Api, Model } from "../src/types.ts"; const EXPECTED_CURRENT_ADAPTIVE_THINKING_MODELS = [ + "anthropic/claude-fable-5", "anthropic/claude-opus-4-8", + "opencode/claude-fable-5", "opencode/claude-opus-4-8", + "vercel-ai-gateway/anthropic/claude-fable-5", "vercel-ai-gateway/anthropic/claude-opus-4.8", ]; @@ -22,7 +25,7 @@ describe("Anthropic adaptive thinking model metadata", () => { expect(flaggedModels).toEqual(expect.arrayContaining([...EXPECTED_CURRENT_ADAPTIVE_THINKING_MODELS].sort())); expect(flaggedModels).toEqual( - flaggedModels.filter((modelId) => /(opus[-.]4[-.][678]|sonnet[-.]4[-.]6)/.test(modelId)), + flaggedModels.filter((modelId) => /(opus[-.]4[-.][678]|sonnet[-.]4[-.]6|fable[-.]5)/.test(modelId)), ); }); }); diff --git a/packages/ai/test/anthropic-force-adaptive-thinking.test.ts b/packages/ai/test/anthropic-force-adaptive-thinking.test.ts index 5b3511ca..e797c3a9 100644 --- a/packages/ai/test/anthropic-force-adaptive-thinking.test.ts +++ b/packages/ai/test/anthropic-force-adaptive-thinking.test.ts @@ -83,6 +83,13 @@ describe("Anthropic forceAdaptiveThinking compat override", () => { expect(payload.output_config).toEqual({ effort: "medium" }); }); + it("uses adaptive thinking with native xhigh effort for Claude Fable 5", async () => { + const payload = await capturePayload(getModel("anthropic", "claude-fable-5"), { reasoning: "xhigh" }); + + expect(payload.thinking).toEqual({ type: "adaptive", display: "summarized" }); + expect(payload.output_config).toEqual({ effort: "xhigh" }); + }); + it("allows built-in adaptive models to opt out with compat.forceAdaptiveThinking false", async () => { const model: Model<"anthropic-messages"> = { ...getModel("anthropic", "claude-opus-4-8"), diff --git a/packages/ai/test/anthropic-thinking-disable.test.ts b/packages/ai/test/anthropic-thinking-disable.test.ts index 5ee89b1e..13d333e5 100644 --- a/packages/ai/test/anthropic-thinking-disable.test.ts +++ b/packages/ai/test/anthropic-thinking-disable.test.ts @@ -132,6 +132,13 @@ describe("Anthropic thinking disable payload", () => { expect(payload.output_config).toBeUndefined(); }); + it("omits thinking.type=disabled for Claude Fable 5 when thinking is off", async () => { + const payload = await capturePayload(getModel("anthropic", "claude-fable-5")); + + expect(payload.thinking).toBeUndefined(); + expect(payload.output_config).toBeUndefined(); + }); + it("uses adaptive thinking for Claude Opus 4.8 when reasoning is enabled", async () => { const payload = await capturePayload(getModel("anthropic", "claude-opus-4-8"), { reasoning: "high" }); diff --git a/packages/ai/test/azure-openai-base-url.test.ts b/packages/ai/test/azure-openai-base-url.test.ts index 530c5f47..15b8a528 100644 --- a/packages/ai/test/azure-openai-base-url.test.ts +++ b/packages/ai/test/azure-openai-base-url.test.ts @@ -13,6 +13,7 @@ interface CapturedAzureClientOptions { interface CapturedAzureResponsesPayload { prompt_cache_key?: string; + store?: boolean; } const azureMock = vi.hoisted(() => ({ @@ -144,6 +145,16 @@ describe("azure-openai-responses base URL normalization", () => { expect(azureMock.lastParams?.prompt_cache_key).toBe("x".repeat(64)); }); + it("disables server-side response storage", async () => { + const model = getModel("azure-openai-responses", "gpt-4o-mini"); + await streamAzureOpenAIResponses(model, context, { + apiKey: "test-api-key", + azureBaseUrl: "https://my-resource.openai.azure.com", + }).result(); + + expect(azureMock.lastParams?.store).toBe(false); + }); + it("builds correct default URL from AZURE_OPENAI_RESOURCE_NAME", async () => { process.env.AZURE_OPENAI_RESOURCE_NAME = "my-resource"; const model = getModel("azure-openai-responses", "gpt-4o-mini"); diff --git a/packages/ai/test/bedrock-endpoint-resolution.test.ts b/packages/ai/test/bedrock-endpoint-resolution.test.ts index 2483fcae..412c62e0 100644 --- a/packages/ai/test/bedrock-endpoint-resolution.test.ts +++ b/packages/ai/test/bedrock-endpoint-resolution.test.ts @@ -128,4 +128,30 @@ describe("bedrock endpoint resolution", () => { expect(config.endpoint).toBe("https://bedrock-vpc.example.com"); expect(config.region).toBe("us-west-2"); }); + + it("extracts region from inference profile ARN regardless of AWS_REGION", async () => { + process.env.AWS_REGION = "us-east-1"; + const baseModel = getModel("amazon-bedrock", "us.anthropic.claude-opus-4-8"); + const model: Model<"bedrock-converse-stream"> = { + ...baseModel, + id: "arn:aws:bedrock:us-west-2:123456789012:application-inference-profile/abc123", + }; + + const config = await captureClientConfig(model); + + expect(config.region).toBe("us-west-2"); + }); + + it("extracts region from GovCloud inference profile ARN", async () => { + process.env.AWS_REGION = "us-east-1"; + const baseModel = getModel("amazon-bedrock", "us.anthropic.claude-opus-4-8"); + const model: Model<"bedrock-converse-stream"> = { + ...baseModel, + id: "arn:aws-us-gov:bedrock:us-gov-west-1:123456789012:application-inference-profile/abc123", + }; + + const config = await captureClientConfig(model); + + expect(config.region).toBe("us-gov-west-1"); + }); }); diff --git a/packages/ai/test/bedrock-thinking-payload.test.ts b/packages/ai/test/bedrock-thinking-payload.test.ts index 93143e38..8f4e06e7 100644 --- a/packages/ai/test/bedrock-thinking-payload.test.ts +++ b/packages/ai/test/bedrock-thinking-payload.test.ts @@ -83,6 +83,25 @@ describe("Bedrock thinking payload", () => { expect(payload.additionalModelRequestFields?.anthropic_beta).toBeUndefined(); }); + it("uses adaptive thinking for Claude Fable 5 when reasoning is enabled", async () => { + const model = getModel("amazon-bedrock", "global.anthropic.claude-fable-5"); + + const payload = await capturePayload(model); + + expect(payload.additionalModelRequestFields?.thinking).toEqual({ type: "adaptive", display: "summarized" }); + expect(payload.additionalModelRequestFields?.output_config).toEqual({ effort: "high" }); + expect(payload.additionalModelRequestFields?.anthropic_beta).toBeUndefined(); + }); + + it("maps xhigh reasoning to effort=xhigh for Claude Fable 5", async () => { + const model = getModel("amazon-bedrock", "global.anthropic.claude-fable-5"); + + const payload = await capturePayload(model, { reasoning: "xhigh" }); + + expect(payload.additionalModelRequestFields?.thinking).toEqual({ type: "adaptive", display: "summarized" }); + expect(payload.additionalModelRequestFields?.output_config).toEqual({ effort: "xhigh" }); + }); + it("omits display for GovCloud model ids on non-adaptive Claude thinking", async () => { const baseModel = getModel("amazon-bedrock", "us.anthropic.claude-sonnet-4-5-20250929-v1:0"); const model: Model<"bedrock-converse-stream"> = { diff --git a/packages/ai/test/openai-completions-tool-choice.test.ts b/packages/ai/test/openai-completions-tool-choice.test.ts index be6419d6..319b18af 100644 --- a/packages/ai/test/openai-completions-tool-choice.test.ts +++ b/packages/ai/test/openai-completions-tool-choice.test.ts @@ -1120,6 +1120,33 @@ describe("openai-completions tool_choice", () => { expect(params.reasoning_effort).toBeUndefined(); }); + it("sends max_tokens for OpenCode completions models", async () => { + const cases = [getModel("opencode-go", "kimi-k2.6")!, getModel("opencode", "grok-build-0.1")!] as const; + + for (const model of cases) { + let payload: unknown; + expect(model.compat?.maxTokensField).toBe("max_tokens"); + + await streamSimple( + model, + { + messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], + }, + { + apiKey: "test", + maxTokens: 123, + onPayload: (params: unknown) => { + payload = params; + }, + }, + ).result(); + + const params = (payload ?? mockState.lastParams) as { max_tokens?: number; max_completion_tokens?: number }; + expect(params.max_tokens).toBe(123); + expect(params.max_completion_tokens).toBeUndefined(); + } + }); + it("omits reasoning effort for OpenCode Grok Build", async () => { const model = getModel("opencode", "grok-build-0.1")!; let payload: unknown; diff --git a/packages/ai/test/supports-xhigh.test.ts b/packages/ai/test/supports-xhigh.test.ts index d5ec3b5b..ec6dc87b 100644 --- a/packages/ai/test/supports-xhigh.test.ts +++ b/packages/ai/test/supports-xhigh.test.ts @@ -20,7 +20,14 @@ describe("getSupportedThinkingLevels", () => { expect(getSupportedThinkingLevels(model!)).toContain("xhigh"); }); - it("does not include xhigh for non-Opus Anthropic models", () => { + it("includes xhigh but not off for Anthropic Claude Fable 5 on anthropic-messages API", () => { + const model = getModel("anthropic", "claude-fable-5"); + expect(model).toBeDefined(); + expect(getSupportedThinkingLevels(model!)).toContain("xhigh"); + expect(getSupportedThinkingLevels(model!)).not.toContain("off"); + }); + + it("does not include xhigh for Claude Sonnet 4.5", () => { const model = getModel("anthropic", "claude-sonnet-4-5"); expect(model).toBeDefined(); expect(getSupportedThinkingLevels(model!)).not.toContain("xhigh"); @@ -79,4 +86,11 @@ describe("getSupportedThinkingLevels", () => { expect(model).toBeDefined(); expect(getSupportedThinkingLevels(model!)).toContain("xhigh"); }); + + it("includes xhigh but not off for Bedrock Claude Fable 5", () => { + const model = getModel("amazon-bedrock", "global.anthropic.claude-fable-5"); + expect(model).toBeDefined(); + expect(getSupportedThinkingLevels(model!)).toContain("xhigh"); + expect(getSupportedThinkingLevels(model!)).not.toContain("off"); + }); }); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 83b6fbc6..f24e827e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,57 @@ ### Added +- Added an experimental first-time setup flow behind `PI_EXPERIMENTAL=1` that asks for a dark/light theme choice (preselecting the detected appearance) and opt-in analytics data sharing on first launch with the default agent directory; opting in stores a `trackingId` in `settings.json`. + +## [0.79.1] - 2026-06-09 + +### New Features + +- **Claude Fable 5** - Claude Fable 5 is now available on the Anthropic and Amazon Bedrock providers, with adaptive thinking and `xhigh` effort support. +- **Prompt template defaults** - Prompt templates can use default positional arguments such as `${1:-7}` for optional values. See [Prompt Template Arguments](docs/prompt-templates.md#arguments). +- **Configurable project trust defaults** - `defaultProjectTrust` lets users choose whether unresolved project trust asks, always trusts, or never trusts by default, and extensions can inspect effective trust decisions. See [Project Trust](docs/security.md#project-trust) and [`ctx.isProjectTrusted()`](docs/extensions.md#ctxisprojecttrusted). +- **Natural extension autocomplete triggers** - Extension autocomplete providers can declare trigger characters such as `#` or `$` so suggestions open without slash-command prefixes. See [Autocomplete Providers](docs/extensions.md#autocomplete-providers). + +### Added + +- Added default-value expansion for prompt template positional arguments, e.g. `${1:-7}` ([#5553](https://github.com/earendil-works/pi/pull/5553) by [@dannote](https://github.com/dannote)). +- Added `areExperimentalFeaturesEnabled` feature guard to allow users to opt in to early features ([#5547](https://github.com/earendil-works/pi/pull/5547) by [@vegarsti](https://github.com/vegarsti)). +- Added `ctx.isProjectTrusted()` for extensions to observe the effective project trust decision, including temporary trust decisions ([#5523](https://github.com/earendil-works/pi/issues/5523)). +- Added a global `defaultProjectTrust` setting to choose whether unresolved project trust asks, always trusts, or never trusts by default. +- Added extension autocomplete trigger character support for `ctx.ui.addAutocompleteProvider()` wrappers ([#4703](https://github.com/earendil-works/pi/issues/4703)). +- Added Claude Fable 5 model support inherited from `@earendil-works/pi-ai` for the Anthropic and Amazon Bedrock providers, with adaptive thinking and `xhigh` effort support. + +### Fixed + +- Fixed inherited Amazon Bedrock inference profile ARN region resolution to prefer the ARN's embedded region over `AWS_REGION` ([#5527](https://github.com/earendil-works/pi/pull/5527) by [@AJM10565](https://github.com/AJM10565)). +- Fixed inherited IME hardware cursor positioning while slash-command autocomplete is visible ([#5283](https://github.com/earendil-works/pi/pull/5283) by [@smoosex](https://github.com/smoosex)). +- Fixed inherited z.ai thinking-off requests to send the provider's `thinking: { type: "disabled" }` compatibility parameter ([#5330](https://github.com/earendil-works/pi/issues/5330)). +- Fixed inherited OpenCode completions model metadata to send explicit `maxTokens` as `max_tokens` ([#5331](https://github.com/earendil-works/pi/issues/5331)). +- Fixed inherited Moonshot Kimi thinking-off requests to send the provider's `thinking: { type: "disabled" }` compatibility parameter ([#5531](https://github.com/earendil-works/pi/issues/5531)). +- Fixed inherited Azure OpenAI Responses requests to disable server-side response storage ([#5530](https://github.com/earendil-works/pi/issues/5530)). +- Fixed inherited Azure GPT-5.4 and GPT-5.5 context window metadata to 1,050,000 tokens, matching Azure Foundry deployments instead of OpenAI's 272k limit ([#5559](https://github.com/earendil-works/pi/issues/5559)). +- Fixed inherited OpenAI and Azure GPT-5 Pro `maxTokens` metadata to 128,000, correcting an upstream value that duplicated the input sub-limit as the output limit ([#5559](https://github.com/earendil-works/pi/issues/5559)). +- Fixed inherited prompt history navigation to restore the current draft when returning from history browsing ([#5494](https://github.com/earendil-works/pi/issues/5494)). +- Fixed inherited wrapping for mixed Latin and CJK text so unspaced CJK runs can break at grapheme boundaries without leaving large trailing gaps ([#5495](https://github.com/earendil-works/pi/issues/5495)). +- Fixed extension OAuth login prompts to keep previous submitted prompt rows stable instead of mirroring the active input value ([#5433](https://github.com/earendil-works/pi/issues/5433)). +- Fixed `/reload` to apply updated `steeringMode` and `followUpMode` settings to the current session ([#5377](https://github.com/earendil-works/pi/issues/5377)). +- Fixed invalid `models.json` syntax to skip startup config migrations and report the normal file-path-aware models error instead of a raw JSON parse stack trace ([#5418](https://github.com/earendil-works/pi/issues/5418)). +- Fixed GitHub release notes and interactive changelog links to resolve package-relative documentation URLs correctly ([#5516](https://github.com/earendil-works/pi/issues/5516)). +- Fixed CLI help and version output, including plain redirected `--help`/`--version` output and simplified `list`/`config` help text. +- Fixed `/new` from ephemeral sessions to keep the new session ephemeral instead of persisting it by default ([#5045](https://github.com/earendil-works/pi/issues/5045)). +- Clarified custom model docs that `name` and `modelOverrides.name` do not replace model IDs in the footer or primary model lists ([#4841](https://github.com/earendil-works/pi/issues/4841)). + +## [0.79.0] - 2026-06-08 + +### New Features + +- **Project trust for local inputs** - Pi now asks before loading project-local settings, resources, instructions, and packages, with saved decisions and `--approve` / `--no-approve` controls for non-interactive modes. See [Project Trust](README.md#project-trust). +- **Extension-controlled trust decisions** - Global and CLI extensions can handle `project_trust`, decide, remember, or defer project trust before project-local resources load. See [`project_trust`](docs/extensions.md#project_trust). +- **Cache-hit visibility in the footer** - The interactive footer now shows the latest prompt cache hit rate (`CH`). See [Interactive Mode](README.md#interactive-mode). +- **Richer SDK and RPC extension surfaces** - Public exports now include RPC extension UI request/response types and package asset path helpers. See [Extension UI Protocol](docs/rpc.md#extension-ui-protocol) and [SDK Exports](docs/sdk.md#exports). + +### Added + - Added a `project_trust` extension event so global and CLI extensions can decide or defer project trust during startup and runtime cwd switches. - Added project trust gating for project-local settings, resources, instructions, and packages ([#5332](https://github.com/earendil-works/pi/pull/5332)). - Added the latest prompt cache hit rate to the interactive footer. @@ -12,11 +63,19 @@ ### Fixed +- Fixed package exports by removing the stale `./hooks` subpath that pointed at non-existent build output. +- Fixed inherited TUI rendering to clear stale lines when content shrinks to zero. +- Fixed inherited autocomplete suggestions to refresh after editor cursor movement ([#5499](https://github.com/earendil-works/pi/pull/5499) by [@Roman-Galeev](https://github.com/Roman-Galeev)). - Fixed `/reload` to persist project trust when an implicitly trusted session creates a project `.pi` directory. +- Fixed project trust input discovery to traverse parent directories portably. +- Fixed inherited intermittent Shift+Enter handling by making Kitty keyboard protocol fallback response-driven instead of timeout-driven ([#5188](https://github.com/earendil-works/pi/issues/5188)). - Fixed the compaction summarization system prompt to use neutral AI assistant wording for non-coding agents ([#5401](https://github.com/earendil-works/pi/issues/5401)). -- Fixed `models.json` schema support for OpenAI Responses `compat.supportsDeveloperRole` ([#5456](https://github.com/earendil-works/pi/issues/5456)). +- Fixed `models.json` schema support and inherited OpenAI Responses custom-provider handling for `compat.supportsDeveloperRole: false` ([#5456](https://github.com/earendil-works/pi/issues/5456)). +- Fixed inherited prompt history navigation to place the cursor at the start when browsing upward and at the end when browsing downward ([#5454](https://github.com/earendil-works/pi/issues/5454)). - Fixed tmux setup documentation to require tmux 3.5 for `extended-keys-format csi-u` and document the tmux 3.2-3.4 fallback ([#5432](https://github.com/earendil-works/pi/issues/5432)). +- Fixed inherited OpenRouter routing preferences on OpenAI-compatible custom providers to work when the custom provider base URL does not point directly at OpenRouter ([#5347](https://github.com/earendil-works/pi/issues/5347)). - Fixed built-in tool expand hints to style closing parentheses consistently ([#5359](https://github.com/earendil-works/pi/issues/5359)). +- Fixed skill-wrapped prompts to insert spacing between skill instructions and the user message ([#5371](https://github.com/earendil-works/pi/pull/5371) by [@Perlence](https://github.com/Perlence)). ## [0.78.1] - 2026-06-04 diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 202b6f3e..6e5c7059 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -291,15 +291,17 @@ See [docs/settings.md](docs/settings.md) for all options. ### Project Trust -On interactive startup, pi asks before trusting a project folder that contains project-local inputs and has no saved decision in `~/.pi/agent/trust.json`. Trusting a project allows pi to read project instructions (`AGENTS.md`/`CLAUDE.md`), load `.pi/settings.json` and `.pi` resources, install missing project packages, and execute project extensions. +On interactive startup, pi asks before trusting a project folder that contains project-local extensions or settings and has no saved decision for the folder or a parent folder in `~/.pi/agent/trust.json`. Trusting a project allows pi to load `.pi/settings.json` and `.pi` resources, install missing project packages, and execute project extensions. -Before the trust decision, pi loads only user/global extensions and CLI `-e` extensions so they can handle the `project_trust` event. Project-local extensions, project package-managed extensions, project settings, and project instructions are loaded only after the project is trusted. This split also applies when switching to a session from a different cwd whose trust has not been resolved in the current process. +Before the trust decision, pi loads only context files, user/global extensions, and CLI `-e` extensions so they can handle the `project_trust` event. Project-local extensions, project package-managed extensions, and project settings are loaded only after the project is trusted. This split also applies when switching to a session from a different cwd whose trust has not been resolved in the current process. -Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without a saved trust decision, they ignore project-local inputs unless `--approve`/`-a` is passed. Use `--no-approve`/`-na` to ignore project-local inputs for one run even when the project is trusted. +Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without an applicable saved trust decision, they use `defaultProjectTrust` from global settings: `ask` (default) and `never` ignore trust-gated project inputs, while `always` trusts them. Pass `--approve`/`-a` or `--no-approve`/`-na` to override project trust for one run. -`pi config` assumes project trust for that command so you can view and change project resource settings before starting a session. It does not save a trust decision; starting a session in that folder still prompts. Pass `--no-approve` to hide project-local inputs in `pi config`. +If no extension or saved decision applies, `defaultProjectTrust` controls the fallback behavior. Set it to `"ask"`, `"always"`, or `"never"` in `~/.pi/agent/settings.json`, or change it with `/settings`. -Use `/trust` in interactive mode to save a project trust decision for future sessions. It writes `~/.pi/agent/trust.json` only; the current session is not reloaded, so restart pi for changes to take effect. +`pi config` and package commands use the same project trust flow. Pass `--approve` to trust project-local settings for one command or `--no-approve` to ignore them. + +Use `/trust` in interactive mode to save a project trust decision for future sessions, including trust for the immediate parent folder. It writes `~/.pi/agent/trust.json` only; the current session is not reloaded, so restart pi for changes to take effect. ### Telemetry and update checks @@ -316,8 +318,8 @@ Use `--offline` or `PI_OFFLINE=1` to disable all startup network operations desc Pi loads `AGENTS.md` (or `CLAUDE.md`) at startup from: - `~/.pi/agent/AGENTS.md` (global) -- Parent directories (walking up from cwd, only when the project is trusted) -- Current directory (only when the project is trusted) +- Parent directories (walking up from cwd) +- Current directory Use for project instructions (`AGENTS.md`/`CLAUDE.md`), conventions, common commands. All matching files are concatenated. @@ -525,7 +527,7 @@ pi list # List installed packages pi config # Enable/disable package resources ``` -Project package commands accept `--approve`/`--no-approve` to trust or ignore project-local package settings for one command. +`pi config` and project package commands accept `--approve`/`--no-approve` to trust or ignore project-local settings for one command. ### Modes diff --git a/packages/coding-agent/docs/docs.json b/packages/coding-agent/docs/docs.json index f781abc2..bbc9e74b 100644 --- a/packages/coding-agent/docs/docs.json +++ b/packages/coding-agent/docs/docs.json @@ -19,6 +19,10 @@ "title": "Providers", "path": "providers.md" }, + { + "title": "Security", + "path": "security.md" + }, { "title": "Containerization", "path": "containerization.md" diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 601d378e..cb02960f 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -339,7 +339,7 @@ exit (Ctrl+C, Ctrl+D, SIGHUP, SIGTERM) #### project_trust -Fired before pi decides whether to trust a project with trust inputs (`.pi`, `AGENTS.md`/`CLAUDE.md`, or `.agents/skills`). It runs during startup and when session replacement (for example `/resume`) enters a cwd whose trust has not been resolved in the current process. Only user/global extensions and CLI `-e` extensions participate; project-local extensions are not loaded until after trust is resolved. +Fired before pi decides whether to trust a project with dynamic configs (`.pi` or `.agents/skills`). It runs during startup and when session replacement (for example `/resume`) enters a cwd whose trust has not been resolved in the current process. Only user/global extensions and CLI `-e` extensions participate; project-local extensions are not loaded until after trust is resolved. ```typescript pi.on("project_trust", async (event, ctx) => { @@ -352,7 +352,7 @@ pi.on("project_trust", async (event, ctx) => { }); ``` -A `project_trust` handler must return `{ trusted: "yes" | "no" | "undecided" }`. A user/global or CLI extension that returns `"yes"` or `"no"` owns the decision; the first yes/no decision wins and suppresses the built-in trust prompt. Use `remember: true` to persist a yes/no decision; otherwise it applies only to the current process. Return `"undecided"` to let later handlers or the built-in trust flow decide. Check `ctx.hasUI` before prompting. If no handler returns yes/no, normal trust resolution continues, including the built-in trust prompt when UI is available. +A `project_trust` handler must return `{ trusted: "yes" | "no" | "undecided" }`. A user/global or CLI extension that returns `"yes"` or `"no"` owns the decision; the first yes/no decision wins and suppresses the built-in trust prompt. Use `remember: true` to persist a yes/no decision; otherwise it applies only to the current process. Return `"undecided"` to let later handlers or the built-in trust flow decide. Check `ctx.hasUI` before prompting. If no handler returns yes/no, normal trust resolution continues: saved `trust.json` decisions apply first, then `defaultProjectTrust` controls whether pi asks, trusts, or declines by default. ### Resource Events @@ -892,6 +892,12 @@ Current run mode: `"tui"`, `"rpc"`, `"json"`, or `"print"`. Use `ctx.mode === "t Current working directory. +### ctx.isProjectTrusted() + +Returns whether project-local trust is active for the current session context. This includes temporary trust decisions and CLI trust overrides, not just saved decisions in the global trust store. + +Use this before reading project-local extension configuration that should only be honored for trusted projects. + ### ctx.sessionManager Read-only access to session state. See [Session Format](session-format.md) for the full SessionManager API and entry types. @@ -2275,6 +2281,7 @@ ctx.ui.pasteToEditor("pasted content"); // Stack custom autocomplete behavior on top of the built-in provider ctx.ui.addAutocompleteProvider((current) => ({ + triggerCharacters: ["#"], async getSuggestions(lines, line, col, options) { const beforeCursor = (lines[line] ?? "").slice(0, col); const match = beforeCursor.match(/(?:^|[ \t])#([^\s#]*)$/); @@ -2323,7 +2330,7 @@ Custom working-indicator frames are rendered verbatim. If you want colors, add t ### Autocomplete Providers -Use `ctx.ui.addAutocompleteProvider()` to stack custom autocomplete logic on top of the built-in slash-command and path provider. +Use `ctx.ui.addAutocompleteProvider()` to stack custom autocomplete logic on top of the built-in slash-command and path provider. Set `triggerCharacters` for custom natural triggers such as `$`. Typical pattern: @@ -2335,6 +2342,7 @@ Typical pattern: ```typescript pi.on("session_start", (_event, ctx) => { ctx.ui.addAutocompleteProvider((current) => ({ + triggerCharacters: ["#"], async getSuggestions(lines, cursorLine, cursorCol, options) { const line = lines[cursorLine] ?? ""; const beforeCursor = line.slice(0, cursorCol); diff --git a/packages/coding-agent/docs/index.md b/packages/coding-agent/docs/index.md index 2a334e8a..71995831 100644 --- a/packages/coding-agent/docs/index.md +++ b/packages/coding-agent/docs/index.md @@ -41,6 +41,7 @@ For the full first-run flow, see [Quickstart](quickstart.md). - [Quickstart](quickstart.md) - install, authenticate, and run a first session. - [Using Pi](usage.md) - interactive mode, slash commands, context files, and CLI reference. - [Providers](providers.md) - subscription and API-key setup for built-in providers. +- [Security](security.md) - project trust, sandbox boundaries, and vulnerability reporting. - [Containerization](containerization.md) - sandbox pi with OpenShell, Gondolin, or Docker. - [Settings](settings.md) - global and project settings. - [Keybindings](keybindings.md) - default shortcuts and custom keybindings. diff --git a/packages/coding-agent/docs/models.md b/packages/coding-agent/docs/models.md index d457643d..39b56aa1 100644 --- a/packages/coding-agent/docs/models.md +++ b/packages/coding-agent/docs/models.md @@ -198,7 +198,7 @@ If your command is slow, expensive, rate-limited, or should keep using a previou | Field | Required | Default | Description | |-------|----------|---------|-------------| | `id` | Yes | — | Model identifier (passed to the API) | -| `name` | No | `id` | Human-readable model label. Used for matching (`--model` patterns) and shown in model details/status text. | +| `name` | No | `id` | Human-readable model label. Used for matching (`--model` patterns) and shown as secondary model detail text. | | `api` | No | provider's `api` | Override provider's API for this model | | `reasoning` | No | `false` | Supports extended thinking | | `thinkingLevelMap` | No | omitted | Maps pi thinking levels to provider values and marks unsupported levels (see below) | @@ -209,8 +209,8 @@ If your command is slow, expensive, rate-limited, or should keep using a previou | `compat` | No | provider `compat` | Provider compatibility overrides. Merged with provider-level `compat` when both are set. | Current behavior: -- `/model` and `--list-models` list entries by model `id`. -- The configured `name` is used for model matching and detail/status text. +- `/model`, `--list-models`, and the interactive footer display entries by model `id`. +- The configured `name` is used for model matching and secondary model detail text. It does not replace the footer/status-bar model id. ### Thinking Level Map @@ -320,6 +320,7 @@ Behavior notes: - `modelOverrides` are applied to built-in provider models. - Unknown model IDs are ignored. - You can combine provider-level `baseUrl`/`headers` with `modelOverrides`. +- Overriding `name` changes model matching and secondary detail text only; the footer and primary model lists continue to show the model `id`. - If `models` is also defined for a provider, custom models are merged after built-in overrides. A custom model with the same `id` replaces the overridden built-in model entry. ## Anthropic Messages Compatibility diff --git a/packages/coding-agent/docs/packages.md b/packages/coding-agent/docs/packages.md index 1469ea98..7009b773 100644 --- a/packages/coding-agent/docs/packages.md +++ b/packages/coding-agent/docs/packages.md @@ -40,8 +40,6 @@ These commands manage pi packages, not the pi CLI installation. To uninstall pi By default, `install` and `remove` write to user settings (`~/.pi/agent/settings.json`). Use `-l` to write to project settings (`.pi/settings.json`) instead. Project settings can be shared with your team, and pi installs any missing packages automatically on startup after the project is trusted. -Project package commands read project settings only when the project is trusted. Use `--approve` to trust project-local files for one command, or `--no-approve` to ignore them for one command. - To try a package without installing it, use `--extension` or `-e`. This installs to a temporary directory for the current run only: ```bash diff --git a/packages/coding-agent/docs/prompt-templates.md b/packages/coding-agent/docs/prompt-templates.md index 00450858..d8990bea 100644 --- a/packages/coding-agent/docs/prompt-templates.md +++ b/packages/coding-agent/docs/prompt-templates.md @@ -64,10 +64,11 @@ Type `/` followed by the template name in the editor. Autocomplete shows availab ## Arguments -Templates support positional arguments and simple slicing: +Templates support positional arguments, defaults, and simple slicing: - `$1`, `$2`, ... positional args - `$@` or `$ARGUMENTS` for all args joined +- `${1:-default}` uses arg 1 when present/non-empty, otherwise `default` - `${@:N}` for args from the Nth position (1-indexed) - `${@:N:L}` for `L` args starting at N @@ -80,6 +81,12 @@ description: Create a component Create a React component named $1 with features: $@ ``` +Default values are useful for optional arguments: + +```markdown +Summarize the current state in ${1:-7} bullet points. +``` + Usage: `/component Button "onClick handler" "disabled support"` ## Loading Rules diff --git a/packages/coding-agent/docs/security.md b/packages/coding-agent/docs/security.md new file mode 100644 index 00000000..0c6d387a --- /dev/null +++ b/packages/coding-agent/docs/security.md @@ -0,0 +1,55 @@ +# Security + +Pi is a local coding agent. It runs with the permissions of the user account that starts it, and it treats files writable by that user as inside the same local trust boundary. + +## Project Trust + +Project trust controls whether pi loads project-local settings, resources, packages, and extensions. It is not a sandbox and it does not restrict what the model can ask tools to do after you start working in a directory. + +Pi considers a project to have trust inputs when it finds any of these from the current working directory: + +- `.pi/` in the current directory +- `.agents/skills` in the current directory or an ancestor directory + +When an interactive session starts in a project with configs in `.pi` or `.agents/skills` and no saved decision for the current directory or a parent directory, pi follows `defaultProjectTrust` from global settings. The default value is `"ask"`, which asks whether to trust the project when UI is available. Saved decisions are stored by canonical directory in `~/.pi/agent/trust.json`, and the closest saved decision on the current or parent path applies before the global default. + +Trusting a project allows pi to load trust-gated project inputs, including: + +- `.pi/settings.json` +- `.pi` resources such as extensions, skills, prompt templates, themes, and system prompt files +- missing project packages configured through project settings +- project-local extensions and project package-managed extensions + +Declining trust skips protected resources. `AGENTS.md` and `CLAUDE.md` context files are loaded regardless of project trust unless context loading is disabled. Before trust is resolved, pi only loads context files, user/global extensions, and CLI `-e` extensions. User/global and CLI extensions can handle the `project_trust` event; the first extension that returns a yes/no decision owns the decision. + +Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without an applicable saved trust decision, `defaultProjectTrust: "ask"` and `"never"` ignore such resources, while `"always"` trusts them. Use `--approve`/`-a` or `--no-approve`/`-na` to override project trust for one run. + +## No Built-in Sandbox + +Pi does not include a built-in sandbox. Built-in tools can read files, write files, edit files, and run shell commands with the permissions of the pi process. Extensions are TypeScript modules that run with the same permissions. Package installs, shell commands, language servers, test commands, and other developer tools behave as ordinary local processes. + +This is intentional. Pi is designed to operate on local source trees, invoke project toolchains, and integrate with the user's existing development environment. A partial in-process sandbox would be easy to misunderstand as a security boundary while still depending on the host shell, filesystem, package managers, credentials, and extension code. Real isolation needs to come from the operating system or a virtualization/container boundary. + +Project trust is only an input-loading guard. It prevents a repository from silently changing pi's settings or extensions before you approve it. It does not make untrusted code, untrusted prompts, or untrusted model output safe. Prompt injection from repository files, comments, documentation, context files, or build output is expected local-agent risk and cannot be reliably prevented by pi. + +## Running Untrusted or Unmonitored Work + +For untrusted repositories, generated code you do not intend to monitor closely, or unattended automation, run pi in a contained environment. Use a container, VM, micro-VM, remote sandbox, or policy-controlled sandbox with only the files and credentials required for the task. + +Common patterns are documented in [Containerization](containerization.md): + +- run the whole `pi` process inside OpenShell or Docker +- run host pi while routing built-in tool execution into a Gondolin micro-VM +- mount only the workspace paths the agent should access +- avoid mounting host `~/.pi/agent` unless the container should access host sessions, settings, and credentials +- pass the minimum required API keys or use short-lived credentials +- restrict network access when the task does not need it +- review diffs and outputs before copying results back to trusted systems + +If you bind-mount a host workspace read/write, writes from inside the container or VM can still modify host files. Use read-only mounts or copy files into and out of the sandbox when you need stronger protection from unintended writes. + +## Reporting Security Issues + +To report a security issue, follow the repository [Security Policy](https://github.com/earendil-works/pi-mono/blob/main/SECURITY.md). Do not open a public issue for security-sensitive reports. + +Expected local-agent behavior, lack of a built-in sandbox, prompt injection from untrusted content, and behavior of user-installed extensions or skills are generally outside the security boundary unless the report demonstrates a real privilege-boundary bypass or shows how pi grants access that the local user did not already have. diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index df2d0bd6..2cf843d1 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -11,13 +11,15 @@ Edit directly or use `/settings` for common options. ## Project Trust -On interactive startup, pi asks before trusting a project folder that contains project-local inputs and has no saved decision in `~/.pi/agent/trust.json`. Trusting a project allows pi to read project instructions (`AGENTS.md`/`CLAUDE.md`), load `.pi/settings.json` and `.pi` resources, install missing project packages, and execute project extensions. +On interactive startup, pi asks before trusting a project folder that contains trust-gated project inputs and has no saved decision for the folder or a parent folder in `~/.pi/agent/trust.json`. Trusting a project allows pi to load `.pi/settings.json` and `.pi` resources, install missing project packages, and execute project extensions. -Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without a saved trust decision, they ignore project-local inputs unless `--approve`/`-a` is passed. Use `--no-approve`/`-na` to ignore project-local inputs for one run even when the project is trusted. +Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without an applicable saved trust decision, they use `defaultProjectTrust` from global settings: `ask` (default) and `never` ignore trust-gated project inputs, while `always` trusts them. Pass `--approve`/`-a` or `--no-approve`/`-na` to override project trust for one run. -`pi config` assumes project trust for that command so you can view and change project resource settings before starting a session. It does not save a trust decision; starting a session in that folder still prompts. Pass `--no-approve` to hide project-local inputs in `pi config`. +If no extension or saved decision applies, `defaultProjectTrust` controls the fallback behavior. Set it to `"ask"`, `"always"`, or `"never"` in `~/.pi/agent/settings.json`, or change it with `/settings`. -Use `/trust` in interactive mode to save a project trust decision for future sessions. It writes `~/.pi/agent/trust.json` only; the current session is not reloaded, so restart pi for changes to take effect. +`pi config` and package commands use the same project trust flow. Pass `--approve` to trust project-local settings for one command or `--no-approve` to ignore them. + +Use `/trust` in interactive mode to save a project trust decision for future sessions, including trust for the immediate parent folder. It writes `~/.pi/agent/trust.json` only; the current session is not reloaded, so restart pi for changes to take effect. ## All Settings @@ -50,8 +52,11 @@ Use `/trust` in interactive mode to save a project trust decision for future ses |---------|------|---------|-------------| | `theme` | string | `"dark"` | Theme name (`"dark"`, `"light"`, or custom) | | `quietStartup` | boolean | `false` | Hide startup header | +| `defaultProjectTrust` | string | `"ask"` | Fallback project trust behavior: `"ask"`, `"always"`, or `"never"`. Global setting only | | `collapseChangelog` | boolean | `false` | Show condensed changelog after updates | | `enableInstallTelemetry` | boolean | `true` | Send an anonymous install/update version ping after first install or changelog-detected updates. This does not control update checks | +| `enableAnalytics` | boolean | `false` | Opt-in analytics data sharing. Currently only asked for during the experimental first-time setup (`PI_EXPERIMENTAL=1`) | +| `trackingId` | string | - | Analytics tracking identifier, generated when `enableAnalytics` is turned on | | `doubleEscapeAction` | string | `"tree"` | Action for double-escape: `"tree"`, `"fork"`, or `"none"` | | `treeFilterMode` | string | `"default"` | Default filter for `/tree`: `"default"`, `"no-tools"`, `"user-only"`, `"labeled-only"`, `"all"` | | `editorPaddingX` | number | `0` | Horizontal padding for input editor (0-3) | diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index ad9b80e1..bb8a4c25 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -96,8 +96,8 @@ See [Sessions](sessions.md) and [Compaction](compaction.md) for details. Pi loads `AGENTS.md` or `CLAUDE.md` at startup from: - `~/.pi/agent/AGENTS.md` for global instructions -- parent directories, walking up from the current working directory when the project is trusted -- the current directory when the project is trusted +- parent directories, walking up from the current working directory +- the current directory Use context files for project conventions, commands, safety rules, and preferences. Disable loading with `--no-context-files` or `-nc`. @@ -112,13 +112,18 @@ Append to the default prompt without replacing it with `APPEND_SYSTEM.md` in eit ### Project Trust -On interactive startup, pi asks before trusting a project folder that contains project-local inputs and has no saved decision in `~/.pi/agent/trust.json`. Trusting a project allows pi to read project instructions (`AGENTS.md`/`CLAUDE.md`), load `.pi/settings.json` and `.pi` resources, install missing project packages, and execute project extensions. +On interactive startup, pi asks before trusting a project folder that contains project-local extensions or settings and has no saved decision for the folder or a parent folder in `~/.pi/agent/trust.json`. Trusting a project allows pi to load `.pi/settings.json` and `.pi` resources, install missing project packages, and execute project extensions. -Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without a saved trust decision, they ignore project-local inputs unless `--approve`/`-a` is passed. Use `--no-approve`/`-na` to ignore project-local inputs for one run even when the project is trusted. +Before the trust decision, pi loads only context files, user/global extensions, and CLI `-e` extensions so they can handle the `project_trust` event. Project-local extensions, project package-managed extensions, and project settings are loaded only after the project is trusted. This split also applies when switching to a session from a different cwd whose trust has not been resolved in the current process. -`pi config` assumes project trust for that command so you can view and change project resource settings before starting a session. It does not save a trust decision; starting a session in that folder still prompts. Pass `--no-approve` to hide project-local inputs in `pi config`. +Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without an applicable saved trust decision, they use `defaultProjectTrust` from global settings: `ask` (default) and `never` ignore trust-gated project inputs, while `always` trusts them. Pass `--approve`/`-a` or `--no-approve`/`-na` to override project trust for one run. + +If no extension or saved decision applies, `defaultProjectTrust` controls the fallback behavior. Set it to `"ask"`, `"always"`, or `"never"` in `~/.pi/agent/settings.json`, or change it with `/settings`. + +`pi config` and package commands use the same project trust flow. Pass `--approve` to trust project-local settings for one command or `--no-approve` to ignore them. + +Use `/trust` in interactive mode to save a project trust decision for future sessions, including trust for the immediate parent folder. It writes `~/.pi/agent/trust.json` only; the current session is not reloaded, so restart pi for changes to take effect. -Use `/trust` in interactive mode to save a project trust decision for future sessions. It writes `~/.pi/agent/trust.json` only; the current session is not reloaded, so restart pi for changes to take effect. ## Exporting and Sharing Sessions @@ -148,7 +153,7 @@ pi list # List installed packages pi config # Enable/disable package resources ``` -These commands manage pi packages, not the pi CLI installation. To uninstall pi itself, see [Quickstart](quickstart.md#uninstall). Project package commands accept `--approve`/`--no-approve` to trust or ignore project-local package settings for one command. +These commands manage pi packages, not the pi CLI installation. To uninstall pi itself, see [Quickstart](quickstart.md#uninstall). `pi config` and project package commands accept `--approve`/`--no-approve` to trust or ignore project-local settings for one command. See [Pi Packages](packages.md) for package sources and security notes. diff --git a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json index 71342f77..dc287898 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json +++ b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-custom-provider", - "version": "0.78.1", + "version": "0.79.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-custom-provider", - "version": "0.78.1", + "version": "0.79.1", "dependencies": { "@anthropic-ai/sdk": "^0.52.0" } diff --git a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json index 40e82831..4b1dd16b 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json +++ b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-custom-provider-anthropic", "private": true, - "version": "0.78.1", + "version": "0.79.1", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json b/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json index df981282..fba74f25 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json +++ b/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-custom-provider-gitlab-duo", "private": true, - "version": "0.78.1", + "version": "0.79.1", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/gondolin/package-lock.json b/packages/coding-agent/examples/extensions/gondolin/package-lock.json index 94a45117..b71b386c 100644 --- a/packages/coding-agent/examples/extensions/gondolin/package-lock.json +++ b/packages/coding-agent/examples/extensions/gondolin/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-gondolin", - "version": "0.78.1", + "version": "0.79.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-gondolin", - "version": "0.78.1", + "version": "0.79.1", "dependencies": { "@earendil-works/gondolin": "0.12.0" } diff --git a/packages/coding-agent/examples/extensions/gondolin/package.json b/packages/coding-agent/examples/extensions/gondolin/package.json index c6c68530..51448000 100644 --- a/packages/coding-agent/examples/extensions/gondolin/package.json +++ b/packages/coding-agent/examples/extensions/gondolin/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-gondolin", "private": true, - "version": "0.78.1", + "version": "0.79.1", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/sandbox/package-lock.json b/packages/coding-agent/examples/extensions/sandbox/package-lock.json index 493ce950..360c0def 100644 --- a/packages/coding-agent/examples/extensions/sandbox/package-lock.json +++ b/packages/coding-agent/examples/extensions/sandbox/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-sandbox", - "version": "1.8.1", + "version": "1.9.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-sandbox", - "version": "1.8.1", + "version": "1.9.1", "dependencies": { "@anthropic-ai/sandbox-runtime": "^0.0.26" } diff --git a/packages/coding-agent/examples/extensions/sandbox/package.json b/packages/coding-agent/examples/extensions/sandbox/package.json index 7614a82d..f285b6ce 100644 --- a/packages/coding-agent/examples/extensions/sandbox/package.json +++ b/packages/coding-agent/examples/extensions/sandbox/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-sandbox", "private": true, - "version": "1.8.1", + "version": "1.9.1", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/with-deps/package-lock.json b/packages/coding-agent/examples/extensions/with-deps/package-lock.json index 1c16ed45..ee75d24f 100644 --- a/packages/coding-agent/examples/extensions/with-deps/package-lock.json +++ b/packages/coding-agent/examples/extensions/with-deps/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-with-deps", - "version": "0.78.1", + "version": "0.79.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-with-deps", - "version": "0.78.1", + "version": "0.79.1", "dependencies": { "ms": "^2.1.3" }, diff --git a/packages/coding-agent/examples/extensions/with-deps/package.json b/packages/coding-agent/examples/extensions/with-deps/package.json index 03fc01e7..fab9f145 100644 --- a/packages/coding-agent/examples/extensions/with-deps/package.json +++ b/packages/coding-agent/examples/extensions/with-deps/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-with-deps", "private": true, - "version": "0.78.1", + "version": "0.79.1", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index c52c882b..9132d489 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -1,17 +1,17 @@ { "name": "@earendil-works/pi-coding-agent", - "version": "0.78.1", + "version": "0.79.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@earendil-works/pi-coding-agent", - "version": "0.78.1", + "version": "0.79.1", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.78.1", - "@earendil-works/pi-ai": "^0.78.1", - "@earendil-works/pi-tui": "^0.78.1", + "@earendil-works/pi-agent-core": "^0.79.1", + "@earendil-works/pi-ai": "^0.79.1", + "@earendil-works/pi-tui": "^0.79.1", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -473,11 +473,11 @@ } }, "node_modules/@earendil-works/pi-agent-core": { - "version": "0.78.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.78.1.tgz", + "version": "0.79.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.79.1.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.78.1", + "@earendil-works/pi-ai": "^0.79.1", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -487,8 +487,8 @@ } }, "node_modules/@earendil-works/pi-ai": { - "version": "0.78.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.78.1.tgz", + "version": "0.79.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.79.1.tgz", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -510,8 +510,8 @@ } }, "node_modules/@earendil-works/pi-tui": { - "version": "0.78.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.78.1.tgz", + "version": "0.79.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.79.1.tgz", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index d53608ab..b9381465 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-coding-agent", - "version": "0.78.1", + "version": "0.79.1", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "type": "module", "piConfig": { @@ -36,9 +36,9 @@ "prepublishOnly": "npm run clean && npm run build && npm run shrinkwrap" }, "dependencies": { - "@earendil-works/pi-agent-core": "^0.78.1", - "@earendil-works/pi-ai": "^0.78.1", - "@earendil-works/pi-tui": "^0.78.1", + "@earendil-works/pi-agent-core": "^0.79.1", + "@earendil-works/pi-ai": "^0.79.1", + "@earendil-works/pi-tui": "^0.79.1", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 0258c0c1..839c60e8 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -230,10 +230,8 @@ ${chalk.bold("Commands:")} ${APP_NAME} remove [-l] Remove extension source from settings ${APP_NAME} uninstall [-l] Alias for remove ${APP_NAME} update [source|self|pi] Update pi and installed extensions - ${APP_NAME} list [--approve|--no-approve] - List installed extensions from settings - ${APP_NAME} config [--no-approve] - Open TUI to enable/disable package resources + ${APP_NAME} list List installed extensions from settings + ${APP_NAME} config Open TUI to enable/disable package resources ${APP_NAME} --help Show help for install/remove/uninstall/update/list ${chalk.bold("Options:")} diff --git a/packages/coding-agent/src/cli/project-trust.ts b/packages/coding-agent/src/cli/project-trust.ts new file mode 100644 index 00000000..b27871a9 --- /dev/null +++ b/packages/coding-agent/src/cli/project-trust.ts @@ -0,0 +1,62 @@ +import chalk from "chalk"; +import type { ProjectTrustContext } from "../core/extensions/types.ts"; +import type { AppMode } from "../core/project-trust.ts"; +import type { SettingsManager } from "../core/settings-manager.ts"; +import { showStartupInput, showStartupSelector } from "./startup-ui.ts"; + +export function createProjectTrustContext(options: { + cwd: string; + mode: AppMode; + settingsManager: SettingsManager; + hasUI: boolean; +}): ProjectTrustContext { + return { + cwd: options.cwd, + mode: options.mode === "interactive" ? "tui" : options.mode, + hasUI: options.hasUI, + ui: { + select: async (title, selectOptions) => { + if (!options.hasUI) { + return undefined; + } + if (options.mode !== "interactive") { + return undefined; + } + return showStartupSelector( + options.settingsManager, + title, + selectOptions.map((option) => ({ label: option, value: option })), + ); + }, + confirm: async (title, message) => { + if (!options.hasUI) { + return false; + } + if (options.mode !== "interactive") { + return false; + } + return ( + (await showStartupSelector(options.settingsManager, `${title}\n${message}`, [ + { label: "Yes", value: true }, + { label: "No", value: false }, + ])) ?? false + ); + }, + input: async (title, placeholder) => { + if (!options.hasUI) { + return undefined; + } + if (options.mode !== "interactive") { + return undefined; + } + return showStartupInput(options.settingsManager, title, placeholder); + }, + notify: (message, type = "info") => { + if (options.mode !== "interactive") { + const color = type === "error" ? chalk.red : type === "warning" ? chalk.yellow : chalk.cyan; + console.error(color(message)); + } + }, + }, + }; +} diff --git a/packages/coding-agent/src/cli/startup-ui.ts b/packages/coding-agent/src/cli/startup-ui.ts new file mode 100644 index 00000000..5a9de203 --- /dev/null +++ b/packages/coding-agent/src/cli/startup-ui.ts @@ -0,0 +1,147 @@ +import { ProcessTerminal, setKeybindings, TUI } from "@earendil-works/pi-tui"; +import { existsSync } from "fs"; +import { ENV_AGENT_DIR, getSettingsPath } from "../config.ts"; +import { areExperimentalFeaturesEnabled } from "../core/experimental.ts"; +import { KeybindingsManager } from "../core/keybindings.ts"; +import type { SettingsManager } from "../core/settings-manager.ts"; +import { ExtensionInputComponent } from "../modes/interactive/components/extension-input.ts"; +import { ExtensionSelectorComponent } from "../modes/interactive/components/extension-selector.ts"; +import { + FirstTimeSetupComponent, + type FirstTimeSetupResult, +} from "../modes/interactive/components/first-time-setup.ts"; +import { detectTerminalBackground, initTheme, setTheme } from "../modes/interactive/theme/theme.ts"; + +function createStartupTui(settingsManager: SettingsManager): TUI { + initTheme(settingsManager.getTheme()); + setKeybindings(KeybindingsManager.create()); + const ui = new TUI(new ProcessTerminal(), settingsManager.getShowHardwareCursor()); + ui.setClearOnShrink(settingsManager.getClearOnShrink()); + return ui; +} + +async function clearStartupTui(ui: TUI): Promise { + ui.clear(); + ui.requestRender(); + await new Promise((resolve) => setTimeout(resolve, 25)); +} + +/** + * First-time setup runs when all of these hold: + * - experimental features are enabled (PI_EXPERIMENTAL=1) + * - the default agent directory is used (no custom agent dir override) + * - setup was not completed before (settings.json does not exist) + */ +export function shouldRunFirstTimeSetup(settingsPath: string = getSettingsPath()): boolean { + if (!areExperimentalFeaturesEnabled()) { + return false; + } + if (process.env[ENV_AGENT_DIR]) { + return false; + } + return !existsSync(settingsPath); +} + +export async function showStartupSelector( + settingsManager: SettingsManager, + title: string, + options: Array<{ label: string; value: T }>, +): Promise { + return new Promise((resolve) => { + const ui = createStartupTui(settingsManager); + + let settled = false; + const finish = async (result: T | undefined) => { + if (settled) { + return; + } + settled = true; + await clearStartupTui(ui); + ui.stop(); + resolve(result); + }; + + const selector = new ExtensionSelectorComponent( + title, + options.map((option) => option.label), + (option) => void finish(options.find((entry) => entry.label === option)?.value), + () => void finish(undefined), + { tui: ui }, + ); + ui.addChild(selector); + ui.setFocus(selector); + ui.start(); + }); +} + +/** Show the first-time setup dialog and persist the result */ +export async function showFirstTimeSetup(settingsManager: SettingsManager): Promise { + return new Promise((resolve) => { + const ui = createStartupTui(settingsManager); + + let settled = false; + const finish = async (result: FirstTimeSetupResult | undefined) => { + if (settled) { + return; + } + settled = true; + if (result) { + settingsManager.setTheme(result.theme); + settingsManager.setEnableAnalytics(result.shareAnalytics); + await settingsManager.flush(); + } + await clearStartupTui(ui); + ui.stop(); + resolve(); + }; + + const component = new FirstTimeSetupComponent({ + detectedTheme: detectTerminalBackground().theme, + onThemePreview: (themeName) => { + setTheme(themeName); + ui.invalidate(); + ui.requestRender(); + }, + onSubmit: (result) => void finish(result), + onCancel: () => void finish(undefined), + }); + ui.addChild(component); + ui.setFocus(component); + ui.start(); + }); +} + +export async function showStartupInput( + settingsManager: SettingsManager, + title: string, + placeholder?: string, +): Promise { + return new Promise((resolve) => { + const ui = createStartupTui(settingsManager); + + let settled = false; + const finish = async (result: string | undefined) => { + if (settled) { + return; + } + settled = true; + input.dispose(); + await clearStartupTui(ui); + ui.stop(); + resolve(result); + }; + + const input = new ExtensionInputComponent( + title, + placeholder, + (value) => void finish(value), + () => void finish(undefined), + { + tui: ui, + }, + ); + ui.addChild(input); + ui.setFocus(input); + ui.start(); + }); +} diff --git a/packages/coding-agent/src/core/agent-session-runtime.ts b/packages/coding-agent/src/core/agent-session-runtime.ts index be855ea4..7f29275a 100644 --- a/packages/coding-agent/src/core/agent-session-runtime.ts +++ b/packages/coding-agent/src/core/agent-session-runtime.ts @@ -232,7 +232,9 @@ export class AgentSessionRuntime { const previousSessionFile = this.session.sessionFile; const sessionDir = this.session.sessionManager.getSessionDir(); - const sessionManager = SessionManager.create(this.cwd, sessionDir); + const sessionManager = this.session.sessionManager.isPersisted() + ? SessionManager.create(this.cwd, sessionDir) + : SessionManager.inMemory(this.cwd); if (options?.parentSession) { sessionManager.newSession({ parentSession: options.parentSession }); } diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index de76a619..201b8084 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -1606,6 +1606,11 @@ export class AgentSession { // Queue Mode Management // ========================================================================= + private syncQueueModesFromSettings(): void { + this.agent.steeringMode = this.settingsManager.getSteeringMode(); + this.agent.followUpMode = this.settingsManager.getFollowUpMode(); + } + /** * Set steering message mode. * Saves to settings. @@ -2239,6 +2244,7 @@ export class AgentSession { { getModel: () => this.model, isIdle: () => !this.isStreaming, + isProjectTrusted: () => this.settingsManager.isProjectTrusted(), getSignal: () => this.agent.signal, abort: () => { if (this._extensionAbortHandler) { @@ -2430,6 +2436,7 @@ export class AgentSession { const previousFlagValues = this._extensionRunner.getFlagValues(); await emitSessionShutdownEvent(this._extensionRunner, { type: "session_shutdown", reason: "reload" }); await this.settingsManager.reload(); + this.syncQueueModesFromSettings(); resetApiProviders(); await this._resourceLoader.reload(); this._buildRuntime({ diff --git a/packages/coding-agent/src/core/experimental.ts b/packages/coding-agent/src/core/experimental.ts new file mode 100644 index 00000000..12d33c74 --- /dev/null +++ b/packages/coding-agent/src/core/experimental.ts @@ -0,0 +1,3 @@ +export function areExperimentalFeaturesEnabled(): boolean { + return process.env.PI_EXPERIMENTAL === "1"; +} diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index 3030b65d..9cb0e657 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -270,6 +270,7 @@ export class ExtensionRunner { private errorListeners: Set = new Set(); private getModel: () => Model | undefined = () => undefined; private isIdleFn: () => boolean = () => true; + private isProjectTrustedFn: () => boolean = () => true; private getSignalFn: () => AbortSignal | undefined = () => undefined; private waitForIdleFn: () => Promise = async () => {}; private abortFn: () => void = () => {}; @@ -330,6 +331,7 @@ export class ExtensionRunner { // Context actions (required) this.getModel = contextActions.getModel; this.isIdleFn = contextActions.isIdle; + this.isProjectTrustedFn = contextActions.isProjectTrusted; this.getSignalFn = contextActions.getSignal; this.abortFn = contextActions.abort; this.hasPendingMessagesFn = contextActions.hasPendingMessages; @@ -648,6 +650,10 @@ export class ExtensionRunner { runner.assertActive(); return runner.isIdleFn(); }, + isProjectTrusted: () => { + runner.assertActive(); + return runner.isProjectTrustedFn(); + }, get signal() { runner.assertActive(); return runner.getSignalFn(); diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 7575d8af..a869a55d 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -314,6 +314,8 @@ export interface ExtensionContext { model: Model | undefined; /** Whether the agent is idle (not streaming) */ isIdle(): boolean; + /** Whether project-local trust is active for this context. */ + isProjectTrusted(): boolean; /** The current abort signal, or undefined when the agent is not streaming. */ signal: AbortSignal | undefined; /** Abort the current agent operation */ @@ -1528,6 +1530,7 @@ export interface ExtensionActions { export interface ExtensionContextActions { getModel: () => Model | undefined; isIdle: () => boolean; + isProjectTrusted: () => boolean; getSignal: () => AbortSignal | undefined; abort: () => void; hasPendingMessages: () => boolean; diff --git a/packages/coding-agent/src/core/index.ts b/packages/coding-agent/src/core/index.ts index 71c45e9c..b7654f42 100644 --- a/packages/coding-agent/src/core/index.ts +++ b/packages/coding-agent/src/core/index.ts @@ -28,6 +28,7 @@ export { export { type BashExecutorOptions, type BashResult, executeBashWithOperations } from "./bash-executor.ts"; export type { CompactionResult } from "./compaction/index.ts"; export { createEventBus, type EventBus, type EventBusController } from "./event-bus.ts"; +export { areExperimentalFeaturesEnabled } from "./experimental.ts"; // Extensions system export { type AgentEndEvent, diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index 53b8ad11..c9afb7b2 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -340,9 +340,10 @@ export interface ResolveCliModelResult { export function resolveCliModel(options: { cliProvider?: string; cliModel?: string; + cliThinking?: string; modelRegistry: ModelRegistry; }): ResolveCliModelResult { - const { cliProvider, cliModel, modelRegistry } = options; + const { cliProvider, cliModel, cliThinking, modelRegistry } = options; if (!cliModel) { return { model: undefined, warning: undefined, error: undefined }; @@ -451,12 +452,28 @@ export function resolveCliModel(options: { } if (provider) { - const fallbackModel = buildFallbackModel(provider, pattern, availableModels); + // Parse thinking level suffix from the pattern before building the fallback model, + // but only when --thinking is not explicitly provided. + // e.g. "zai-org/GLM-5.1-FP8:high" → modelId="zai-org/GLM-5.1-FP8", fallbackThinking="high" + let fallbackPattern = pattern; + let fallbackThinking: ThinkingLevel | undefined; + if (!cliThinking) { + const lastColon = pattern.lastIndexOf(":"); + if (lastColon !== -1) { + const suffix = pattern.substring(lastColon + 1); + if (isValidThinkingLevel(suffix)) { + fallbackPattern = pattern.substring(0, lastColon); + fallbackThinking = suffix; + } + } + } + + const fallbackModel = buildFallbackModel(provider, fallbackPattern, availableModels); if (fallbackModel) { const fallbackWarning = warning - ? `${warning} Model "${pattern}" not found for provider "${provider}". Using custom model id.` - : `Model "${pattern}" not found for provider "${provider}". Using custom model id.`; - return { model: fallbackModel, thinkingLevel: undefined, warning: fallbackWarning, error: undefined }; + ? `${warning} Model "${fallbackPattern}" not found for provider "${provider}". Using custom model id.` + : `Model "${fallbackPattern}" not found for provider "${provider}". Using custom model id.`; + return { model: fallbackModel, thinkingLevel: fallbackThinking, warning: fallbackWarning, error: undefined }; } } diff --git a/packages/coding-agent/src/core/project-trust.ts b/packages/coding-agent/src/core/project-trust.ts new file mode 100644 index 00000000..c8b57250 --- /dev/null +++ b/packages/coding-agent/src/core/project-trust.ts @@ -0,0 +1,95 @@ +import { emitProjectTrustEvent } from "./extensions/runner.ts"; +import type { LoadExtensionsResult, ProjectTrustContext } from "./extensions/types.ts"; +import type { DefaultProjectTrust } from "./settings-manager.ts"; +import { + getProjectTrustOptions, + hasProjectTrustInputs, + type ProjectTrustOption, + type ProjectTrustStore, +} from "./trust-manager.ts"; + +export type AppMode = "interactive" | "print" | "json" | "rpc"; + +export interface ResolveProjectTrustedOptions { + cwd: string; + trustStore: ProjectTrustStore; + trustOverride?: boolean; + defaultProjectTrust?: DefaultProjectTrust; + extensionsResult?: LoadExtensionsResult; + projectTrustContext: ProjectTrustContext; + onExtensionError?: (message: string) => void; +} + +function formatProjectTrustPrompt(cwd: string): string { + return `Trust project folder?\n${cwd}\n\nThis allows pi to load .pi settings and resources, install missing project packages, and execute project extensions.`; +} + +async function selectProjectTrustOption( + cwd: string, + ctx: ProjectTrustContext, +): Promise { + const options = getProjectTrustOptions(cwd, { includeSessionOnly: true }); + const selected = await ctx.ui.select( + formatProjectTrustPrompt(cwd), + options.map((option) => option.label), + ); + return options.find((option) => option.label === selected); +} + +function saveProjectTrustPromptResult(trustStore: ProjectTrustStore, result: ProjectTrustOption): void { + if (result.updates.length > 0) { + trustStore.setMany(result.updates); + } +} + +export async function resolveProjectTrusted(options: ResolveProjectTrustedOptions): Promise { + if (options.trustOverride !== undefined) { + return options.trustOverride; + } + if (!hasProjectTrustInputs(options.cwd)) { + return true; + } + + if (options.extensionsResult) { + const { result, errors } = await emitProjectTrustEvent( + options.extensionsResult, + { type: "project_trust", cwd: options.cwd }, + options.projectTrustContext, + ); + for (const error of errors) { + options.onExtensionError?.(`Extension "${error.extensionPath}" project_trust error: ${error.error}`); + } + if (result) { + const trusted = result.trusted === "yes"; + if (result.remember === true) { + options.trustStore.set(options.cwd, trusted); + } + return trusted; + } + } + + const decision = options.trustStore.get(options.cwd); + if (decision !== null) { + return decision; + } + + switch (options.defaultProjectTrust ?? "ask") { + case "always": + return true; + case "never": + return false; + case "ask": + break; + } + + if (!options.projectTrustContext.hasUI) { + return false; + } + + const selected = await selectProjectTrustOption(options.cwd, options.projectTrustContext); + if (selected !== undefined) { + saveProjectTrustPromptResult(options.trustStore, selected); + return selected.trusted; + } + return false; +} diff --git a/packages/coding-agent/src/core/prompt-templates.ts b/packages/coding-agent/src/core/prompt-templates.ts index 581a34eb..6b5b1e24 100644 --- a/packages/coding-agent/src/core/prompt-templates.ts +++ b/packages/coding-agent/src/core/prompt-templates.ts @@ -59,46 +59,45 @@ export function parseCommandArgs(argsString: string): string[] { * Supports: * - $1, $2, ... for positional args * - $@ and $ARGUMENTS for all args + * - ${N:-default} for positional arg N with default when missing/empty * - ${@:N} for args from Nth onwards (bash-style slicing) * - ${@:N:L} for L args starting from Nth * - * Note: Replacement happens on the template string only. Argument values + * Note: Replacement happens on the template string only. Argument and default values * containing patterns like $1, $@, or $ARGUMENTS are NOT recursively substituted. */ export function substituteArgs(content: string, args: string[]): string { - let result = content; - - // Replace $1, $2, etc. with positional args FIRST (before wildcards) - // This prevents wildcard replacement values containing $ patterns from being re-substituted - result = result.replace(/\$(\d+)/g, (_, num) => { - const index = parseInt(num, 10) - 1; - return args[index] ?? ""; - }); - - // Replace ${@:start} or ${@:start:length} with sliced args (bash-style) - // Process BEFORE simple $@ to avoid conflicts - result = result.replace(/\$\{@:(\d+)(?::(\d+))?\}/g, (_, startStr, lengthStr) => { - let start = parseInt(startStr, 10) - 1; // Convert to 0-indexed (user provides 1-indexed) - // Treat 0 as 1 (bash convention: args start at 1) - if (start < 0) start = 0; - - if (lengthStr) { - const length = parseInt(lengthStr, 10); - return args.slice(start, start + length).join(" "); - } - return args.slice(start).join(" "); - }); - - // Pre-compute all args joined (optimization) const allArgs = args.join(" "); - // Replace $ARGUMENTS with all args joined (new syntax, aligns with Claude, Codex, OpenCode) - result = result.replace(/\$ARGUMENTS/g, allArgs); + return content.replace( + /\$\{(\d+):-([^}]*)\}|\$\{@:(\d+)(?::(\d+))?\}|\$(ARGUMENTS|@|\d+)/g, + (_match, defaultNum, defaultValue, sliceStart, sliceLength, simple) => { + if (defaultNum) { + const index = parseInt(defaultNum, 10) - 1; + const value = args[index]; + return value ? value : defaultValue; + } - // Replace $@ with all args joined (existing syntax) - result = result.replace(/\$@/g, allArgs); + if (sliceStart) { + let start = parseInt(sliceStart, 10) - 1; // Convert to 0-indexed (user provides 1-indexed) + // Treat 0 as 1 (bash convention: args start at 1) + if (start < 0) start = 0; - return result; + if (sliceLength) { + const length = parseInt(sliceLength, 10); + return args.slice(start, start + length).join(" "); + } + return args.slice(start).join(" "); + } + + if (simple === "ARGUMENTS" || simple === "@") { + return allArgs; + } + + const index = parseInt(simple, 10) - 1; + return args[index] ?? ""; + }, + ); } function loadTemplateFromFile(filePath: string, sourceInfo: SourceInfo): PromptTemplate | null { diff --git a/packages/coding-agent/src/core/resource-loader.ts b/packages/coding-agent/src/core/resource-loader.ts index 394679bb..b35787af 100644 --- a/packages/coding-agent/src/core/resource-loader.ts +++ b/packages/coding-agent/src/core/resource-loader.ts @@ -79,7 +79,6 @@ function loadContextFileFromDir(dir: string): { path: string; content: string } export function loadProjectContextFiles(options: { cwd: string; agentDir: string; - projectTrusted?: boolean; }): Array<{ path: string; content: string }> { const resolvedCwd = resolvePath(options.cwd); const resolvedAgentDir = resolvePath(options.agentDir); @@ -93,29 +92,27 @@ export function loadProjectContextFiles(options: { seenPaths.add(globalContext.path); } - if (options.projectTrusted !== false) { - const ancestorContextFiles: Array<{ path: string; content: string }> = []; + const ancestorContextFiles: Array<{ path: string; content: string }> = []; - let currentDir = resolvedCwd; - const root = resolve("/"); + let currentDir = resolvedCwd; + const root = resolve("/"); - while (true) { - const contextFile = loadContextFileFromDir(currentDir); - if (contextFile && !seenPaths.has(contextFile.path)) { - ancestorContextFiles.unshift(contextFile); - seenPaths.add(contextFile.path); - } - - if (currentDir === root) break; - - const parentDir = resolve(currentDir, ".."); - if (parentDir === currentDir) break; - currentDir = parentDir; + while (true) { + const contextFile = loadContextFileFromDir(currentDir); + if (contextFile && !seenPaths.has(contextFile.path)) { + ancestorContextFiles.unshift(contextFile); + seenPaths.add(contextFile.path); } - contextFiles.push(...ancestorContextFiles); + if (currentDir === root) break; + + const parentDir = resolve(currentDir, ".."); + if (parentDir === currentDir) break; + currentDir = parentDir; } + contextFiles.push(...ancestorContextFiles); + return contextFiles; } @@ -325,14 +322,18 @@ export class DefaultResourceLoader implements ResourceLoader { } } + async loadProjectTrustExtensions(): Promise { + // Force untrusted project settings for the bootstrap pass. This keeps project-local + // extensions/packages out while still loading user/global and temporary CLI extensions. + this.settingsManager.setProjectTrusted(false); + await this.settingsManager.reload(); + return this.loadCurrentExtensionSet({ includeInlineFactories: true }); + } + async reload(options?: ResourceLoaderReloadOptions): Promise { let preTrustExtensions: LoadExtensionsResult | undefined; if (options?.resolveProjectTrust) { - // Force untrusted project settings for the bootstrap pass. This keeps project-local - // extensions/packages out while still loading user/global and temporary CLI extensions. - this.settingsManager.setProjectTrusted(false); - await this.settingsManager.reload(); - preTrustExtensions = await this.loadCurrentExtensionSet({ includeInlineFactories: true }); + preTrustExtensions = await this.loadProjectTrustExtensions(); const projectTrusted = await options.resolveProjectTrust({ extensionsResult: preTrustExtensions }); this.settingsManager.setProjectTrusted(projectTrusted); } @@ -454,7 +455,6 @@ export class DefaultResourceLoader implements ResourceLoader { : loadProjectContextFiles({ cwd: this.cwd, agentDir: this.agentDir, - projectTrusted: this.settingsManager.isProjectTrusted(), }), }; const resolvedAgentsFiles = this.agentsFilesOverride ? this.agentsFilesOverride(agentsFiles) : agentsFiles; diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 2ef32d6d..8acd5998 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -1,4 +1,5 @@ import type { Transport } from "@earendil-works/pi-ai"; +import { randomUUID } from "crypto"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import { dirname, join } from "path"; import lockfile from "proper-lockfile"; @@ -57,6 +58,8 @@ export interface WarningSettings { anthropicExtraUsage?: boolean; // default: true } +export type DefaultProjectTrust = "ask" | "always" | "never"; + export type TransportSetting = Transport; /** @@ -89,10 +92,13 @@ export interface Settings { hideThinkingBlock?: boolean; shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows) quietStartup?: boolean; + defaultProjectTrust?: DefaultProjectTrust; // default: "ask"; global setting only shellCommandPrefix?: string; // Prefix prepended to every bash command (e.g., "shopt -s expand_aliases" for alias support) npmCommand?: string[]; // Command used for npm package lookup/install operations, argv-style (e.g., ["mise", "exec", "node@20", "--", "npm"]) collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full) enableInstallTelemetry?: boolean; // default: true - anonymous version/update ping after changelog-detected updates + enableAnalytics?: boolean; // default: false - opt-in analytics data sharing + trackingId?: string; // analytics tracking identifier, generated when analytics is enabled packages?: PackageSource[]; // Array of npm/git package sources (string or object with filtering) extensions?: string[]; // Array of local extension file paths or directories skills?: string[]; // Array of local skill file paths or directories @@ -853,6 +859,17 @@ export class SettingsManager { this.save(); } + getDefaultProjectTrust(): DefaultProjectTrust { + const value = this.globalSettings.defaultProjectTrust; + return value === "always" || value === "never" ? value : "ask"; + } + + setDefaultProjectTrust(defaultProjectTrust: DefaultProjectTrust): void { + this.globalSettings.defaultProjectTrust = defaultProjectTrust; + this.markModified("defaultProjectTrust"); + this.save(); + } + getShellCommandPrefix(): string | undefined { return this.settings.shellCommandPrefix; } @@ -893,6 +910,25 @@ export class SettingsManager { this.save(); } + getEnableAnalytics(): boolean { + return this.settings.enableAnalytics ?? false; + } + + getTrackingId(): string | undefined { + return this.settings.trackingId; + } + + /** Set the analytics opt-in preference; generates a tracking identifier on first opt-in */ + setEnableAnalytics(enabled: boolean): void { + this.globalSettings.enableAnalytics = enabled; + this.markModified("enableAnalytics"); + if (enabled && !this.globalSettings.trackingId) { + this.globalSettings.trackingId = randomUUID(); + this.markModified("trackingId"); + } + this.save(); + } + getPackages(): PackageSource[] { return [...(this.settings.packages ?? [])]; } diff --git a/packages/coding-agent/src/core/trust-manager.ts b/packages/coding-agent/src/core/trust-manager.ts index c86c8518..69f616ae 100644 --- a/packages/coding-agent/src/core/trust-manager.ts +++ b/packages/coding-agent/src/core/trust-manager.ts @@ -6,14 +6,87 @@ import { canonicalizePath, resolvePath } from "../utils/paths.ts"; export type ProjectTrustDecision = boolean | null; -type TrustFile = Record; +export interface ProjectTrustStoreEntry { + path: string; + decision: boolean; +} -const CONTEXT_FILE_NAMES = ["AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"]; +export interface ProjectTrustUpdate { + path: string; + decision: ProjectTrustDecision; +} + +export interface ProjectTrustOption { + label: string; + trusted: boolean; + updates: ProjectTrustUpdate[]; + savedPath?: string; +} + +type TrustFile = Record; function normalizeCwd(cwd: string): string { return canonicalizePath(resolvePath(cwd)); } +function findNearestTrustEntry(data: TrustFile, cwd: string): ProjectTrustStoreEntry | null { + let currentDir = normalizeCwd(cwd); + while (true) { + const value = data[currentDir]; + if (value === true || value === false) { + return { path: currentDir, decision: value }; + } + + const parentDir = dirname(currentDir); + if (parentDir === currentDir) { + return null; + } + currentDir = parentDir; + } +} + +export function getProjectTrustPath(cwd: string): string { + return normalizeCwd(cwd); +} + +export function getProjectTrustParentPath(cwd: string): string | undefined { + const trustPath = getProjectTrustPath(cwd); + const parentDir = dirname(trustPath); + return parentDir === trustPath ? undefined : parentDir; +} + +export function getProjectTrustOptions(cwd: string, options?: { includeSessionOnly?: boolean }): ProjectTrustOption[] { + const trustPath = getProjectTrustPath(cwd); + const trustOptions: ProjectTrustOption[] = [ + { label: "Trust", trusted: true, updates: [{ path: trustPath, decision: true }], savedPath: trustPath }, + ]; + const parentPath = getProjectTrustParentPath(cwd); + if (parentPath !== undefined) { + trustOptions.push({ + label: `Trust parent folder (${parentPath})`, + trusted: true, + updates: [ + { path: parentPath, decision: true }, + { path: trustPath, decision: null }, + ], + savedPath: parentPath, + }); + } + if (options?.includeSessionOnly) { + trustOptions.push({ label: "Trust (this session only)", trusted: true, updates: [] }); + } + trustOptions.push({ + label: "Do not trust", + trusted: false, + updates: [{ path: trustPath, decision: false }], + savedPath: trustPath, + }); + if (options?.includeSessionOnly) { + trustOptions.push({ label: "Do not trust (this session only)", trusted: false, updates: [] }); + } + return trustOptions; +} + function readTrustFile(path: string): TrustFile { if (!existsSync(path)) { return {}; @@ -105,11 +178,6 @@ export function hasProjectTrustInputs(cwd: string): boolean { } while (true) { - for (const filename of CONTEXT_FILE_NAMES) { - if (existsSync(join(currentDir, filename))) { - return true; - } - } if (existsSync(join(currentDir, ".agents", "skills"))) { return true; } @@ -130,21 +198,30 @@ export class ProjectTrustStore { } get(cwd: string): ProjectTrustDecision { + return this.getEntry(cwd)?.decision ?? null; + } + + getEntry(cwd: string): ProjectTrustStoreEntry | null { return withTrustFileLock(this.trustPath, () => { const data = readTrustFile(this.trustPath); - const value = data[normalizeCwd(cwd)]; - return value === true || value === false ? value : null; + return findNearestTrustEntry(data, cwd); }); } set(cwd: string, decision: ProjectTrustDecision): void { + this.setMany([{ path: cwd, decision }]); + } + + setMany(decisions: ProjectTrustUpdate[]): void { withTrustFileLock(this.trustPath, () => { const data = readTrustFile(this.trustPath); - const key = normalizeCwd(cwd); - if (decision === null) { - delete data[key]; - } else { - data[key] = decision; + for (const { path, decision } of decisions) { + const key = normalizeCwd(path); + if (decision === null) { + delete data[key]; + } else { + data[key] = decision; + } } writeTrustFile(this.trustPath, data); }); diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 398f5430..958c7ebb 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -220,6 +220,7 @@ export { } from "./core/session-manager.ts"; export { type CompactionSettings, + type DefaultProjectTrust, type ImageSettings, type PackageSource, type RetrySettings, @@ -287,7 +288,13 @@ export { type WriteToolOptions, withFileMutationQueue, } from "./core/tools/index.ts"; -export { hasProjectTrustInputs, type ProjectTrustDecision, ProjectTrustStore } from "./core/trust-manager.ts"; +export { + hasProjectTrustInputs, + type ProjectTrustDecision, + ProjectTrustStore, + type ProjectTrustStoreEntry, + type ProjectTrustUpdate, +} from "./core/trust-manager.ts"; // Main entry point export { type MainOptions, main } from "./main.ts"; // Run modes for programmatic SDK usage diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 335d814a..0bc24685 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -7,13 +7,14 @@ import { createInterface } from "node:readline"; import { type ImageContent, modelsAreEqual } from "@earendil-works/pi-ai"; -import { ProcessTerminal, setKeybindings, TUI } from "@earendil-works/pi-tui"; import chalk from "chalk"; import { type Args, type Mode, parseArgs, printHelp } from "./cli/args.ts"; import { processFileArguments } from "./cli/file-processor.ts"; import { buildInitialMessage } from "./cli/initial-message.ts"; import { listModels } from "./cli/list-models.ts"; +import { createProjectTrustContext } from "./cli/project-trust.ts"; import { selectSession } from "./cli/session-picker.ts"; +import { shouldRunFirstTimeSetup, showFirstTimeSetup, showStartupSelector } from "./cli/startup-ui.ts"; import { ENV_SESSION_DIR, expandTildePath, getAgentDir, getPackageDir, VERSION } from "./config.ts"; import { type CreateAgentSessionRuntimeFactory, createAgentSessionRuntime } from "./core/agent-session-runtime.ts"; import { @@ -24,13 +25,12 @@ import { import { formatNoModelsAvailableMessage } from "./core/auth-guidance.ts"; import { AuthStorage } from "./core/auth-storage.ts"; import { exportFromFile } from "./core/export-html/index.ts"; -import { emitProjectTrustEvent } from "./core/extensions/runner.ts"; -import type { ExtensionFactory, LoadExtensionsResult, ProjectTrustContext } from "./core/extensions/types.ts"; +import type { ExtensionFactory } from "./core/extensions/types.ts"; import { configureHttpDispatcher } from "./core/http-dispatcher.ts"; -import { KeybindingsManager } from "./core/keybindings.ts"; import type { ModelRegistry } from "./core/model-registry.ts"; import { resolveCliModel, resolveModelScope, type ScopedModel } from "./core/model-resolver.ts"; import { restoreStdout, takeOverStdout } from "./core/output-guard.ts"; +import { type AppMode, resolveProjectTrusted } from "./core/project-trust.ts"; import type { CreateAgentSessionOptions } from "./core/sdk.ts"; import { formatMissingSessionCwdPrompt, @@ -44,8 +44,6 @@ import { printTimings, resetTimings, time } from "./core/timings.ts"; import { hasProjectTrustInputs, ProjectTrustStore } from "./core/trust-manager.ts"; import { runMigrations, showDeprecationWarnings } from "./migrations.ts"; import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.ts"; -import { ExtensionInputComponent } from "./modes/interactive/components/extension-input.ts"; -import { ExtensionSelectorComponent } from "./modes/interactive/components/extension-selector.ts"; import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.ts"; import { handleConfigCommand, handlePackageCommand } from "./package-manager-cli.ts"; import { isLocalPath, normalizePath, resolvePath } from "./utils/paths.ts"; @@ -97,16 +95,14 @@ function isTruthyEnvFlag(value: string | undefined): boolean { return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes"; } -type AppMode = "interactive" | "print" | "json" | "rpc"; - -function resolveAppMode(parsed: Args, stdinIsTTY: boolean): AppMode { +function resolveAppMode(parsed: Args, stdinIsTTY: boolean, stdoutIsTTY: boolean): AppMode { if (parsed.mode === "rpc") { return "rpc"; } if (parsed.mode === "json") { return "json"; } - if (parsed.print || !stdinIsTTY) { + if (parsed.print || !stdinIsTTY || !stdoutIsTTY) { return "print"; } return "interactive"; @@ -116,6 +112,10 @@ function toPrintOutputMode(appMode: AppMode): Exclude { return appMode === "json" ? "json" : "text"; } +function isPlainRuntimeMetadataCommand(parsed: Args): boolean { + return !parsed.print && parsed.mode === undefined && (parsed.help === true || parsed.listModels !== undefined); +} + async function prepareInitialMessage( parsed: Args, autoResizeImages: boolean, @@ -360,6 +360,7 @@ function buildSessionOptions( const resolved = resolveCliModel({ cliProvider: parsed.provider, cliModel: parsed.model, + cliThinking: parsed.thinking, modelRegistry, }); if (resolved.warning) { @@ -439,87 +440,6 @@ function resolveCliPaths(cwd: string, paths: string[] | undefined): string[] | u return paths?.map((value) => (isLocalPath(value) ? resolvePath(value, cwd) : value)); } -function createStartupTui(settingsManager: SettingsManager): TUI { - initTheme(settingsManager.getTheme()); - setKeybindings(KeybindingsManager.create()); - const ui = new TUI(new ProcessTerminal(), settingsManager.getShowHardwareCursor()); - ui.setClearOnShrink(settingsManager.getClearOnShrink()); - return ui; -} - -async function clearStartupTui(ui: TUI): Promise { - ui.clear(); - ui.requestRender(); - await new Promise((resolve) => setTimeout(resolve, 25)); -} - -async function showStartupSelector( - settingsManager: SettingsManager, - title: string, - options: Array<{ label: string; value: T }>, -): Promise { - return new Promise((resolve) => { - const ui = createStartupTui(settingsManager); - - let settled = false; - const finish = async (result: T | undefined) => { - if (settled) { - return; - } - settled = true; - await clearStartupTui(ui); - ui.stop(); - resolve(result); - }; - - const selector = new ExtensionSelectorComponent( - title, - options.map((option) => option.label), - (option) => void finish(options.find((entry) => entry.label === option)?.value), - () => void finish(undefined), - { tui: ui }, - ); - ui.addChild(selector); - ui.setFocus(selector); - ui.start(); - }); -} - -async function showStartupInput( - settingsManager: SettingsManager, - title: string, - placeholder?: string, -): Promise { - return new Promise((resolve) => { - const ui = createStartupTui(settingsManager); - - let settled = false; - const finish = async (result: string | undefined) => { - if (settled) { - return; - } - settled = true; - input.dispose(); - await clearStartupTui(ui); - ui.stop(); - resolve(result); - }; - - const input = new ExtensionInputComponent( - title, - placeholder, - (value) => void finish(value), - () => void finish(undefined), - { - tui: ui, - }, - ); - ui.addChild(input); - ui.setFocus(input); - ui.start(); - }); -} - async function promptForMissingSessionCwd( issue: SessionCwdIssue, settingsManager: SettingsManager, @@ -530,160 +450,6 @@ async function promptForMissingSessionCwd( ]); } -interface ProjectTrustPromptResult { - trusted: boolean; - remember: boolean; -} - -const PROJECT_TRUST_PROMPT_OPTIONS: Array<{ label: string; value: ProjectTrustPromptResult }> = [ - { label: "Trust", value: { trusted: true, remember: true } }, - { label: "Trust (this session only)", value: { trusted: true, remember: false } }, - { label: "Do not trust", value: { trusted: false, remember: true } }, - { label: "Do not trust (this session only)", value: { trusted: false, remember: false } }, -]; - -function formatProjectTrustPrompt(cwd: string): string { - return `Trust project folder?\n${cwd}\n\nThis allows pi to read project instructions (AGENTS.md/CLAUDE.md), load .pi settings and resources, install missing project packages, and execute project extensions.`; -} - -async function promptForProjectTrust( - cwd: string, - settingsManager: SettingsManager, -): Promise { - return showStartupSelector(settingsManager, formatProjectTrustPrompt(cwd), PROJECT_TRUST_PROMPT_OPTIONS); -} - -async function promptForProjectTrustWithContext( - cwd: string, - ctx: ProjectTrustContext, -): Promise { - const selected = await ctx.ui.select( - formatProjectTrustPrompt(cwd), - PROJECT_TRUST_PROMPT_OPTIONS.map((option) => option.label), - ); - return PROJECT_TRUST_PROMPT_OPTIONS.find((option) => option.label === selected)?.value; -} - -function createProjectTrustContext(options: { - cwd: string; - mode: AppMode; - settingsManager: SettingsManager; - hasUI: boolean; -}): ProjectTrustContext { - return { - cwd: options.cwd, - mode: options.mode === "interactive" ? "tui" : options.mode, - hasUI: options.hasUI, - ui: { - select: async (title, selectOptions) => { - if (!options.hasUI) { - return undefined; - } - if (options.mode !== "interactive") { - return undefined; - } - return showStartupSelector( - options.settingsManager, - title, - selectOptions.map((option) => ({ label: option, value: option })), - ); - }, - confirm: async (title, message) => { - if (!options.hasUI) { - return false; - } - if (options.mode !== "interactive") { - return false; - } - return ( - (await showStartupSelector(options.settingsManager, `${title}\n${message}`, [ - { label: "Yes", value: true }, - { label: "No", value: false }, - ])) ?? false - ); - }, - input: async (title, placeholder) => { - if (!options.hasUI) { - return undefined; - } - if (options.mode !== "interactive") { - return undefined; - } - return showStartupInput(options.settingsManager, title, placeholder); - }, - notify: (message, type = "info") => { - if (options.mode !== "interactive") { - const color = type === "error" ? chalk.red : type === "warning" ? chalk.yellow : chalk.cyan; - console.error(color(message)); - } - }, - }, - }; -} - -async function resolveProjectTrusted(options: { - cwd: string; - trustStore: ProjectTrustStore; - trustOverride?: boolean; - appMode: AppMode; - settingsManagerForPrompt: SettingsManager; - extensionsResult?: LoadExtensionsResult; - projectTrustContext?: ProjectTrustContext; - onExtensionError?: (message: string) => void; -}): Promise { - if (options.trustOverride !== undefined) { - return options.trustOverride; - } - if (!hasProjectTrustInputs(options.cwd)) { - return true; - } - - if (options.extensionsResult && options.projectTrustContext) { - const { result, errors } = await emitProjectTrustEvent( - options.extensionsResult, - { type: "project_trust", cwd: options.cwd }, - options.projectTrustContext, - ); - for (const error of errors) { - options.onExtensionError?.(`Extension "${error.extensionPath}" project_trust error: ${error.error}`); - } - if (result) { - const trusted = result.trusted === "yes"; - if (result.remember === true) { - options.trustStore.set(options.cwd, trusted); - } - return trusted; - } - } - - const decision = options.trustStore.get(options.cwd); - if (decision !== null) { - return decision; - } - if (options.projectTrustContext?.hasUI) { - const selected = await promptForProjectTrustWithContext(options.cwd, options.projectTrustContext); - if (selected !== undefined) { - if (selected.remember) { - options.trustStore.set(options.cwd, selected.trusted); - } - return selected.trusted; - } - return false; - } - if (options.appMode !== "interactive") { - return false; - } - - const selected = await promptForProjectTrust(options.cwd, options.settingsManagerForPrompt); - if (selected !== undefined) { - if (selected.remember) { - options.trustStore.set(options.cwd, selected.trusted); - } - return selected.trusted; - } - return false; -} - export interface MainOptions { extensionFactories?: ExtensionFactory[]; } @@ -700,11 +466,11 @@ export async function main(args: string[], options?: MainOptions) { cleanupWindowsSelfUpdateQuarantine(getPackageDir()); } - if (await handlePackageCommand(args)) { + if (await handlePackageCommand(args, { extensionFactories: options?.extensionFactories })) { return; } - if (await handleConfigCommand(args)) { + if (await handleConfigCommand(args, { extensionFactories: options?.extensionFactories })) { return; } @@ -719,11 +485,6 @@ export async function main(args: string[], options?: MainOptions) { } } time("parseArgs"); - let appMode = resolveAppMode(parsed, process.stdin.isTTY); - const shouldTakeOverStdout = appMode !== "interactive"; - if (shouldTakeOverStdout) { - takeOverStdout(); - } if (parsed.version) { console.log(VERSION); @@ -744,6 +505,12 @@ export async function main(args: string[], options?: MainOptions) { process.exit(0); } + let appMode = resolveAppMode(parsed, process.stdin.isTTY, process.stdout.isTTY); + const shouldTakeOverStdout = appMode !== "interactive" && !isPlainRuntimeMetadataCommand(parsed); + if (shouldTakeOverStdout) { + takeOverStdout(); + } + if (parsed.mode === "rpc" && parsed.fileArgs.length > 0) { console.error(chalk.red("Error: @file arguments are not supported in RPC mode")); process.exit(1); @@ -761,6 +528,13 @@ export async function main(args: string[], options?: MainOptions) { const startupSettingsManager = SettingsManager.create(cwd, agentDir); reportDiagnostics(collectSettingsDiagnostics(startupSettingsManager, "startup session lookup")); + // Experimental first-time setup: theme choice and analytics opt-in. + // Runs before any runtime services are created so the chosen settings apply everywhere. + if (appMode === "interactive" && !parsed.help && parsed.listModels === undefined && shouldRunFirstTimeSetup()) { + await showFirstTimeSetup(startupSettingsManager); + time("firstTimeSetup"); + } + // Decide the final runtime cwd before creating cwd-bound runtime services. // --session and --resume may select a session from another project, so project-local // settings, resources, provider registrations, and models must be resolved only after @@ -837,8 +611,7 @@ export async function main(args: string[], options?: MainOptions) { cwd, trustStore, trustOverride: parsed.projectTrustOverride, - appMode: isInitialRuntime ? trustPromptMode : "print", - settingsManagerForPrompt: startupSettingsManager, + defaultProjectTrust: startupSettingsManager.getDefaultProjectTrust(), extensionsResult, projectTrustContext: projectTrustContext ?? diff --git a/packages/coding-agent/src/migrations.ts b/packages/coding-agent/src/migrations.ts index eb07f220..5cce43b8 100644 --- a/packages/coding-agent/src/migrations.ts +++ b/packages/coding-agent/src/migrations.ts @@ -144,47 +144,52 @@ function migrateModelsJsonConfigValues(agentDir: string): ConfigValueMigration[] const modelsPath = join(agentDir, "models.json"); if (!existsSync(modelsPath)) return []; - const parsed = JSON.parse(stripJsonComments(readFileSync(modelsPath, "utf-8"))) as unknown; - if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return []; - const modelsData = parsed as Record; - const providers = modelsData.providers; - if (typeof providers !== "object" || providers === null || Array.isArray(providers)) return []; + try { + const parsed = JSON.parse(stripJsonComments(readFileSync(modelsPath, "utf-8"))) as unknown; + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return []; + const modelsData = parsed as Record; + const providers = modelsData.providers; + if (typeof providers !== "object" || providers === null || Array.isArray(providers)) return []; - const migrations: ConfigValueMigration[] = []; - for (const [provider, providerConfig] of Object.entries(providers)) { - if (typeof providerConfig !== "object" || providerConfig === null || Array.isArray(providerConfig)) continue; - const providerRecord = providerConfig as Record; - const providerLocation = `models.json.providers[${JSON.stringify(provider)}]`; - migrateStringProperty(providerRecord, "apiKey", `${providerLocation}.apiKey`, migrations); - migrateHeadersConfig(providerRecord.headers, `${providerLocation}.headers`, migrations); + const migrations: ConfigValueMigration[] = []; + for (const [provider, providerConfig] of Object.entries(providers)) { + if (typeof providerConfig !== "object" || providerConfig === null || Array.isArray(providerConfig)) continue; + const providerRecord = providerConfig as Record; + const providerLocation = `models.json.providers[${JSON.stringify(provider)}]`; + migrateStringProperty(providerRecord, "apiKey", `${providerLocation}.apiKey`, migrations); + migrateHeadersConfig(providerRecord.headers, `${providerLocation}.headers`, migrations); - if (Array.isArray(providerRecord.models)) { - for (let index = 0; index < providerRecord.models.length; index++) { - const modelConfig = providerRecord.models[index]; - if (typeof modelConfig !== "object" || modelConfig === null || Array.isArray(modelConfig)) continue; - const modelRecord = modelConfig as Record; - const modelKey = typeof modelRecord.id === "string" ? JSON.stringify(modelRecord.id) : String(index); - migrateHeadersConfig(modelRecord.headers, `${providerLocation}.models[${modelKey}].headers`, migrations); + if (Array.isArray(providerRecord.models)) { + for (let index = 0; index < providerRecord.models.length; index++) { + const modelConfig = providerRecord.models[index]; + if (typeof modelConfig !== "object" || modelConfig === null || Array.isArray(modelConfig)) continue; + const modelRecord = modelConfig as Record; + const modelKey = typeof modelRecord.id === "string" ? JSON.stringify(modelRecord.id) : String(index); + migrateHeadersConfig(modelRecord.headers, `${providerLocation}.models[${modelKey}].headers`, migrations); + } + } + + const modelOverrides = providerRecord.modelOverrides; + if (typeof modelOverrides === "object" && modelOverrides !== null && !Array.isArray(modelOverrides)) { + for (const [modelId, modelOverride] of Object.entries(modelOverrides)) { + if (typeof modelOverride !== "object" || modelOverride === null || Array.isArray(modelOverride)) + continue; + const modelOverrideRecord = modelOverride as Record; + migrateHeadersConfig( + modelOverrideRecord.headers, + `${providerLocation}.modelOverrides[${JSON.stringify(modelId)}].headers`, + migrations, + ); + } } } - const modelOverrides = providerRecord.modelOverrides; - if (typeof modelOverrides === "object" && modelOverrides !== null && !Array.isArray(modelOverrides)) { - for (const [modelId, modelOverride] of Object.entries(modelOverrides)) { - if (typeof modelOverride !== "object" || modelOverride === null || Array.isArray(modelOverride)) continue; - const modelOverrideRecord = modelOverride as Record; - migrateHeadersConfig( - modelOverrideRecord.headers, - `${providerLocation}.modelOverrides[${JSON.stringify(modelId)}].headers`, - migrations, - ); - } - } + if (migrations.length === 0) return []; + writeFileSync(modelsPath, `${JSON.stringify(parsed, null, 2)}\n`, "utf-8"); + return migrations; + } catch { + return []; } - - if (migrations.length === 0) return []; - writeFileSync(modelsPath, `${JSON.stringify(parsed, null, 2)}\n`, "utf-8"); - return migrations; } function migrateExplicitEnvVarConfigValues(): void { diff --git a/packages/coding-agent/src/modes/interactive/components/first-time-setup.ts b/packages/coding-agent/src/modes/interactive/components/first-time-setup.ts new file mode 100644 index 00000000..6ad8ddba --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/first-time-setup.ts @@ -0,0 +1,145 @@ +import { Container, getKeybindings, Spacer, Text } from "@earendil-works/pi-tui"; +import { APP_NAME } from "../../../config.ts"; +import { type TerminalTheme, theme } from "../theme/theme.ts"; +import { DynamicBorder } from "./dynamic-border.ts"; +import { keyHint, rawKeyHint } from "./keybinding-hints.ts"; + +export interface FirstTimeSetupResult { + theme: TerminalTheme; + shareAnalytics: boolean; +} + +export interface FirstTimeSetupOptions { + detectedTheme: TerminalTheme; + onThemePreview: (themeName: TerminalTheme) => void; + onSubmit: (result: FirstTimeSetupResult) => void; + onCancel: () => void; +} + +const THEME_OPTIONS: Array<{ value: TerminalTheme; label: string }> = [ + { value: "dark", label: "Dark" }, + { value: "light", label: "Light" }, +]; + +const ANALYTICS_OPTIONS: Array<{ value: boolean; label: string }> = [ + { value: true, label: "Share anonymous usage data" }, + { value: false, label: "Don't share" }, +]; + +const SETUP_LOGO_LINES = ["██████", "██ ██", "████ ██", "██ ██"]; + +/** First-time setup dialog: theme choice and analytics opt-in. */ +export class FirstTimeSetupComponent extends Container { + private step: "theme" | "analytics" = "theme"; + private themeIndex: number; + private analyticsIndex = 0; + private readonly options: FirstTimeSetupOptions; + + constructor(options: FirstTimeSetupOptions) { + super(); + this.options = options; + this.themeIndex = Math.max( + 0, + THEME_OPTIONS.findIndex((option) => option.value === options.detectedTheme), + ); + this.update(); + } + + // Rebuild the whole dialog on every change so theme previews recolor all text. + private update(): void { + this.clear(); + this.addChild(new DynamicBorder()); + this.addChild(new Spacer(1)); + this.addChild(new Text(theme.fg("accent", SETUP_LOGO_LINES.join("\n")), 1, 0)); + this.addChild(new Spacer(1)); + this.addChild( + new Text(theme.fg("accent", theme.bold(`Welcome to ${APP_NAME}, the minimal coding agent.`)), 1, 0), + ); + this.addChild(new Spacer(1)); + + if (this.step === "theme") { + this.addChild(new Text(theme.fg("text", "Pick a theme."), 1, 0)); + this.addChild(new Text(theme.fg("muted", `Detected system appearance: ${this.options.detectedTheme}`), 1, 0)); + this.addChild(new Spacer(1)); + this.addOptionList( + THEME_OPTIONS.map((option) => option.label), + this.themeIndex, + ); + } else { + this.addChild(new Text(theme.fg("text", `Help improve ${APP_NAME} by sharing anonymous usage data?`), 1, 0)); + this.addChild( + new Text( + theme.fg( + "muted", + "Opting in stores a tracking identifier in settings.json and enables anonymous\nusage analytics. You can change this at any time in settings.json.", + ), + 1, + 0, + ), + ); + this.addChild(new Spacer(1)); + this.addOptionList( + ANALYTICS_OPTIONS.map((option) => option.label), + this.analyticsIndex, + ); + } + + this.addChild(new Spacer(1)); + this.addChild( + new Text( + rawKeyHint("↑↓", "navigate") + + " " + + keyHint("tui.select.confirm", this.step === "theme" ? "continue" : "finish") + + " " + + keyHint("tui.select.cancel", "skip setup"), + 1, + 0, + ), + ); + this.addChild(new Spacer(1)); + this.addChild(new DynamicBorder()); + } + + private addOptionList(labels: string[], selectedIndex: number): void { + for (let i = 0; i < labels.length; i++) { + const isSelected = i === selectedIndex; + const prefix = isSelected ? theme.fg("accent", "→ ") : " "; + const label = isSelected ? theme.fg("accent", labels[i]) : theme.fg("text", labels[i]); + this.addChild(new Text(`${prefix}${label}`, 1, 0)); + } + } + + private moveSelection(delta: number): void { + if (this.step === "theme") { + const next = Math.max(0, Math.min(THEME_OPTIONS.length - 1, this.themeIndex + delta)); + if (next !== this.themeIndex) { + this.themeIndex = next; + this.options.onThemePreview(THEME_OPTIONS[this.themeIndex].value); + } + } else { + this.analyticsIndex = Math.max(0, Math.min(ANALYTICS_OPTIONS.length - 1, this.analyticsIndex + delta)); + } + this.update(); + } + + handleInput(keyData: string): void { + const kb = getKeybindings(); + if (kb.matches(keyData, "tui.select.up") || keyData === "k") { + this.moveSelection(-1); + } else if (kb.matches(keyData, "tui.select.down") || keyData === "j") { + this.moveSelection(1); + } else if (kb.matches(keyData, "tui.select.confirm") || keyData === "\n") { + if (this.step === "theme") { + this.step = "analytics"; + this.update(); + } else { + this.options.onSubmit({ + theme: THEME_OPTIONS[this.themeIndex].value, + shareAnalytics: ANALYTICS_OPTIONS[this.analyticsIndex].value, + }); + } + } else if (kb.matches(keyData, "tui.select.cancel")) { + this.options.onCancel(); + } + } +} diff --git a/packages/coding-agent/src/modes/interactive/components/index.ts b/packages/coding-agent/src/modes/interactive/components/index.ts index fa47d642..38c2b987 100644 --- a/packages/coding-agent/src/modes/interactive/components/index.ts +++ b/packages/coding-agent/src/modes/interactive/components/index.ts @@ -13,6 +13,11 @@ export { DynamicBorder } from "./dynamic-border.ts"; export { ExtensionEditorComponent } from "./extension-editor.ts"; export { ExtensionInputComponent } from "./extension-input.ts"; export { ExtensionSelectorComponent } from "./extension-selector.ts"; +export { + FirstTimeSetupComponent, + type FirstTimeSetupOptions, + type FirstTimeSetupResult, +} from "./first-time-setup.ts"; export { FooterComponent } from "./footer.ts"; export { keyHint, keyText, rawKeyHint } from "./keybinding-hints.ts"; export { LoginDialogComponent } from "./login-dialog.ts"; diff --git a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts index 84d9a2c1..958db6d6 100644 --- a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts +++ b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts @@ -56,7 +56,9 @@ export class LoginDialogComponent extends Container implements Focusable { this.input = new Input(); this.input.onSubmit = () => { if (this.inputResolver) { - this.inputResolver(this.input.getValue()); + const value = this.input.getValue(); + this.replaceInputWithSubmittedText(value); + this.inputResolver(value); this.inputResolver = undefined; this.inputRejecter = undefined; } @@ -73,6 +75,12 @@ export class LoginDialogComponent extends Container implements Focusable { return this.abortController.signal; } + private replaceInputWithSubmittedText(value: string): void { + this.contentContainer.children = this.contentContainer.children.map((child) => + child === this.input ? new Text(`> ${value}`, 0, 0) : child, + ); + } + private cancel(): void { this.abortController.abort(); if (this.inputRejecter) { @@ -128,6 +136,7 @@ export class LoginDialogComponent extends Container implements Focusable { * Show input for manual code/URL entry (for callback server providers) */ showManualInput(prompt: string): Promise { + this.input.setValue(""); this.contentContainer.addChild(new Spacer(1)); this.contentContainer.addChild(new Text(theme.fg("dim", prompt), 1, 0)); this.contentContainer.addChild(this.input); diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index 7d210028..39d25f80 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -12,7 +12,7 @@ import { Text, } from "@earendil-works/pi-tui"; import { formatHttpIdleTimeoutMs, HTTP_IDLE_TIMEOUT_CHOICES } from "../../../core/http-dispatcher.ts"; -import type { WarningSettings } from "../../../core/settings-manager.ts"; +import type { DefaultProjectTrust, WarningSettings } from "../../../core/settings-manager.ts"; import { getSelectListTheme, getSettingsListTheme, theme } from "../theme/theme.ts"; import { DynamicBorder } from "./dynamic-border.ts"; import { keyDisplayText } from "./keybinding-hints.ts"; @@ -31,6 +31,16 @@ const THINKING_DESCRIPTIONS: Record = { xhigh: "Maximum reasoning (~32k tokens)", }; +const DEFAULT_PROJECT_TRUST_LABELS: Record = { + ask: "Ask", + always: "Always trust", + never: "Never trust", +}; + +const DEFAULT_PROJECT_TRUST_BY_LABEL = new Map( + Object.entries(DEFAULT_PROJECT_TRUST_LABELS).map(([value, label]) => [label, value as DefaultProjectTrust]), +); + export interface SettingsConfig { autoCompact: boolean; showImages: boolean; @@ -55,6 +65,7 @@ export interface SettingsConfig { editorPaddingX: number; autocompleteMaxVisible: number; quietStartup: boolean; + defaultProjectTrust: DefaultProjectTrust; clearOnShrink: boolean; showTerminalProgress: boolean; warnings: WarningSettings; @@ -83,6 +94,7 @@ export interface SettingsCallbacks { onEditorPaddingXChange: (padding: number) => void; onAutocompleteMaxVisibleChange: (maxVisible: number) => void; onQuietStartupChange: (enabled: boolean) => void; + onDefaultProjectTrustChange: (defaultProjectTrust: DefaultProjectTrust) => void; onClearOnShrinkChange: (enabled: boolean) => void; onShowTerminalProgressChange: (enabled: boolean) => void; onWarningsChange: (warnings: WarningSettings) => void; @@ -277,6 +289,13 @@ export class SettingsSelectorComponent extends Container { currentValue: config.enableInstallTelemetry ? "true" : "false", values: ["true", "false"], }, + { + id: "default-project-trust", + label: "Default project trust", + description: "Fallback behavior when no extension or saved trust decision decides project trust", + currentValue: DEFAULT_PROJECT_TRUST_LABELS[config.defaultProjectTrust], + values: Object.values(DEFAULT_PROJECT_TRUST_LABELS), + }, { id: "double-escape-action", label: "Double-escape action", @@ -512,6 +531,13 @@ export class SettingsSelectorComponent extends Container { case "install-telemetry": callbacks.onEnableInstallTelemetryChange(newValue === "true"); break; + case "default-project-trust": { + const defaultProjectTrust = DEFAULT_PROJECT_TRUST_BY_LABEL.get(newValue); + if (defaultProjectTrust) { + callbacks.onDefaultProjectTrustChange(defaultProjectTrust); + } + break; + } case "double-escape-action": callbacks.onDoubleEscapeActionChange(newValue as "fork" | "tree"); break; diff --git a/packages/coding-agent/src/modes/interactive/components/trust-selector.ts b/packages/coding-agent/src/modes/interactive/components/trust-selector.ts index b3664768..b7b1fe00 100644 --- a/packages/coding-agent/src/modes/interactive/components/trust-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/trust-selector.ts @@ -1,51 +1,51 @@ import { Container, getKeybindings, Spacer, Text } from "@earendil-works/pi-tui"; -import type { ProjectTrustDecision } from "../../../core/trust-manager.ts"; +import { + getProjectTrustOptions, + getProjectTrustPath, + type ProjectTrustOption, + type ProjectTrustStoreEntry, +} from "../../../core/trust-manager.ts"; import { theme } from "../theme/theme.ts"; import { DynamicBorder } from "./dynamic-border.ts"; import { keyHint, rawKeyHint } from "./keybinding-hints.ts"; -interface TrustOption { - label: string; - trusted: boolean; -} +export type TrustSelection = Pick; export interface TrustSelectorOptions { cwd: string; - savedDecision: ProjectTrustDecision; + savedDecision: ProjectTrustStoreEntry | null; projectTrusted: boolean; - onSelect: (trusted: boolean) => void; + onSelect: (selection: TrustSelection) => void; onCancel: () => void; } -const TRUST_OPTIONS: TrustOption[] = [ - { label: "Trust", trusted: true }, - { label: "Do not trust", trusted: false }, -]; - -function formatDecision(decision: ProjectTrustDecision): string { - if (decision === true) { - return "trusted"; +function formatDecision(cwd: string, decision: ProjectTrustStoreEntry | null): string { + if (decision === null) { + return "none"; } - if (decision === false) { - return "untrusted"; + const label = decision.decision ? "trusted" : "untrusted"; + if (decision.path !== getProjectTrustPath(cwd)) { + return `${label} (inherited from ${decision.path})`; } - return "none"; + return `${label} (${decision.path})`; } export class TrustSelectorComponent extends Container { private selectedIndex: number; private readonly listContainer: Container; - private readonly savedDecision: ProjectTrustDecision; - private readonly onSelectCallback: (trusted: boolean) => void; + private readonly trustOptions: ProjectTrustOption[]; + private readonly savedDecision: ProjectTrustStoreEntry | null; + private readonly onSelectCallback: (selection: TrustSelection) => void; private readonly onCancelCallback: () => void; constructor(options: TrustSelectorOptions) { super(); this.savedDecision = options.savedDecision; + this.trustOptions = getProjectTrustOptions(options.cwd); this.selectedIndex = Math.max( 0, - TRUST_OPTIONS.findIndex((option) => option.trusted === options.savedDecision), + this.trustOptions.findIndex((option) => this.isSavedOption(option)), ); this.onSelectCallback = options.onSelect; this.onCancelCallback = options.onCancel; @@ -55,7 +55,9 @@ export class TrustSelectorComponent extends Container { this.addChild(new Text(theme.fg("accent", theme.bold("Project trust")), 1, 0)); this.addChild(new Text(theme.fg("muted", options.cwd), 1, 0)); this.addChild(new Spacer(1)); - this.addChild(new Text(theme.fg("muted", `Saved decision: ${formatDecision(options.savedDecision)}`), 1, 0)); + this.addChild( + new Text(theme.fg("muted", `Saved decision: ${formatDecision(options.cwd, options.savedDecision)}`), 1, 0), + ); this.addChild( new Text(theme.fg("muted", `Current session: ${options.projectTrusted ? "trusted" : "untrusted"}`), 1, 0), ); @@ -81,16 +83,24 @@ export class TrustSelectorComponent extends Container { this.updateList(); } + private isSavedOption(option: ProjectTrustOption): boolean { + return ( + option.savedPath !== undefined && + this.savedDecision?.decision === option.trusted && + this.savedDecision.path === option.savedPath + ); + } + private updateList(): void { this.listContainer.clear(); - for (let i = 0; i < TRUST_OPTIONS.length; i++) { - const option = TRUST_OPTIONS[i]; + for (let i = 0; i < this.trustOptions.length; i++) { + const option = this.trustOptions[i]; if (!option) { continue; } const isSelected = i === this.selectedIndex; - const isCurrent = option.trusted === this.savedDecision; + const isCurrent = this.isSavedOption(option); const checkmark = isCurrent ? theme.fg("success", " ✓") : ""; const prefix = isSelected ? theme.fg("accent", "→ ") : " "; const label = isSelected ? theme.fg("accent", option.label) : theme.fg("text", option.label); @@ -104,12 +114,12 @@ export class TrustSelectorComponent extends Container { this.selectedIndex = Math.max(0, this.selectedIndex - 1); this.updateList(); } else if (kb.matches(keyData, "tui.select.down") || keyData === "j") { - this.selectedIndex = Math.min(TRUST_OPTIONS.length - 1, this.selectedIndex + 1); + this.selectedIndex = Math.min(this.trustOptions.length - 1, this.selectedIndex + 1); this.updateList(); } else if (kb.matches(keyData, "tui.select.confirm") || keyData === "\n") { - const selected = TRUST_OPTIONS[this.selectedIndex]; + const selected = this.trustOptions[this.selectedIndex]; if (selected) { - this.onSelectCallback(selected.trusted); + this.onSelectCallback({ trusted: selected.trusted, updates: selected.updates }); } } else if (kb.matches(keyData, "tui.select.cancel")) { this.onCancelCallback(); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index f90cffab..d50611af 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -87,7 +87,7 @@ import type { SourceInfo } from "../../core/source-info.ts"; import { isInstallTelemetryEnabled } from "../../core/telemetry.ts"; import type { TruncationResult } from "../../core/tools/truncate.ts"; import { hasProjectConfigDir, hasProjectTrustInputs, ProjectTrustStore } from "../../core/trust-manager.ts"; -import { getChangelogPath, getNewEntries, parseChangelog } from "../../utils/changelog.ts"; +import { getChangelogPath, getNewEntries, normalizeChangelogLinks, parseChangelog } from "../../utils/changelog.ts"; import { copyToClipboard } from "../../utils/clipboard.ts"; import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.ts"; import { parseGitUrl } from "../../utils/git.ts"; @@ -555,8 +555,13 @@ export class InteractiveMode { private setupAutocompleteProvider(): void { let provider = this.createBaseAutocompleteProvider(); + const triggerCharacters: string[] = []; for (const wrapProvider of this.autocompleteProviderWrappers) { provider = wrapProvider(provider); + triggerCharacters.push(...(provider.triggerCharacters ?? [])); + } + if (triggerCharacters.length > 0) { + provider.triggerCharacters = [...new Set(triggerCharacters)]; } this.autocompleteProvider = provider; @@ -909,7 +914,7 @@ export class InteractiveMode { if (newEntries.length > 0) { this.settingsManager.setLastChangelogVersion(VERSION); this.reportInstallTelemetry(VERSION); - return newEntries.map((e) => e.content).join("\n\n"); + return newEntries.map((e) => normalizeChangelogLinks(e.content, e)).join("\n\n"); } return undefined; @@ -1669,6 +1674,7 @@ export class InteractiveMode { modelRegistry: this.session.modelRegistry, model: this.session.model, isIdle: () => !this.session.isStreaming, + isProjectTrusted: () => this.settingsManager.isProjectTrusted(), signal: this.session.agent.signal, abort: () => { this.restoreQueuedMessagesToEditor({ abort: true }); @@ -3276,7 +3282,7 @@ export class InteractiveMode { new Text( theme.fg( "warning", - "This project is not trusted. Project instructions (AGENTS.md/CLAUDE.md), .pi resources, and project packages are ignored. Use /trust to save a trust decision, then restart pi.", + "This project is not trusted. Project .pi resources and packages are ignored. Use /trust to save a trust decision, then restart pi.", ), 1, 0, @@ -3965,6 +3971,7 @@ export class InteractiveMode { doubleEscapeAction: this.settingsManager.getDoubleEscapeAction(), treeFilterMode: this.settingsManager.getTreeFilterMode(), showHardwareCursor: this.settingsManager.getShowHardwareCursor(), + defaultProjectTrust: this.settingsManager.getDefaultProjectTrust(), editorPaddingX: this.settingsManager.getEditorPaddingX(), autocompleteMaxVisible: this.settingsManager.getAutocompleteMaxVisible(), quietStartup: this.settingsManager.getQuietStartup(), @@ -4058,6 +4065,9 @@ export class InteractiveMode { onQuietStartupChange: (enabled) => { this.settingsManager.setQuietStartup(enabled); }, + onDefaultProjectTrustChange: (defaultProjectTrust) => { + this.settingsManager.setDefaultProjectTrust(defaultProjectTrust); + }, onDoubleEscapeActionChange: (action) => { this.settingsManager.setDoubleEscapeAction(action); }, @@ -4212,17 +4222,17 @@ export class InteractiveMode { private showTrustSelector(): void { const cwd = this.sessionManager.getCwd(); const trustStore = new ProjectTrustStore(this.runtimeHost.services.agentDir); - const savedDecision = trustStore.get(cwd); + const savedDecision = trustStore.getEntry(cwd); this.showSelector((done) => { const selector = new TrustSelectorComponent({ cwd, savedDecision, projectTrusted: this.settingsManager.isProjectTrusted(), - onSelect: (trusted) => { - trustStore.set(cwd, trusted); + onSelect: (selection) => { + trustStore.setMany(selection.updates); done(); this.showStatus( - `Saved trust decision: ${trusted ? "trusted" : "untrusted"}. Restart pi for this to take effect.`, + `Saved trust decision: ${selection.trusted ? "trusted" : "untrusted"}. Restart pi for this to take effect.`, ); }, onCancel: () => { @@ -5380,7 +5390,7 @@ export class InteractiveMode { allEntries.length > 0 ? allEntries .reverse() - .map((e) => e.content) + .map((e) => normalizeChangelogLinks(e.content, e)) .join("\n\n") : "No changelog entries found."; @@ -5454,7 +5464,7 @@ export class InteractiveMode { **Navigation** | Key | Action | |-----|--------| -| \`${cursorUp}\` / \`${cursorDown}\` / \`${cursorLeft}\` / \`${cursorRight}\` | Move cursor / browse history (Up when empty) | +| \`${cursorUp}\` / \`${cursorDown}\` / \`${cursorLeft}\` / \`${cursorRight}\` | Move cursor / browse history | | \`${cursorWordLeft}\` / \`${cursorWordRight}\` | Move by word | | \`${cursorLineStart}\` | Start of line | | \`${cursorLineEnd}\` | End of line | diff --git a/packages/coding-agent/src/package-manager-cli.ts b/packages/coding-agent/src/package-manager-cli.ts index ea90d318..94dbff85 100644 --- a/packages/coding-agent/src/package-manager-cli.ts +++ b/packages/coding-agent/src/package-manager-cli.ts @@ -1,6 +1,7 @@ import { Markdown, type MarkdownTheme } from "@earendil-works/pi-tui"; import chalk from "chalk"; import { selectConfig } from "./cli/config-selector.ts"; +import { createProjectTrustContext } from "./cli/project-trust.ts"; import { APP_NAME, detectInstallMethod, @@ -12,7 +13,10 @@ import { type SelfUpdateCommand, VERSION, } from "./config.ts"; +import type { ExtensionFactory } from "./core/extensions/types.ts"; import { DefaultPackageManager } from "./core/package-manager.ts"; +import { type AppMode, resolveProjectTrusted } from "./core/project-trust.ts"; +import { DefaultResourceLoader } from "./core/resource-loader.ts"; import { SettingsManager } from "./core/settings-manager.ts"; import { hasProjectTrustInputs, ProjectTrustStore } from "./core/trust-manager.ts"; import { spawnProcess } from "./utils/child-process.ts"; @@ -425,22 +429,82 @@ function parseProjectTrustOverride(args: readonly string[]): boolean | undefined return trustOverride; } -function resolveProjectTrusted(cwd: string, agentDir: string, trustOverride: boolean | undefined): boolean { - if (trustOverride !== undefined) { - return trustOverride; - } - return !hasProjectTrustInputs(cwd) || new ProjectTrustStore(agentDir).get(cwd) === true; +export interface PackageCommandRuntimeOptions { + extensionFactories?: ExtensionFactory[]; } -export async function handleConfigCommand(args: string[]): Promise { +interface CommandSettingsResult { + settingsManager: SettingsManager; + projectTrustWarnings: string[]; +} + +function getCommandAppMode(): AppMode { + return process.stdin.isTTY && process.stdout.isTTY ? "interactive" : "print"; +} + +function reportProjectTrustWarnings(warnings: readonly string[]): void { + for (const warning of warnings) { + console.error(chalk.yellow(`Warning: ${warning}`)); + } +} + +async function createCommandSettingsManager(options: { + cwd: string; + agentDir: string; + projectTrustOverride?: boolean; + extensionFactories?: ExtensionFactory[]; +}): Promise { + const settingsManager = SettingsManager.create(options.cwd, options.agentDir, { projectTrusted: false }); + const projectTrustWarnings: string[] = []; + const appMode = getCommandAppMode(); + const extensionsResult = + options.projectTrustOverride === undefined && hasProjectTrustInputs(options.cwd) + ? await new DefaultResourceLoader({ + cwd: options.cwd, + agentDir: options.agentDir, + settingsManager, + extensionFactories: options.extensionFactories, + }).loadProjectTrustExtensions() + : undefined; + for (const error of extensionsResult?.errors ?? []) { + projectTrustWarnings.push(`Failed to load extension "${error.path}": ${error.error}`); + } + + const projectTrusted = await resolveProjectTrusted({ + cwd: options.cwd, + trustStore: new ProjectTrustStore(options.agentDir), + trustOverride: options.projectTrustOverride, + defaultProjectTrust: settingsManager.getDefaultProjectTrust(), + extensionsResult, + projectTrustContext: createProjectTrustContext({ + cwd: options.cwd, + mode: appMode, + settingsManager, + hasUI: appMode === "interactive", + }), + onExtensionError: (message) => projectTrustWarnings.push(message), + }); + settingsManager.setProjectTrusted(projectTrusted); + return { settingsManager, projectTrustWarnings }; +} + +export async function handleConfigCommand( + args: string[], + runtimeOptions: PackageCommandRuntimeOptions = {}, +): Promise { if (args[0] !== "config") { return false; } const cwd = process.cwd(); const agentDir = getAgentDir(); - const projectTrusted = parseProjectTrustOverride(args) ?? true; - const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted }); + const { settingsManager, projectTrustWarnings } = await createCommandSettingsManager({ + cwd, + agentDir, + projectTrustOverride: parseProjectTrustOverride(args), + extensionFactories: runtimeOptions.extensionFactories, + }); + reportProjectTrustWarnings(projectTrustWarnings); reportSettingsErrors(settingsManager, "config command"); const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager }); const resolvedPaths = await packageManager.resolve(); @@ -455,7 +519,10 @@ export async function handleConfigCommand(args: string[]): Promise { process.exit(0); } -export async function handlePackageCommand(args: string[]): Promise { +export async function handlePackageCommand( + args: string[], + runtimeOptions: PackageCommandRuntimeOptions = {}, +): Promise { const options = parsePackageCommand(args); if (!options) { return false; @@ -505,13 +572,18 @@ export async function handlePackageCommand(args: string[]): Promise { const cwd = process.cwd(); const agentDir = getAgentDir(); const writesProjectPackageConfig = (options.command === "install" || options.command === "remove") && options.local; - const projectTrusted = resolveProjectTrusted(cwd, agentDir, options.projectTrustOverride); - if (!projectTrusted && writesProjectPackageConfig) { + const { settingsManager, projectTrustWarnings } = await createCommandSettingsManager({ + cwd, + agentDir, + projectTrustOverride: options.projectTrustOverride, + extensionFactories: runtimeOptions.extensionFactories, + }); + reportProjectTrustWarnings(projectTrustWarnings); + if (!settingsManager.isProjectTrusted() && writesProjectPackageConfig) { console.error(chalk.red("Project is not trusted. Use --approve to modify local package config.")); process.exitCode = 1; return true; } - const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted }); reportSettingsErrors(settingsManager, "package command"); const selfUpdateNpmCommand = settingsManager.getGlobalSettings().npmCommand; diff --git a/packages/coding-agent/src/utils/changelog.ts b/packages/coding-agent/src/utils/changelog.ts index b9e8e35d..2c8ce4a6 100644 --- a/packages/coding-agent/src/utils/changelog.ts +++ b/packages/coding-agent/src/utils/changelog.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { existsSync, readFileSync } from "fs"; export interface ChangelogEntry { @@ -7,6 +8,102 @@ export interface ChangelogEntry { content: string; } +const GITHUB_REPO = "earendil-works/pi"; +const CHANGELOG_LINK_BASE_PATH = "packages/coding-agent"; +const LEGACY_REPO_RE = /^https:\/\/github\.com\/(?:badlogic|earendil-works)\/pi-mono(?=\/|$)/; +const URL_SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i; +const INLINE_MARKDOWN_LINK_RE = /(!?\[[^\]\n]+\]\()([^\s)]+)((?:\s+[^)]*)?\))/g; + +function entryVersion(entry: ChangelogEntry): string { + return `${entry.major}.${entry.minor}.${entry.patch}`; +} + +function normalizeTag(version: string | ChangelogEntry): string { + const versionString = typeof version === "string" ? version : entryVersion(version); + return versionString.startsWith("v") ? versionString : `v${versionString}`; +} + +function splitLocalTarget(target: string): { fragment: string; pathPart: string; query: string } { + const hashIndex = target.indexOf("#"); + const beforeHash = hashIndex === -1 ? target : target.slice(0, hashIndex); + const fragment = hashIndex === -1 ? "" : target.slice(hashIndex); + const queryIndex = beforeHash.indexOf("?"); + + if (queryIndex === -1) { + return { fragment, pathPart: beforeHash, query: "" }; + } + + return { + fragment, + pathPart: beforeHash.slice(0, queryIndex), + query: beforeHash.slice(queryIndex), + }; +} + +function normalizePathPart(value: string): string { + return value.replaceAll("\\", "/"); +} + +function resolveRepositoryPath(targetPath: string): string | undefined { + const normalizedTarget = normalizePathPart(targetPath); + const joined = normalizedTarget.startsWith("/") + ? path.posix.normalize(normalizedTarget.replace(/^\/+/, "")) + : path.posix.normalize(path.posix.join(CHANGELOG_LINK_BASE_PATH, normalizedTarget)); + + if (joined === "." || joined.startsWith("../") || joined === "..") { + return undefined; + } + + return joined; +} + +function isDirectoryTarget(originalPath: string, repositoryPath: string): boolean { + if (originalPath.endsWith("/")) { + return true; + } + + const basename = path.posix.basename(repositoryPath); + return !basename.includes("."); +} + +function normalizeChangelogLinkTarget(target: string, tag: string): string { + let canonicalTarget = target.replace(LEGACY_REPO_RE, `https://github.com/${GITHUB_REPO}`); + const repoUrl = `https://github.com/${GITHUB_REPO}`; + + for (const route of ["blob", "tree"]) { + for (const branch of ["main", "master"]) { + const floatingRefPrefix = `${repoUrl}/${route}/${branch}/`; + if (canonicalTarget.startsWith(floatingRefPrefix)) { + canonicalTarget = `${repoUrl}/${route}/${tag}/${canonicalTarget.slice(floatingRefPrefix.length)}`; + } + } + } + + if (canonicalTarget.startsWith("#") || canonicalTarget.startsWith("//") || URL_SCHEME_RE.test(canonicalTarget)) { + return canonicalTarget; + } + + const { fragment, pathPart, query } = splitLocalTarget(canonicalTarget); + if (!pathPart) { + return canonicalTarget; + } + + const repositoryPath = resolveRepositoryPath(pathPart); + if (!repositoryPath) { + return canonicalTarget; + } + + const route = isDirectoryTarget(pathPart, repositoryPath) ? "tree" : "blob"; + return `https://github.com/${GITHUB_REPO}/${route}/${tag}/${encodeURI(repositoryPath)}${query}${fragment}`; +} + +export function normalizeChangelogLinks(markdown: string, version: string | ChangelogEntry): string { + const tag = normalizeTag(version); + return markdown.replace(INLINE_MARKDOWN_LINK_RE, (_match, prefix, target, suffix) => { + return `${prefix}${normalizeChangelogLinkTarget(target, tag)}${suffix}`; + }); +} + /** * Parse changelog entries from CHANGELOG.md * Scans for ## lines and collects content until next ## or EOF diff --git a/packages/coding-agent/test/changelog.test.ts b/packages/coding-agent/test/changelog.test.ts new file mode 100644 index 00000000..979e7cdf --- /dev/null +++ b/packages/coding-agent/test/changelog.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "vitest"; +import { type ChangelogEntry, normalizeChangelogLinks } from "../src/utils/changelog.ts"; + +const entry: ChangelogEntry = { + major: 0, + minor: 79, + patch: 0, + content: "", +}; + +describe("normalizeChangelogLinks", () => { + test("rewrites package-relative changelog links to tag-pinned GitHub source links", () => { + const markdown = [ + "[Project Trust](README.md#project-trust)", + "[Extensions](docs/extensions.md#project_trust)", + "[Examples](examples/extensions/)", + "[Root README](../../README.md#supply-chain-hardening)", + ].join("\n"); + + expect(normalizeChangelogLinks(markdown, entry)).toBe( + [ + "[Project Trust](https://github.com/earendil-works/pi/blob/v0.79.0/packages/coding-agent/README.md#project-trust)", + "[Extensions](https://github.com/earendil-works/pi/blob/v0.79.0/packages/coding-agent/docs/extensions.md#project_trust)", + "[Examples](https://github.com/earendil-works/pi/tree/v0.79.0/packages/coding-agent/examples/extensions/)", + "[Root README](https://github.com/earendil-works/pi/blob/v0.79.0/README.md#supply-chain-hardening)", + ].join("\n"), + ); + }); + + test("canonicalizes old repository URLs without changing external links", () => { + const markdown = [ + "[#5167](https://github.com/earendil-works/pi-mono/pull/5167)", + "[#4163](https://github.com/badlogic/pi-mono/issues/4163)", + "[Agent README](https://github.com/badlogic/pi-mono/blob/main/packages/agent/README.md)", + "[External](https://example.com/docs)", + "[Local anchor](#settings)", + ].join("\n"); + + expect(normalizeChangelogLinks(markdown, "0.79.0")).toBe( + [ + "[#5167](https://github.com/earendil-works/pi/pull/5167)", + "[#4163](https://github.com/earendil-works/pi/issues/4163)", + "[Agent README](https://github.com/earendil-works/pi/blob/v0.79.0/packages/agent/README.md)", + "[External](https://example.com/docs)", + "[Local anchor](#settings)", + ].join("\n"), + ); + }); +}); diff --git a/packages/coding-agent/test/config-value-migration.test.ts b/packages/coding-agent/test/config-value-migration.test.ts index 35d4f155..d0bb8125 100644 --- a/packages/coding-agent/test/config-value-migration.test.ts +++ b/packages/coding-agent/test/config-value-migration.test.ts @@ -3,6 +3,8 @@ import * as os from "node:os"; import * as path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { ENV_AGENT_DIR } from "../src/config.ts"; +import { AuthStorage } from "../src/core/auth-storage.ts"; +import { ModelRegistry } from "../src/core/model-registry.ts"; import { runMigrations } from "../src/migrations.ts"; describe("config value env var syntax migration", () => { @@ -68,6 +70,23 @@ describe("config value env var syntax migration", () => { expect(logMessage).toContain('auth.json["anthropic"].key: ANTHROPIC_API_KEY -> $ANTHROPIC_API_KEY'); }); + it.each([ + ["malformed", '{\n "providers": {\n'], + ["blank", ""], + ])("does not throw on %s models.json during config migration", (_name, content) => { + const agentDir = createAgentDir(); + const modelsPath = path.join(agentDir, "models.json"); + fs.writeFileSync(modelsPath, content, "utf-8"); + + withAgentDir(agentDir, () => expect(() => runMigrations(agentDir)).not.toThrow()); + + expect(fs.readFileSync(modelsPath, "utf-8")).toBe(content); + const registry = ModelRegistry.create(AuthStorage.create(path.join(agentDir, "auth.json")), modelsPath); + const loadError = registry.getError(); + expect(loadError).toContain("Failed to parse models.json"); + expect(loadError).toContain(`File: ${modelsPath}`); + }); + it("rewrites legacy uppercase models.json API key and header values", () => { const agentDir = createAgentDir(); fs.writeFileSync( diff --git a/packages/coding-agent/test/experimental.test.ts b/packages/coding-agent/test/experimental.test.ts new file mode 100644 index 00000000..665616e8 --- /dev/null +++ b/packages/coding-agent/test/experimental.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { areExperimentalFeaturesEnabled } from "../src/core/experimental.ts"; + +describe("areExperimentalFeaturesEnabled", () => { + const originalPiExperimental = process.env.PI_EXPERIMENTAL; + + afterEach(() => { + if (originalPiExperimental === undefined) { + delete process.env.PI_EXPERIMENTAL; + } else { + process.env.PI_EXPERIMENTAL = originalPiExperimental; + } + }); + + it("returns false when PI_EXPERIMENTAL is unset", () => { + delete process.env.PI_EXPERIMENTAL; + + expect(areExperimentalFeaturesEnabled()).toBe(false); + }); + + it("returns false when PI_EXPERIMENTAL is empty", () => { + process.env.PI_EXPERIMENTAL = ""; + + expect(areExperimentalFeaturesEnabled()).toBe(false); + }); + + it("returns true when PI_EXPERIMENTAL is set to 1", () => { + process.env.PI_EXPERIMENTAL = "1"; + + expect(areExperimentalFeaturesEnabled()).toBe(true); + }); + + it("returns false when PI_EXPERIMENTAL is set to 0", () => { + process.env.PI_EXPERIMENTAL = "0"; + + expect(areExperimentalFeaturesEnabled()).toBe(false); + }); + + it("returns false when PI_EXPERIMENTAL is set to a non-1 value", () => { + process.env.PI_EXPERIMENTAL = "true"; + + expect(areExperimentalFeaturesEnabled()).toBe(false); + }); +}); diff --git a/packages/coding-agent/test/extensions-runner.test.ts b/packages/coding-agent/test/extensions-runner.test.ts index f4939367..cd611962 100644 --- a/packages/coding-agent/test/extensions-runner.test.ts +++ b/packages/coding-agent/test/extensions-runner.test.ts @@ -76,6 +76,7 @@ describe("ExtensionRunner", () => { const extensionContextActions: ExtensionContextActions = { getModel: () => undefined, isIdle: () => true, + isProjectTrusted: () => true, getSignal: () => undefined, abort: () => {}, hasPendingMessages: () => false, @@ -496,6 +497,18 @@ describe("ExtensionRunner", () => { expect(ctx.hasUI).toBe(false); }); + it("exposes project trust state on ExtensionContext", async () => { + const result = await discoverAndLoadExtensions([], tempDir, tempDir); + const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); + runner.bindCore(extensionActions, { + ...extensionContextActions, + isProjectTrusted: () => false, + }); + + const ctx = runner.createContext(); + expect(ctx.isProjectTrusted()).toBe(false); + }); + it("exposes rpc mode with hasUI true when an RPC UI context is provided", async () => { const result = await discoverAndLoadExtensions([], tempDir, tempDir); const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); diff --git a/packages/coding-agent/test/first-time-setup.test.ts b/packages/coding-agent/test/first-time-setup.test.ts new file mode 100644 index 00000000..c470e6dc --- /dev/null +++ b/packages/coding-agent/test/first-time-setup.test.ts @@ -0,0 +1,95 @@ +import { mkdtempSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { shouldRunFirstTimeSetup } from "../src/cli/startup-ui.ts"; +import { ENV_AGENT_DIR } from "../src/config.ts"; +import { SettingsManager } from "../src/core/settings-manager.ts"; + +describe("shouldRunFirstTimeSetup", () => { + const originalPiExperimental = process.env.PI_EXPERIMENTAL; + const originalAgentDir = process.env[ENV_AGENT_DIR]; + let tempDir: string; + let settingsPath: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "pi-first-time-setup-")); + settingsPath = join(tempDir, "settings.json"); + process.env.PI_EXPERIMENTAL = "1"; + delete process.env[ENV_AGENT_DIR]; + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + if (originalPiExperimental === undefined) { + delete process.env.PI_EXPERIMENTAL; + } else { + process.env.PI_EXPERIMENTAL = originalPiExperimental; + } + if (originalAgentDir === undefined) { + delete process.env[ENV_AGENT_DIR]; + } else { + process.env[ENV_AGENT_DIR] = originalAgentDir; + } + }); + + it("returns true when experimental, default agent dir, and no settings.json", () => { + expect(shouldRunFirstTimeSetup(settingsPath)).toBe(true); + }); + + it("returns false when experimental features are disabled", () => { + delete process.env.PI_EXPERIMENTAL; + + expect(shouldRunFirstTimeSetup(settingsPath)).toBe(false); + }); + + it("returns false when a custom agent dir is set", () => { + process.env[ENV_AGENT_DIR] = tempDir; + + expect(shouldRunFirstTimeSetup(settingsPath)).toBe(false); + }); + + it("returns false when settings.json already exists", () => { + writeFileSync(settingsPath, "{}", "utf-8"); + + expect(shouldRunFirstTimeSetup(settingsPath)).toBe(false); + }); +}); + +describe("analytics settings", () => { + it("defaults to disabled with no tracking identifier", () => { + const manager = SettingsManager.inMemory(); + + expect(manager.getEnableAnalytics()).toBe(false); + expect(manager.getTrackingId()).toBeUndefined(); + }); + + it("generates a tracking identifier on opt-in", () => { + const manager = SettingsManager.inMemory(); + + manager.setEnableAnalytics(true); + + expect(manager.getEnableAnalytics()).toBe(true); + expect(manager.getTrackingId()).toMatch(/^[0-9a-f-]{36}$/); + }); + + it("does not generate a tracking identifier on opt-out", () => { + const manager = SettingsManager.inMemory(); + + manager.setEnableAnalytics(false); + + expect(manager.getEnableAnalytics()).toBe(false); + expect(manager.getTrackingId()).toBeUndefined(); + }); + + it("keeps the tracking identifier when toggling analytics", () => { + const manager = SettingsManager.inMemory(); + + manager.setEnableAnalytics(true); + const trackingId = manager.getTrackingId(); + manager.setEnableAnalytics(false); + manager.setEnableAnalytics(true); + + expect(manager.getTrackingId()).toBe(trackingId); + }); +}); diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index 5f4a8e21..7ec54c7b 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -324,6 +324,36 @@ describe("InteractiveMode.setupAutocompleteProvider", () => { expect(provider.shouldTriggerFileCompletion?.(["foo"], 0, 3)).toBe(true); expect(calls).toEqual(["shouldTrigger:wrap2", "shouldTrigger:wrap1"]); }); + + test("merges triggerCharacters from wrapper factories", () => { + const defaultEditor = { setAutocompleteProvider: vi.fn() }; + const customEditor = { setAutocompleteProvider: vi.fn() }; + const passThrough = + (triggerCharacters: string[]): AutocompleteProviderFactory => + (current) => ({ + triggerCharacters, + getSuggestions: (lines, cursorLine, cursorCol, options) => + current.getSuggestions(lines, cursorLine, cursorCol, options), + applyCompletion: (lines, cursorLine, cursorCol, item, prefix) => + current.applyCompletion(lines, cursorLine, cursorCol, item, prefix), + }); + + const fakeThis = { + createBaseAutocompleteProvider: () => new CombinedAutocompleteProvider([], "/tmp/project", undefined), + defaultEditor, + editor: customEditor, + autocompleteProviderWrappers: [passThrough(["$"]), passThrough(["!"])], + }; + + ( + InteractiveMode as unknown as { + prototype: { setupAutocompleteProvider: (this: typeof fakeThis) => void }; + } + ).prototype.setupAutocompleteProvider.call(fakeThis); + + const provider = defaultEditor.setAutocompleteProvider.mock.calls[0]?.[0] as AutocompleteProvider; + expect(provider.triggerCharacters).toEqual(["$", "!"]); + }); }); describe("InteractiveMode.showLoadedResources", () => { diff --git a/packages/coding-agent/test/model-resolver.test.ts b/packages/coding-agent/test/model-resolver.test.ts index 0ac54939..4b4caf0f 100644 --- a/packages/coding-agent/test/model-resolver.test.ts +++ b/packages/coding-agent/test/model-resolver.test.ts @@ -370,6 +370,127 @@ describe("resolveCliModel", () => { expect(result.model?.provider).toBe("openrouter"); expect(result.model?.id).toBe("qwen/qwen3-coder:exacto"); }); + + describe("custom model fallback with :thinking suffix (#5552)", () => { + // Models for a provider that has registered models but the specific model ID + // is not in the registry (triggers buildFallbackModel path). + const neuralwattModel: Model<"anthropic-messages"> = { + id: "some-base-model", + name: "Some Base Model", + api: "anthropic-messages", + provider: "neuralwatt", + baseUrl: "https://api.neuralwatt.com", + reasoning: false, + input: ["text"], + cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 1 }, + contextWindow: 128000, + maxTokens: 8192, + }; + + const modelsWithNeuralwatt = [...allModels, neuralwattModel]; + + test("strips :thinking suffix from custom model id in fallback path", () => { + const registry = { + getAll: () => modelsWithNeuralwatt, + } as unknown as Parameters[0]["modelRegistry"]; + + const result = resolveCliModel({ + cliModel: "neuralwatt/zai-org/GLM-5.1-FP8:high", + modelRegistry: registry, + }); + + expect(result.error).toBeUndefined(); + expect(result.model?.provider).toBe("neuralwatt"); + // The :high suffix must NOT leak into the model id sent to the API + expect(result.model?.id).toBe("zai-org/GLM-5.1-FP8"); + expect(result.thinkingLevel).toBe("high"); + }); + + test("custom model without thinking suffix works normally in fallback path", () => { + const registry = { + getAll: () => modelsWithNeuralwatt, + } as unknown as Parameters[0]["modelRegistry"]; + + const result = resolveCliModel({ + cliModel: "neuralwatt/zai-org/GLM-5.1-FP8", + modelRegistry: registry, + }); + + expect(result.error).toBeUndefined(); + expect(result.model?.provider).toBe("neuralwatt"); + expect(result.model?.id).toBe("zai-org/GLM-5.1-FP8"); + expect(result.thinkingLevel).toBeUndefined(); + }); + + test("all valid thinking levels work in fallback path", () => { + const registry = { + getAll: () => modelsWithNeuralwatt, + } as unknown as Parameters[0]["modelRegistry"]; + + for (const level of ["off", "minimal", "low", "medium", "high", "xhigh"]) { + const result = resolveCliModel({ + cliModel: `neuralwatt/zai-org/GLM-5.1-FP8:${level}`, + modelRegistry: registry, + }); + + expect(result.error).toBeUndefined(); + expect(result.model?.id).toBe("zai-org/GLM-5.1-FP8"); + expect(result.thinkingLevel).toBe(level); + } + }); + + test("invalid thinking suffix on custom model is treated as part of model id", () => { + const registry = { + getAll: () => modelsWithNeuralwatt, + } as unknown as Parameters[0]["modelRegistry"]; + + const result = resolveCliModel({ + cliModel: "neuralwatt/zai-org/GLM-5.1-FP8:banana", + modelRegistry: registry, + }); + + expect(result.error).toBeUndefined(); + expect(result.model?.provider).toBe("neuralwatt"); + // Invalid suffix stays in the id (it's not a thinking level) + expect(result.model?.id).toBe("zai-org/GLM-5.1-FP8:banana"); + expect(result.thinkingLevel).toBeUndefined(); + }); + + test("explicit --provider with custom model:thinking strips suffix correctly", () => { + const registry = { + getAll: () => modelsWithNeuralwatt, + } as unknown as Parameters[0]["modelRegistry"]; + + const result = resolveCliModel({ + cliProvider: "neuralwatt", + cliModel: "zai-org/GLM-5.1-FP8:high", + modelRegistry: registry, + }); + + expect(result.error).toBeUndefined(); + expect(result.model?.provider).toBe("neuralwatt"); + expect(result.model?.id).toBe("zai-org/GLM-5.1-FP8"); + expect(result.thinkingLevel).toBe("high"); + }); + + test("with explicit --thinking, :suffix is kept as part of model id", () => { + const registry = { + getAll: () => modelsWithNeuralwatt, + } as unknown as Parameters[0]["modelRegistry"]; + + const result = resolveCliModel({ + cliModel: "neuralwatt/zai-org/GLM-5.1-FP8:high", + cliThinking: "medium", + modelRegistry: registry, + }); + + expect(result.error).toBeUndefined(); + expect(result.model?.provider).toBe("neuralwatt"); + // :high is kept as part of the model id since --thinking was explicit + expect(result.model?.id).toBe("zai-org/GLM-5.1-FP8:high"); + expect(result.thinkingLevel).toBeUndefined(); + }); + }); }); describe("default model selection", () => { diff --git a/packages/coding-agent/test/package-command-paths.test.ts b/packages/coding-agent/test/package-command-paths.test.ts index 1fc587c8..25a55b35 100644 --- a/packages/coding-agent/test/package-command-paths.test.ts +++ b/packages/coding-agent/test/package-command-paths.test.ts @@ -157,6 +157,70 @@ describe("package commands", () => { } }); + it("uses default project trust for list", async () => { + mkdirSync(join(projectDir, ".pi"), { recursive: true }); + writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ defaultProjectTrust: "always" })); + writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] })); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + await expect(main(["list"])).resolves.toBeUndefined(); + + const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n"); + expect(stdout).toContain("Project packages:"); + expect(stdout).toContain("npm:@project/pkg"); + expect(stdout).not.toContain("No packages installed."); + expect(process.exitCode).toBeUndefined(); + } finally { + logSpy.mockRestore(); + } + }); + + it("uses project_trust extensions for package commands", async () => { + mkdirSync(join(projectDir, ".pi"), { recursive: true }); + writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] })); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + await expect( + main(["list"], { + extensionFactories: [ + (pi) => { + pi.on("project_trust", () => ({ trusted: "yes" })); + }, + ], + }), + ).resolves.toBeUndefined(); + + const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n"); + expect(stdout).toContain("Project packages:"); + expect(stdout).toContain("npm:@project/pkg"); + expect(stdout).not.toContain("No packages installed."); + expect(process.exitCode).toBeUndefined(); + } finally { + logSpy.mockRestore(); + } + }); + + it("lets trust.json override default project trust", async () => { + mkdirSync(join(projectDir, ".pi"), { recursive: true }); + writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ defaultProjectTrust: "always" })); + writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] })); + new ProjectTrustStore(agentDir).set(projectDir, false); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + await expect(main(["list"])).resolves.toBeUndefined(); + + const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n"); + expect(stdout).toContain("No packages installed."); + expect(stdout).not.toContain("Project packages:"); + expect(process.exitCode).toBeUndefined(); + } finally { + logSpy.mockRestore(); + } + }); + it("blocks local package changes when project is untrusted", async () => { mkdirSync(join(projectDir, ".pi"), { recursive: true }); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); diff --git a/packages/coding-agent/test/prompt-templates.test.ts b/packages/coding-agent/test/prompt-templates.test.ts index 1e4bcf5c..681a7c62 100644 --- a/packages/coding-agent/test/prompt-templates.test.ts +++ b/packages/coding-agent/test/prompt-templates.test.ts @@ -190,6 +190,52 @@ describe("substituteArgs", () => { }); }); +// ============================================================================ +// substituteArgs - Positional Defaults +// ============================================================================ + +describe("substituteArgs - positional defaults", () => { + test("should use default when positional arg is missing", () => { + expect(substituteArgs(`List exactly \${1:-7} next steps`, [])).toBe("List exactly 7 next steps"); + }); + + test("should use positional arg when present", () => { + expect(substituteArgs(`List exactly \${1:-7} next steps`, ["3"])).toBe("List exactly 3 next steps"); + }); + + test("should use default when positional arg is empty", () => { + expect(substituteArgs(`Mode: \${1:-brief}`, [""])).toBe("Mode: brief"); + }); + + test("should support multiple positional defaults", () => { + expect(substituteArgs(`\${1:-7} \${2:-brief}`, [])).toBe("7 brief"); + expect(substituteArgs(`\${1:-7} \${2:-brief}`, ["3"])).toBe("3 brief"); + expect(substituteArgs(`\${1:-7} \${2:-brief}`, ["3", "verbose"])).toBe("3 verbose"); + }); + + test("should not recursively substitute patterns in arg values", () => { + expect(substituteArgs(`\${1:-7}`, ["$ARGUMENTS"])).toBe("$ARGUMENTS"); + expect(substituteArgs(`\${1:-7}`, ["$1"])).toBe("$1"); + }); + + test("should not recursively substitute patterns in default values", () => { + expect(substituteArgs(`\${1:-$ARGUMENTS}`, ["a", "b"])).toBe("a"); + expect(substituteArgs(`\${3:-$ARGUMENTS}`, ["a", "b"])).toBe("$ARGUMENTS"); + }); + + test("should support defaults with spaces", () => { + expect(substituteArgs(`\${1:-seven steps}`, [])).toBe("seven steps"); + }); + + test("should support out-of-range positional defaults", () => { + expect(substituteArgs(`\${3:-fallback}`, ["a", "b"])).toBe("fallback"); + }); + + test("should mix positional defaults with existing placeholders", () => { + expect(substituteArgs(`$1 \${2:-x} $ARGUMENTS`, ["a"])).toBe("a x a"); + }); +}); + // ============================================================================ // substituteArgs - Array Slicing (Bash-Style) // ============================================================================ diff --git a/packages/coding-agent/test/resource-loader.test.ts b/packages/coding-agent/test/resource-loader.test.ts index abbe8975..7258b121 100644 --- a/packages/coding-agent/test/resource-loader.test.ts +++ b/packages/coding-agent/test/resource-loader.test.ts @@ -376,7 +376,7 @@ Content`, expect(loader.getSystemPrompt()).toBe("You are a helpful assistant."); }); - it("should skip project resources when project is not trusted", async () => { + it("should skip trust-gated project resources when project is not trusted", async () => { const piDir = join(cwd, ".pi"); const extensionsDir = join(piDir, "extensions"); const skillDir = join(piDir, "skills", "project-skill"); @@ -414,7 +414,7 @@ Project skill content`, expect(loader.getAgentsFiles().agentsFiles.some((file) => file.path === join(agentDir, "AGENTS.md"))).toBe( true, ); - expect(loader.getAgentsFiles().agentsFiles.some((file) => file.path === join(cwd, "AGENTS.md"))).toBe(false); + expect(loader.getAgentsFiles().agentsFiles.some((file) => file.path === join(cwd, "AGENTS.md"))).toBe(true); expect(loader.getExtensions().extensions).toHaveLength(0); expect(loader.getExtensions().errors).toEqual([]); expect(loader.getSkills().skills.some((skill) => skill.name === "project-skill")).toBe(false); diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index b28d086a..279bece1 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -250,6 +250,23 @@ describe("SettingsManager", () => { expect(manager.getProjectSettings()).toEqual({}); expect(JSON.parse(readFileSync(projectSettingsPath, "utf-8"))).toEqual({ packages: ["npm:existing"] }); }); + + it("should read default project trust from global settings only", () => { + writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ defaultProjectTrust: "always" })); + writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ defaultProjectTrust: "never" })); + + const manager = SettingsManager.create(projectDir, agentDir); + + expect(manager.getDefaultProjectTrust()).toBe("always"); + }); + + it("should default invalid project trust settings to ask", () => { + writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ defaultProjectTrust: "sometimes" })); + + const manager = SettingsManager.create(projectDir, agentDir); + + expect(manager.getDefaultProjectTrust()).toBe("ask"); + }); }); describe("project settings directory creation", () => { diff --git a/packages/coding-agent/test/stdout-cleanliness.test.ts b/packages/coding-agent/test/stdout-cleanliness.test.ts index 057db06a..f1b31eb3 100644 --- a/packages/coding-agent/test/stdout-cleanliness.test.ts +++ b/packages/coding-agent/test/stdout-cleanliness.test.ts @@ -80,6 +80,22 @@ async function runCli(args: string[]): Promise<{ stdout: string; stderr: string; } describe("stdout cleanliness in non-interactive modes", () => { + it("prints --version to stdout when stdout is redirected", async () => { + const result = await runCli(["--version"]); + + expect(result.code).toBe(0); + expect(result.stdout.trim()).toMatch(/^\d+\.\d+\.\d+/); + expect(result.stderr).toBe(""); + }); + + it("prints plain --help to stdout when stdout is redirected", async () => { + const result = await runCli(["--help"]); + + expect(result.code).toBe(0); + expect(result.stdout).toContain("Usage:"); + expect(result.stderr).not.toContain("Usage:"); + }); + it("keeps stdout empty for --mode json --help while routing trusted startup chatter to stderr", async () => { const result = await runCli(["--mode", "json", "--help", "--approve"]); diff --git a/packages/coding-agent/test/suite/regressions/5433-extension-oauth-prompt-input.test.ts b/packages/coding-agent/test/suite/regressions/5433-extension-oauth-prompt-input.test.ts new file mode 100644 index 00000000..06562b3d --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/5433-extension-oauth-prompt-input.test.ts @@ -0,0 +1,93 @@ +import { setKeybindings, type TUI } from "@earendil-works/pi-tui"; +import { beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; +import { KeybindingsManager } from "../../../src/core/keybindings.ts"; +import { LoginDialogComponent } from "../../../src/modes/interactive/components/login-dialog.ts"; +import { initTheme } from "../../../src/modes/interactive/theme/theme.ts"; +import { stripAnsi } from "../../../src/utils/ansi.ts"; + +vi.mock("../../../src/utils/open-browser.ts", () => ({ + openBrowser: vi.fn(), +})); + +function createDialog(): LoginDialogComponent { + return new LoginDialogComponent( + { requestRender: vi.fn() } as unknown as TUI, + "prompt-repro", + () => {}, + "Prompt Repro", + ); +} + +function renderDialog(dialog: LoginDialogComponent): string[] { + return stripAnsi(dialog.render(120).join("\n")) + .split("\n") + .map((line) => line.trimEnd()); +} + +function countRenderedValue(lines: string[], value: string): number { + return lines.filter((line) => line.trim() === `> ${value}`).length; +} + +describe("LoginDialogComponent OAuth prompts", () => { + beforeAll(() => { + initTheme("dark"); + }); + + beforeEach(() => { + setKeybindings(new KeybindingsManager()); + }); + + test("keeps previous prompt input stable when a later prompt is active", async () => { + const dialog = createDialog(); + + const firstPrompt = dialog.showPrompt("First prompt:", "first-value"); + dialog.handleInput("first-value"); + dialog.handleInput("\n"); + await expect(firstPrompt).resolves.toBe("first-value"); + + const secondPrompt = dialog.showPrompt("Second prompt:"); + dialog.handleInput("second-secret-demo"); + + const lines = renderDialog(dialog); + expect(lines.join("\n")).toContain("First prompt:"); + expect(lines.join("\n")).toContain("Second prompt:"); + expect(countRenderedValue(lines, "first-value")).toBe(1); + expect(countRenderedValue(lines, "second-secret-demo")).toBe(1); + + dialog.handleInput("\n"); + await expect(secondPrompt).resolves.toBe("second-secret-demo"); + }); + + test("preserves auth instructions when showing a prompt", () => { + const dialog = createDialog(); + + dialog.showAuth("https://example.invalid/login", "Authorize the extension"); + dialog.showPrompt("First prompt:"); + + const output = renderDialog(dialog).join("\n"); + expect(output).toContain("https://example.invalid/login"); + expect(output).toContain("Authorize the extension"); + expect(output).toContain("First prompt:"); + }); + + test("keeps previous manual input stable when a later prompt is active", async () => { + const dialog = createDialog(); + + const manualInput = dialog.showManualInput("Paste callback URL:"); + dialog.handleInput("callback-value"); + dialog.handleInput("\n"); + await expect(manualInput).resolves.toBe("callback-value"); + + const prompt = dialog.showPrompt("Second prompt:"); + dialog.handleInput("second-secret-demo"); + + const lines = renderDialog(dialog); + expect(lines.join("\n")).toContain("Paste callback URL:"); + expect(lines.join("\n")).toContain("Second prompt:"); + expect(countRenderedValue(lines, "callback-value")).toBe(1); + expect(countRenderedValue(lines, "second-secret-demo")).toBe(1); + + dialog.handleInput("\n"); + await expect(prompt).resolves.toBe("second-secret-demo"); + }); +}); diff --git a/packages/coding-agent/test/trigger-compact-extension.test.ts b/packages/coding-agent/test/trigger-compact-extension.test.ts index c114fac9..80f3d46c 100644 --- a/packages/coding-agent/test/trigger-compact-extension.test.ts +++ b/packages/coding-agent/test/trigger-compact-extension.test.ts @@ -12,6 +12,7 @@ function createContext(tokens: number | null, compact = vi.fn()): ExtensionConte modelRegistry: {} as ExtensionContext["modelRegistry"], model: undefined, isIdle: () => true, + isProjectTrusted: () => true, signal: undefined, abort: vi.fn(), hasPendingMessages: () => false, diff --git a/packages/coding-agent/test/trust-manager.test.ts b/packages/coding-agent/test/trust-manager.test.ts index d91dde49..2716da36 100644 --- a/packages/coding-agent/test/trust-manager.test.ts +++ b/packages/coding-agent/test/trust-manager.test.ts @@ -2,7 +2,12 @@ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { hasProjectConfigDir, hasProjectTrustInputs, ProjectTrustStore } from "../src/core/trust-manager.ts"; +import { + getProjectTrustPath, + hasProjectConfigDir, + hasProjectTrustInputs, + ProjectTrustStore, +} from "../src/core/trust-manager.ts"; describe("ProjectTrustStore", () => { let tempDir: string; @@ -25,12 +30,52 @@ describe("ProjectTrustStore", () => { const store = new ProjectTrustStore(agentDir); expect(store.get(cwd)).toBeNull(); + expect(store.getEntry(cwd)).toBeNull(); store.set(cwd, true); expect(store.get(cwd)).toBe(true); + expect(store.getEntry(cwd)).toEqual({ path: getProjectTrustPath(cwd), decision: true }); store.set(cwd, false); expect(store.get(cwd)).toBe(false); + expect(store.getEntry(cwd)).toEqual({ path: getProjectTrustPath(cwd), decision: false }); store.set(cwd, null); expect(store.get(cwd)).toBeNull(); + expect(store.getEntry(cwd)).toBeNull(); + }); + + it("inherits the closest saved decision from parent directories", () => { + const store = new ProjectTrustStore(agentDir); + const parentDir = join(tempDir, "trusted-parent"); + const childDir = join(parentDir, "project"); + const grandchildDir = join(childDir, "nested"); + mkdirSync(grandchildDir, { recursive: true }); + + store.set(parentDir, true); + expect(store.get(childDir)).toBe(true); + expect(store.getEntry(childDir)).toEqual({ path: getProjectTrustPath(parentDir), decision: true }); + expect(store.get(grandchildDir)).toBe(true); + expect(store.getEntry(grandchildDir)).toEqual({ path: getProjectTrustPath(parentDir), decision: true }); + + store.set(childDir, false); + expect(store.get(grandchildDir)).toBe(false); + expect(store.getEntry(grandchildDir)).toEqual({ path: getProjectTrustPath(childDir), decision: false }); + }); + + it("can clear a child override to inherit parent trust", () => { + const store = new ProjectTrustStore(agentDir); + const parentDir = join(tempDir, "trusted-parent"); + const childDir = join(parentDir, "project"); + mkdirSync(childDir, { recursive: true }); + + store.set(parentDir, true); + store.set(childDir, false); + expect(store.getEntry(childDir)).toEqual({ path: getProjectTrustPath(childDir), decision: false }); + + store.setMany([ + { path: parentDir, decision: true }, + { path: childDir, decision: null }, + ]); + expect(store.get(childDir)).toBe(true); + expect(store.getEntry(childDir)).toEqual({ path: getProjectTrustPath(parentDir), decision: true }); }); it("fails loudly without overwriting malformed trust stores", () => { @@ -53,9 +98,13 @@ describe("ProjectTrustStore", () => { rmSync(join(cwd, ".pi"), { recursive: true, force: true }); writeFileSync(join(cwd, "AGENTS.md"), "Project instructions"); - expect(hasProjectTrustInputs(cwd)).toBe(true); + expect(hasProjectTrustInputs(cwd)).toBe(false); rmSync(join(cwd, "AGENTS.md"), { force: true }); + writeFileSync(join(cwd, "CLAUDE.md"), "Legacy project instructions"); + expect(hasProjectTrustInputs(cwd)).toBe(false); + rmSync(join(cwd, "CLAUDE.md"), { force: true }); + mkdirSync(join(cwd, ".agents", "skills"), { recursive: true }); expect(hasProjectTrustInputs(cwd)).toBe(true); }); diff --git a/packages/coding-agent/test/trust-selector.test.ts b/packages/coding-agent/test/trust-selector.test.ts index 65c73c21..33be9a97 100644 --- a/packages/coding-agent/test/trust-selector.test.ts +++ b/packages/coding-agent/test/trust-selector.test.ts @@ -17,7 +17,7 @@ describe("TrustSelectorComponent", () => { it("marks the saved trusted decision", () => { const selector = new TrustSelectorComponent({ cwd: "/project", - savedDecision: true, + savedDecision: { path: "/project", decision: true }, projectTrusted: true, onSelect: () => {}, onCancel: () => {}, @@ -25,7 +25,7 @@ describe("TrustSelectorComponent", () => { const output = stripAnsi(selector.render(120).join("\n")); - expect(output).toContain("Saved decision: trusted"); + expect(output).toContain("Saved decision: trusted (/project)"); expect(output).toContain("Current session: trusted"); expect(output).toContain("Trust ✓"); expect(output).not.toContain("Do not trust ✓"); @@ -43,6 +43,45 @@ describe("TrustSelectorComponent", () => { selector.handleInput("\n"); - expect(onSelect).toHaveBeenCalledWith(true); + expect(onSelect).toHaveBeenCalledWith({ trusted: true, updates: [{ path: "/project", decision: true }] }); + }); + + it("labels saved ancestor decisions as inherited", () => { + const selector = new TrustSelectorComponent({ + cwd: "/parent/project/nested", + savedDecision: { path: "/parent", decision: true }, + projectTrusted: true, + onSelect: () => {}, + onCancel: () => {}, + }); + + const output = stripAnsi(selector.render(120).join("\n")); + + expect(output).toContain("Saved decision: trusted (inherited from /parent)"); + }); + + it("adds a trust parent option", () => { + const onSelect = vi.fn(); + const selector = new TrustSelectorComponent({ + cwd: "/parent/project", + savedDecision: { path: "/parent", decision: true }, + projectTrusted: true, + onSelect, + onCancel: () => {}, + }); + + const output = stripAnsi(selector.render(120).join("\n")); + expect(output).toContain("Saved decision: trusted (inherited from /parent)"); + expect(output).toContain("Trust parent folder (/parent) ✓"); + + selector.handleInput("\n"); + + expect(onSelect).toHaveBeenCalledWith({ + trusted: true, + updates: [ + { path: "/parent", decision: true }, + { path: "/parent/project", decision: null }, + ], + }); }); }); diff --git a/packages/coding-agent/vitest.config.ts b/packages/coding-agent/vitest.config.ts index d3857107..67ce0fca 100644 --- a/packages/coding-agent/vitest.config.ts +++ b/packages/coding-agent/vitest.config.ts @@ -4,6 +4,7 @@ import { defineConfig } from "vitest/config"; const aiSrcIndex = fileURLToPath(new URL("../ai/src/index.ts", import.meta.url)); const aiSrcOAuth = fileURLToPath(new URL("../ai/src/oauth.ts", import.meta.url)); const agentSrcIndex = fileURLToPath(new URL("../agent/src/index.ts", import.meta.url)); +const tuiSrcIndex = fileURLToPath(new URL("../tui/src/index.ts", import.meta.url)); export default defineConfig({ test: { @@ -21,9 +22,11 @@ export default defineConfig({ { find: /^@earendil-works\/pi-ai$/, replacement: aiSrcIndex }, { find: /^@earendil-works\/pi-ai\/oauth$/, replacement: aiSrcOAuth }, { find: /^@earendil-works\/pi-agent-core$/, replacement: agentSrcIndex }, + { find: /^@earendil-works\/pi-tui$/, replacement: tuiSrcIndex }, { find: /^@mariozechner\/pi-ai$/, replacement: aiSrcIndex }, { find: /^@mariozechner\/pi-ai\/oauth$/, replacement: aiSrcOAuth }, { find: /^@mariozechner\/pi-agent-core$/, replacement: agentSrcIndex }, + { find: /^@mariozechner\/pi-tui$/, replacement: tuiSrcIndex }, ], }, }); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 3682b529..ef8a1ba1 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,10 +2,26 @@ ## [Unreleased] +## [0.79.1] - 2026-06-09 + +### Added + +- Added `AutocompleteProvider.triggerCharacters` so editor autocomplete can naturally trigger on provider-defined token prefixes ([#4703](https://github.com/earendil-works/pi/issues/4703)). + +### Fixed + +- Fixed IME hardware cursor positioning while slash-command autocomplete is visible ([#5283](https://github.com/earendil-works/pi/pull/5283) by [@smoosex](https://github.com/smoosex)). +- Fixed prompt history navigation to restore the current draft when returning from history browsing ([#5494](https://github.com/earendil-works/pi/issues/5494)). +- Fixed wrapping for mixed Latin and CJK text so unspaced CJK runs can break at grapheme boundaries without leaving large trailing gaps ([#5495](https://github.com/earendil-works/pi/issues/5495)). + +## [0.79.0] - 2026-06-08 + ### Fixed - Fixed prompt history navigation to place the cursor at the start when browsing upward and at the end when browsing downward, so repeated Up/Down traverses multiline prompts immediately ([#5454](https://github.com/earendil-works/pi/issues/5454)). - Fixed intermittent Shift+Enter handling by making Kitty keyboard protocol fallback response-driven instead of timeout-driven ([#5188](https://github.com/earendil-works/pi/issues/5188)). +- Fixed TUI rendering to clear stale lines when content shrinks to zero. +- Fixed autocomplete suggestions to re-query after editor cursor movement ([#5499](https://github.com/earendil-works/pi/pull/5499) by [@Roman-Galeev](https://github.com/Roman-Galeev)). ## [0.78.1] - 2026-06-04 diff --git a/packages/tui/package.json b/packages/tui/package.json index 224132e2..94ba86ad 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-tui", - "version": "0.78.1", + "version": "0.79.1", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "type": "module", "main": "dist/index.js", diff --git a/packages/tui/src/autocomplete.ts b/packages/tui/src/autocomplete.ts index 5408967d..205748d8 100644 --- a/packages/tui/src/autocomplete.ts +++ b/packages/tui/src/autocomplete.ts @@ -239,6 +239,9 @@ export interface AutocompleteSuggestions { } export interface AutocompleteProvider { + /** Characters that should naturally trigger this provider at token boundaries. */ + triggerCharacters?: string[]; + // Get autocomplete suggestions for current text/cursor position // Returns null if no suggestions available getSuggestions( diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index f485218c..724dd460 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -4,7 +4,14 @@ import { decodePrintableKey, matchesKey } from "../keys.ts"; import { KillRing } from "../kill-ring.ts"; import { type Component, CURSOR_MARKER, type Focusable, type TUI } from "../tui.ts"; import { UndoStack } from "../undo-stack.ts"; -import { getGraphemeSegmenter, getWordSegmenter, isWhitespaceChar, truncateToWidth, visibleWidth } from "../utils.ts"; +import { + cjkBreakRegex, + getGraphemeSegmenter, + getWordSegmenter, + isWhitespaceChar, + truncateToWidth, + visibleWidth, +} from "../utils.ts"; import { findWordBackward, findWordForward } from "../word-navigation.ts"; import { SelectList, type SelectListLayoutOptions, type SelectListTheme } from "./select-list.ts"; @@ -174,13 +181,21 @@ export function wordWrapLine(line: string, maxWidth: number, preSegmented?: Intl // Advance. currentWidth += gWidth; - // Record wrap opportunity: whitespace followed by non-whitespace. - // Multiple spaces join (no break between them); the break point is - // after the last space before the next word. + // Record wrap opportunity: whitespace followed by non-whitespace + // (multiple spaces join; the break point is after the last space), + // or at a boundary where either side is CJK (CJK allows breaking + // between any adjacent characters). const next = segments[i + 1]; if (isWs && next && (isPasteMarker(next.segment) || !isWhitespaceChar(next.segment))) { wrapOppIndex = next.index; wrapOppWidth = currentWidth; + } else if (!isWs && next && !isWhitespaceChar(next.segment)) { + const isCjk = !isPasteMarker(grapheme) && cjkBreakRegex.test(grapheme); + const nextIsCjk = !isPasteMarker(next.segment) && cjkBreakRegex.test(next.segment); + if (isCjk || nextIsCjk) { + wrapOppIndex = next.index; + wrapOppWidth = currentWidth; + } } } @@ -219,6 +234,20 @@ const SLASH_COMMAND_SELECT_LIST_LAYOUT: SelectListLayoutOptions = { }; const ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS = 20; +const DEFAULT_AUTOCOMPLETE_TRIGGER_CHARACTERS = ["@", "#"]; + +function escapeCharacterClass(value: string): string { + return value.replace(/[\\^$.*+?()[\]{}|-]/g, "\\$&"); +} + +function buildTriggerPattern(triggerCharacters: string[]): RegExp { + return new RegExp(`(?:^|[\\s])[${triggerCharacters.map(escapeCharacterClass).join("")}][^\\s]*$`); +} + +function buildDebouncePattern(triggerCharacters: string[]): RegExp { + const escapedWithoutAt = triggerCharacters.filter((character) => character !== "@").map(escapeCharacterClass); + return new RegExp(`(?:^|[ \\t])(?:@(?:"[^"]*|[^\\s]*)|[${escapedWithoutAt.join("")}][^\\s]*)$`); +} export class Editor implements Component, Focusable { private state: EditorState = { @@ -245,6 +274,9 @@ export class Editor implements Component, Focusable { // Autocomplete support private autocompleteProvider?: AutocompleteProvider; + private autocompleteTriggerCharacters = [...DEFAULT_AUTOCOMPLETE_TRIGGER_CHARACTERS]; + private autocompleteTriggerPattern = buildTriggerPattern(this.autocompleteTriggerCharacters); + private autocompleteDebouncePattern = buildDebouncePattern(this.autocompleteTriggerCharacters); private autocompleteList?: SelectList; private autocompleteState: "regular" | "force" | null = null; private autocompletePrefix: string = ""; @@ -266,6 +298,7 @@ export class Editor implements Component, Focusable { // Prompt history for up/down navigation private history: string[] = []; private historyIndex: number = -1; // -1 = not browsing, 0 = most recent, 1 = older, etc. + private historyDraft: EditorState | null = null; // Kill ring for Emacs-style kill/yank operations private killRing = new KillRing(); @@ -338,6 +371,7 @@ export class Editor implements Component, Focusable { setAutocompleteProvider(provider: AutocompleteProvider): void { this.cancelAutocomplete(); this.autocompleteProvider = provider; + this.setAutocompleteTriggerCharacters(provider.triggerCharacters ?? []); } /** @@ -356,10 +390,6 @@ export class Editor implements Component, Focusable { } } - private isEditorEmpty(): boolean { - return this.state.lines.length === 1 && this.state.lines[0] === ""; - } - private isOnFirstVisualLine(): boolean { const visualLines = this.buildVisualLineMap(this.lastWidth); const currentVisualLine = this.findCurrentVisualLine(visualLines); @@ -382,18 +412,33 @@ export class Editor implements Component, Focusable { // Capture state when first entering history browsing mode if (this.historyIndex === -1 && newIndex >= 0) { this.pushUndoSnapshot(); + this.historyDraft = structuredClone(this.state); } this.historyIndex = newIndex; if (this.historyIndex === -1) { - // Returned to "current" state - clear editor - this.setTextInternal(""); + const draft = this.historyDraft; + this.historyDraft = null; + if (draft) { + this.state = draft; + this.preferredVisualCol = null; + this.snappedFromCursorCol = null; + this.scrollOffset = 0; + if (this.onChange) this.onChange(this.getText()); + } else { + this.setTextInternal(""); + } } else { this.setTextInternal(this.history[this.historyIndex] || "", direction === -1 ? "start" : "end"); } } + private exitHistoryBrowsing(): void { + this.historyIndex = -1; + this.historyDraft = null; + } + /** Internal setText that doesn't reset history state - used by navigateHistory */ private setTextInternal(text: string, cursorPlacement: "start" | "end" = "end"): void { const lines = text.split("\n"); @@ -469,8 +514,10 @@ export class Editor implements Component, Focusable { } // Render each visible layout line - // Emit hardware cursor marker only when focused and not showing autocomplete - const emitCursorMarker = this.focused && !this.autocompleteState; + // Emit hardware cursor marker when focused so TUI can position the + // hardware cursor for IME candidate-window placement even while + // autocomplete (e.g. slash-command menu) is visible. + const emitCursorMarker = this.focused; for (const layoutLine of visibleLines) { let displayText = layoutLine.text; @@ -756,9 +803,7 @@ export class Editor implements Component, Focusable { // Arrow key navigation (with history support) if (kb.matches(data, "tui.editor.cursorUp")) { - if (this.isEditorEmpty()) { - this.navigateHistory(-1); - } else if (this.historyIndex > -1 && this.isOnFirstVisualLine()) { + if (this.isOnFirstVisualLine() && this.history.length > 0) { this.navigateHistory(-1); } else if (this.isOnFirstVisualLine()) { // Already at top - jump to start of line @@ -946,7 +991,7 @@ export class Editor implements Component, Focusable { setText(text: string): void { this.cancelAutocomplete(); this.lastAction = null; - this.historyIndex = -1; // Exit history browsing mode + this.exitHistoryBrowsing(); const normalized = this.normalizeText(text); // Push undo snapshot if content differs (makes programmatic changes undoable) if (this.getText() !== normalized) { @@ -965,7 +1010,7 @@ export class Editor implements Component, Focusable { this.cancelAutocomplete(); this.pushUndoSnapshot(); this.lastAction = null; - this.historyIndex = -1; + this.exitHistoryBrowsing(); this.insertTextAtCursorInternal(text); } @@ -1028,7 +1073,7 @@ export class Editor implements Component, Focusable { // All the editor methods from before... private insertCharacter(char: string, skipUndoCoalescing?: boolean): void { - this.historyIndex = -1; // Exit history browsing mode + this.exitHistoryBrowsing(); // Undo coalescing (fish-style): // - Consecutive word chars coalesce into one undo unit @@ -1060,8 +1105,8 @@ export class Editor implements Component, Focusable { if (char === "/" && this.isAtStartOfMessage()) { this.tryTriggerAutocomplete(); } - // Auto-trigger for symbol-based completion like @ or # at token boundaries - else if (char === "@" || char === "#") { + // Auto-trigger for symbol-based completion like @, #, or provider triggers at token boundaries + else if (this.autocompleteTriggerCharacters.includes(char)) { const currentLine = this.state.lines[this.state.cursorLine] || ""; const textBeforeCursor = currentLine.slice(0, this.state.cursorCol); const charBeforeSymbol = textBeforeCursor[textBeforeCursor.length - 2]; @@ -1077,8 +1122,8 @@ export class Editor implements Component, Focusable { if (this.isInSlashCommandContext(textBeforeCursor)) { this.tryTriggerAutocomplete(); } - // Check if we're in a symbol-based completion context like @ or # - else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) { + // Check if we're in a symbol-based completion context like @, #, or provider triggers + else if (this.autocompleteTriggerPattern.test(textBeforeCursor)) { this.tryTriggerAutocomplete(); } } @@ -1089,7 +1134,7 @@ export class Editor implements Component, Focusable { private handlePaste(pastedText: string): void { this.cancelAutocomplete(); - this.historyIndex = -1; // Exit history browsing mode + this.exitHistoryBrowsing(); this.lastAction = null; this.pushUndoSnapshot(); @@ -1157,7 +1202,7 @@ export class Editor implements Component, Focusable { private addNewLine(): void { this.cancelAutocomplete(); - this.historyIndex = -1; // Exit history browsing mode + this.exitHistoryBrowsing(); this.lastAction = null; this.pushUndoSnapshot(); @@ -1198,7 +1243,7 @@ export class Editor implements Component, Focusable { this.state = { lines: [""], cursorLine: 0, cursorCol: 0 }; this.pastes.clear(); this.pasteCounter = 0; - this.historyIndex = -1; + this.exitHistoryBrowsing(); this.scrollOffset = 0; this.undoStack.clear(); this.lastAction = null; @@ -1208,7 +1253,7 @@ export class Editor implements Component, Focusable { } private handleBackspace(): void { - this.historyIndex = -1; // Exit history browsing mode + this.exitHistoryBrowsing(); this.lastAction = null; if (this.state.cursorCol > 0) { @@ -1257,8 +1302,8 @@ export class Editor implements Component, Focusable { if (this.isInSlashCommandContext(textBeforeCursor)) { this.tryTriggerAutocomplete(); } - // Symbol-based completion context like @ or # - else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) { + // Symbol-based completion context like @, #, or provider triggers + else if (this.autocompleteTriggerPattern.test(textBeforeCursor)) { this.tryTriggerAutocomplete(); } } @@ -1425,7 +1470,7 @@ export class Editor implements Component, Focusable { } private deleteToStartOfLine(): void { - this.historyIndex = -1; // Exit history browsing mode + this.exitHistoryBrowsing(); const currentLine = this.state.lines[this.state.cursorLine] || ""; @@ -1460,7 +1505,7 @@ export class Editor implements Component, Focusable { } private deleteToEndOfLine(): void { - this.historyIndex = -1; // Exit history browsing mode + this.exitHistoryBrowsing(); const currentLine = this.state.lines[this.state.cursorLine] || ""; @@ -1492,7 +1537,7 @@ export class Editor implements Component, Focusable { } private deleteWordBackwards(): void { - this.historyIndex = -1; // Exit history browsing mode + this.exitHistoryBrowsing(); const currentLine = this.state.lines[this.state.cursorLine] || ""; @@ -1537,7 +1582,7 @@ export class Editor implements Component, Focusable { } private deleteWordForward(): void { - this.historyIndex = -1; // Exit history browsing mode + this.exitHistoryBrowsing(); const currentLine = this.state.lines[this.state.cursorLine] || ""; @@ -1579,7 +1624,7 @@ export class Editor implements Component, Focusable { } private handleForwardDelete(): void { - this.historyIndex = -1; // Exit history browsing mode + this.exitHistoryBrowsing(); this.lastAction = null; const currentLine = this.state.lines[this.state.cursorLine] || ""; @@ -1621,8 +1666,8 @@ export class Editor implements Component, Focusable { if (this.isInSlashCommandContext(textBeforeCursor)) { this.tryTriggerAutocomplete(); } - // Symbol-based completion context like @ or # - else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) { + // Symbol-based completion context like @, #, or provider triggers + else if (this.autocompleteTriggerPattern.test(textBeforeCursor)) { this.tryTriggerAutocomplete(); } } @@ -1835,7 +1880,7 @@ export class Editor implements Component, Focusable { * Insert text at cursor position (used by yank operations). */ private insertYankedText(text: string): void { - this.historyIndex = -1; // Exit history browsing mode + this.exitHistoryBrowsing(); const lines = text.split("\n"); if (lines.length === 1) { @@ -1920,7 +1965,7 @@ export class Editor implements Component, Focusable { } private undo(): void { - this.historyIndex = -1; // Exit history browsing mode + this.exitHistoryBrowsing(); const snapshot = this.undoStack.pop(); if (!snapshot) return; Object.assign(this.state, snapshot); @@ -2120,6 +2165,19 @@ export class Editor implements Component, Focusable { await this.autocompleteRequestTask; } + private setAutocompleteTriggerCharacters(triggerCharacters: string[]): void { + const next = [...DEFAULT_AUTOCOMPLETE_TRIGGER_CHARACTERS]; + for (const character of triggerCharacters) { + if (character.length !== 1 || character === "/" || isWhitespaceChar(character) || next.includes(character)) { + continue; + } + next.push(character); + } + this.autocompleteTriggerCharacters = next; + this.autocompleteTriggerPattern = buildTriggerPattern(next); + this.autocompleteDebouncePattern = buildDebouncePattern(next); + } + private getAutocompleteDebounceMs(options: { force: boolean; explicitTab: boolean }): number { if (options.explicitTab || options.force) { return 0; @@ -2127,8 +2185,7 @@ export class Editor implements Component, Focusable { const currentLine = this.state.lines[this.state.cursorLine] || ""; const textBeforeCursor = currentLine.slice(0, this.state.cursorCol); - const isSymbolAutocompleteContext = /(?:^|[ \t])(?:@(?:"[^"]*|[^\s]*)|#[^\s]*)$/.test(textBeforeCursor); - return isSymbolAutocompleteContext ? ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS : 0; + return this.autocompleteDebouncePattern.test(textBeforeCursor) ? ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS : 0; } private async runAutocompleteRequest( diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index d5e3f477..ed8ad780 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -572,6 +572,7 @@ export class Markdown implements Component { for (let i = 0; i < token.items.length; i++) { const item = token.items[i]; + const isLastItem = i === token.items.length - 1; const bullet = token.ordered ? this.options.preserveOrderedListMarkers ? (this.getOrderedListMarker(item) ?? `${startNumber + i}. `) @@ -604,6 +605,10 @@ export class Markdown implements Component { if (!renderedAnyLine) { lines.push(firstPrefix); } + + if (token.loose && !isLastItem) { + lines.push(""); + } } return lines; diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index 02c40c79..bf228ce0 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -45,6 +45,9 @@ const rgiEmojiRegex = /^\p{RGI_Emoji}$/v; const WIDTH_CACHE_SIZE = 512; const widthCache = new Map(); +export const cjkBreakRegex = + /[\p{Script_Extensions=Han}\p{Script_Extensions=Hiragana}\p{Script_Extensions=Katakana}\p{Script_Extensions=Hangul}\p{Script_Extensions=Bopomofo}]/u; + function isPrintableAscii(str: string): boolean { for (let i = 0; i < str.length; i++) { const code = str.charCodeAt(i); @@ -605,9 +608,18 @@ function splitIntoTokensWithAnsi(text: string): string[] { const tokens: string[] = []; let current = ""; let pendingAnsi = ""; // ANSI codes waiting to be attached to next visible content - let inWhitespace = false; + let currentKind: "space" | "word" | null = null; let i = 0; + const flushCurrent = (): void => { + if (!current) { + return; + } + tokens.push(current); + current = ""; + currentKind = null; + }; + while (i < text.length) { const ansiResult = extractAnsiCode(text, i); if (ansiResult) { @@ -617,29 +629,48 @@ function splitIntoTokensWithAnsi(text: string): string[] { continue; } - const char = text[i]; - const charIsSpace = char === " "; - - if (charIsSpace !== inWhitespace && current) { - // Switching between whitespace and non-whitespace, push current token - tokens.push(current); - current = ""; + let end = i; + while (end < text.length && !extractAnsiCode(text, end)) { + end++; } - // Attach any pending ANSI codes to this visible character - if (pendingAnsi) { - current += pendingAnsi; - pendingAnsi = ""; + for (const { segment } of graphemeSegmenter.segment(text.slice(i, end))) { + const segmentIsSpace = segment === " "; + if (!segmentIsSpace && cjkBreakRegex.test(segment)) { + flushCurrent(); + const token = pendingAnsi + segment; + pendingAnsi = ""; + tokens.push(token); + continue; + } + + const segmentKind = segmentIsSpace ? "space" : "word"; + if (current && currentKind !== segmentKind) { + flushCurrent(); + } + + // Attach any pending ANSI codes to this visible character + if (pendingAnsi) { + current += pendingAnsi; + pendingAnsi = ""; + } + + currentKind = segmentKind; + current += segment; } - inWhitespace = charIsSpace; - current += char; - i++; + i = end; } // Handle any remaining pending ANSI codes (attach to last token) if (pendingAnsi) { - current += pendingAnsi; + if (current) { + current += pendingAnsi; + } else if (tokens.length > 0) { + tokens[tokens.length - 1] += pendingAnsi; + } else { + current = pendingAnsi; + } } if (current) { diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index 9f92643a..2c8a7b62 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -79,16 +79,20 @@ describe("Editor component", () => { assert.strictEqual(editor.getText(), "first"); }); - it("returns to empty editor on Down arrow after browsing history", () => { + it("restores draft on Down arrow after browsing history", () => { const editor = new Editor(createTestTUI(), defaultEditorTheme); editor.addToHistory("prompt"); + editor.setText("draft"); + editor.handleInput("\x1b[D"); + editor.handleInput("\x1b[D"); editor.handleInput("\x1b[A"); // Up - shows "prompt" assert.strictEqual(editor.getText(), "prompt"); - editor.handleInput("\x1b[B"); // Down - clears editor - assert.strictEqual(editor.getText(), ""); + editor.handleInput("\x1b[B"); // Down - restores draft + assert.strictEqual(editor.getText(), "draft"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 3 }); }); it("navigates forward through history with Down arrow", () => { @@ -97,6 +101,7 @@ describe("Editor component", () => { editor.addToHistory("first"); editor.addToHistory("second"); editor.addToHistory("third"); + editor.setText("draft"); // Go to oldest editor.handleInput("\x1b[A"); // third @@ -110,8 +115,8 @@ describe("Editor component", () => { editor.handleInput("\x1b[B"); // third assert.strictEqual(editor.getText(), "third"); - editor.handleInput("\x1b[B"); // empty - assert.strictEqual(editor.getText(), ""); + editor.handleInput("\x1b[B"); // draft + assert.strictEqual(editor.getText(), "draft"); }); it("exits history mode when typing a character", () => { @@ -2342,6 +2347,58 @@ describe("Editor component", () => { assert.strictEqual(editor.isShowingAutocomplete(), true); }); + it("debounces custom triggerCharacters autocomplete while typing", async () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + let suggestionCalls = 0; + + editor.setAutocompleteProvider({ + triggerCharacters: ["$"], + getSuggestions: async (lines, _cursorLine, cursorCol) => { + suggestionCalls += 1; + const prefix = (lines[0] || "").slice(0, cursorCol); + return { items: [{ value: "$skill-name", label: "skill-name" }], prefix }; + }, + applyCompletion, + }); + + editor.handleInput("$"); + editor.handleInput("s"); + editor.handleInput("k"); + + assert.strictEqual(suggestionCalls, 0); + await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAutocomplete(); + + assert.strictEqual(suggestionCalls, 1); + assert.strictEqual(editor.isShowingAutocomplete(), true); + }); + + it("resets custom triggerCharacters when provider changes", async () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + let suggestionCalls = 0; + + editor.setAutocompleteProvider({ + triggerCharacters: ["$"], + getSuggestions: async () => ({ items: [{ value: "$skill-name", label: "skill-name" }], prefix: "$" }), + applyCompletion, + }); + editor.setAutocompleteProvider({ + getSuggestions: async () => { + suggestionCalls += 1; + return { items: [{ value: "$skill-name", label: "skill-name" }], prefix: "$" }; + }, + applyCompletion, + }); + + editor.handleInput("$"); + editor.handleInput("s"); + await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAutocomplete(); + + assert.strictEqual(suggestionCalls, 0); + assert.strictEqual(editor.isShowingAutocomplete(), false); + }); + it("aborts active @ autocomplete when typing continues", async () => { const editor = new Editor(createTestTUI(), defaultEditorTheme); let aborts = 0; diff --git a/packages/tui/test/markdown.test.ts b/packages/tui/test/markdown.test.ts index fd5345df..9c0be05c 100644 --- a/packages/tui/test/markdown.test.ts +++ b/packages/tui/test/markdown.test.ts @@ -149,6 +149,37 @@ describe("Markdown component", () => { assert.ok(plainLines.some((line) => line.includes("2. Second ordered"))); }); + it("should render blank lines between loose list items", () => { + const markdown = new Markdown( + `1. Lorem ipsum dolor sit amet. + + Ut enim ad minim veniam. + +2. Duis aute irure dolor. + + Excepteur sint occaecat cupidatat. + +3. Beep boop`, + 0, + 0, + defaultMarkdownTheme, + ); + + const lines = markdown.render(80).map((line) => stripAnsi(line).trimEnd()); + + assert.deepStrictEqual(lines, [ + "1. Lorem ipsum dolor sit amet.", + "", + " Ut enim ad minim veniam.", + "", + "2. Duis aute irure dolor.", + "", + " Excepteur sint occaecat cupidatat.", + "", + "3. Beep boop", + ]); + }); + it("should render task list markers", () => { const markdown = new Markdown("- [ ] beep\n- [x] boop", 0, 0, defaultMarkdownTheme); diff --git a/packages/tui/test/wrap-ansi.test.ts b/packages/tui/test/wrap-ansi.test.ts index 52d59148..a1183f75 100644 --- a/packages/tui/test/wrap-ansi.test.ts +++ b/packages/tui/test/wrap-ansi.test.ts @@ -111,6 +111,30 @@ describe("wrapTextWithAnsi", () => { } }); + it("should break CJK runs at grapheme boundaries after Latin text", () => { + const text = "This is an example 中文汉字测试段落内容中文汉字测试段落内容."; + const wrapped = wrapTextWithAnsi(text, 40); + + assert.deepStrictEqual(wrapped, ["This is an example 中文汉字测试段落内容", "中文汉字测试段落内容."]); + for (const line of wrapped) { + assert.ok(visibleWidth(line) <= 40); + } + }); + + it("should preserve color codes when wrapping CJK runs", () => { + const red = "\x1b[31m"; + const reset = "\x1b[0m"; + const text = `${red}This is an example 中文汉字测试段落内容中文汉字测试段落内容.${reset}`; + const wrapped = wrapTextWithAnsi(text, 40); + + assert.strictEqual(wrapped.length, 2); + assert.strictEqual(wrapped[0], `${red}This is an example 中文汉字测试段落内容`); + assert.strictEqual(wrapped[1], `${red}中文汉字测试段落内容.${reset}`); + for (const line of wrapped) { + assert.ok(visibleWidth(line) <= 40); + } + }); + it("should ignore OSC 133 semantic markers in visible width", () => { const text = "\x1b]133;A\x07hello\x1b]133;B\x07"; assert.strictEqual(visibleWidth(text), 5); diff --git a/scripts/release-notes.mjs b/scripts/release-notes.mjs new file mode 100644 index 00000000..545bdc4f --- /dev/null +++ b/scripts/release-notes.mjs @@ -0,0 +1,364 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +const DEFAULT_REPO = "earendil-works/pi"; +const DEFAULT_BASE_PATH = "packages/coding-agent"; +const DEFAULT_CHANGELOG = "packages/coding-agent/CHANGELOG.md"; +const DEFAULT_FIX_SINCE_TAG = "v0.74.0"; +const LEGACY_REPO_RE = /^https:\/\/github\.com\/(?:badlogic|earendil-works)\/pi-mono(?=\/|$)/; +const URL_SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i; +const INLINE_MARKDOWN_LINK_RE = /(!?\[[^\]\n]+\]\()([^\s)]+)((?:\s+[^)]*)?\))/g; + +function printUsage() { + console.log(`Usage: node scripts/release-notes.mjs [options] + +Commands: + extract Extract release notes from the coding-agent changelog + fix-github-releases Rewrite existing GitHub release note links in place + +extract options: + --version Version to extract + --tag Release tag used for repository links (defaults to v) + --changelog Changelog path (default: ${DEFAULT_CHANGELOG}) + --out Output file (default: stdout) + --repo GitHub repository for generated links (default: ${DEFAULT_REPO}) + --base-path Base path for relative changelog links (default: ${DEFAULT_BASE_PATH}) + +fix-github-releases options: + --repo GitHub repository to patch (default: ${DEFAULT_REPO}) + --tag Patch only one release tag + --since-tag Oldest release tag to patch (default: ${DEFAULT_FIX_SINCE_TAG}) + --base-path Base path for relative changelog links (default: ${DEFAULT_BASE_PATH}) + --dry-run Print releases that would change without updating GitHub +`); +} + +function commandForPlatform(command) { + return process.platform === "win32" ? `${command}.cmd` : command; +} + +function run(command, args, options = {}) { + const result = spawnSync(commandForPlatform(command), args, { + cwd: options.cwd, + encoding: "utf8", + maxBuffer: options.maxBuffer ?? 20 * 1024 * 1024, + stdio: options.capture ? ["inherit", "pipe", "pipe"] : "inherit", + }); + + if (result.status !== 0) { + const output = [result.stdout, result.stderr].filter(Boolean).join("\n"); + throw new Error(output ? `Command failed: ${command} ${args.join(" ")}\n${output}` : `Command failed: ${command} ${args.join(" ")}`); + } + + return result.stdout ?? ""; +} + +function parseOptions(args) { + const options = { + basePath: DEFAULT_BASE_PATH, + changelog: DEFAULT_CHANGELOG, + dryRun: false, + out: undefined, + repo: DEFAULT_REPO, + sinceTag: DEFAULT_FIX_SINCE_TAG, + tag: undefined, + version: undefined, + }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === "--help") { + printUsage(); + process.exit(0); + } + if (arg === "--dry-run") { + options.dryRun = true; + continue; + } + + const optionNames = new Set(["--base-path", "--changelog", "--out", "--repo", "--since-tag", "--tag", "--version"]); + if (!optionNames.has(arg)) { + throw new Error(`Unknown option: ${arg}`); + } + + const value = args[++i]; + if (!value) { + throw new Error(`${arg} requires a value`); + } + + if (arg === "--base-path") options.basePath = value; + if (arg === "--changelog") options.changelog = value; + if (arg === "--out") options.out = value; + if (arg === "--repo") options.repo = value; + if (arg === "--since-tag") options.sinceTag = value; + if (arg === "--tag") options.tag = value; + if (arg === "--version") options.version = value; + } + + return options; +} + +function normalizeTag(tagOrVersion) { + if (!tagOrVersion) { + return undefined; + } + return tagOrVersion.startsWith("v") ? tagOrVersion : `v${tagOrVersion}`; +} + +function versionFromTag(tag) { + return tag.startsWith("v") ? tag.slice(1) : tag; +} + +function compareVersions(a, b) { + const aParts = versionFromTag(a).split(".").map(Number); + const bParts = versionFromTag(b).split(".").map(Number); + + for (let i = 0; i < 3; i++) { + const diff = (aParts[i] || 0) - (bParts[i] || 0); + if (diff !== 0) { + return diff; + } + } + + return 0; +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function extractChangelogSection(changelog, version) { + const headingRe = new RegExp(`^## \\[${escapeRegExp(version)}\\](?:\\s+-\\s+\\d{4}-\\d{2}-\\d{2})?\\s*$`, "m"); + const heading = headingRe.exec(changelog); + + if (!heading) { + return ""; + } + + const sectionStart = heading.index + heading[0].length; + const rest = changelog.slice(sectionStart); + const nextHeading = rest.search(/^## \[/m); + const section = nextHeading === -1 ? rest : rest.slice(0, nextHeading); + return section.trim(); +} + +function splitLocalTarget(target) { + const hashIndex = target.indexOf("#"); + const beforeHash = hashIndex === -1 ? target : target.slice(0, hashIndex); + const fragment = hashIndex === -1 ? "" : target.slice(hashIndex); + const queryIndex = beforeHash.indexOf("?"); + + if (queryIndex === -1) { + return { fragment, pathPart: beforeHash, query: "" }; + } + + return { + fragment, + pathPart: beforeHash.slice(0, queryIndex), + query: beforeHash.slice(queryIndex), + }; +} + +function normalizePathPart(value) { + return value.replaceAll("\\", "/"); +} + +function normalizeBasePath(basePath) { + const normalized = path.posix.normalize(normalizePathPart(basePath)).replace(/\/+$/, ""); + return normalized === "." ? "" : normalized; +} + +function resolveRepositoryPath(targetPath, basePath) { + const normalizedTarget = normalizePathPart(targetPath); + const joined = normalizedTarget.startsWith("/") + ? path.posix.normalize(normalizedTarget.replace(/^\/+/, "")) + : path.posix.normalize(path.posix.join(normalizeBasePath(basePath), normalizedTarget)); + + if (joined === "." || joined.startsWith("../") || joined === "..") { + return undefined; + } + + return joined; +} + +function isDirectoryTarget(originalPath, repositoryPath) { + if (originalPath.endsWith("/")) { + return true; + } + + const basename = path.posix.basename(repositoryPath); + return !basename.includes("."); +} + +function normalizeLinkTarget(target, options) { + let canonicalTarget = target.replace(LEGACY_REPO_RE, `https://github.com/${options.repo}`); + const repoUrl = `https://github.com/${options.repo}`; + + for (const route of ["blob", "tree"]) { + for (const branch of ["main", "master"]) { + const floatingRefPrefix = `${repoUrl}/${route}/${branch}/`; + if (canonicalTarget.startsWith(floatingRefPrefix)) { + canonicalTarget = `${repoUrl}/${route}/${options.tag}/${canonicalTarget.slice(floatingRefPrefix.length)}`; + } + } + } + + if (canonicalTarget.startsWith("#") || canonicalTarget.startsWith("//") || URL_SCHEME_RE.test(canonicalTarget)) { + return canonicalTarget; + } + + const { fragment, pathPart, query } = splitLocalTarget(canonicalTarget); + if (!pathPart) { + return canonicalTarget; + } + + const repositoryPath = resolveRepositoryPath(pathPart, options.basePath); + if (!repositoryPath) { + return canonicalTarget; + } + + const route = isDirectoryTarget(pathPart, repositoryPath) ? "tree" : "blob"; + return `https://github.com/${options.repo}/${route}/${options.tag}/${encodeURI(repositoryPath)}${query}${fragment}`; +} + +function normalizeReleaseNoteLinks(markdown, options) { + const changes = []; + const normalized = markdown.replace(INLINE_MARKDOWN_LINK_RE, (match, prefix, target, suffix) => { + const normalizedTarget = normalizeLinkTarget(target, options); + if (normalizedTarget !== target) { + changes.push({ from: target, to: normalizedTarget }); + } + return `${prefix}${normalizedTarget}${suffix}`; + }); + + return { changes, markdown: normalized }; +} + +function writeOutput(content, outPath) { + if (outPath) { + writeFileSync(outPath, content); + return; + } + + process.stdout.write(content); +} + +function extractReleaseNotes(options) { + const version = options.version ?? (options.tag ? versionFromTag(options.tag) : undefined); + if (!version) { + throw new Error("extract requires --version or --tag"); + } + + if (!existsSync(options.changelog)) { + throw new Error(`Changelog does not exist: ${options.changelog}`); + } + + const tag = normalizeTag(options.tag ?? version); + const changelog = readFileSync(options.changelog, "utf8"); + const section = extractChangelogSection(changelog, version); + const rawNotes = section ? `${section}\n` : `Release ${version}\n`; + const { markdown } = normalizeReleaseNoteLinks(rawNotes, { basePath: options.basePath, repo: options.repo, tag }); + writeOutput(markdown, options.out); +} + +function listGithubReleases(repo) { + const output = run("gh", ["api", `repos/${repo}/releases`, "--paginate", "--jq", ".[] | {id, tag_name, body} | @json"], { + capture: true, + }); + return output + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => JSON.parse(line)); +} + +function uniqueChanges(changes) { + const seen = new Set(); + const unique = []; + for (const change of changes) { + const key = `${change.from}\n${change.to}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + unique.push(change); + } + return unique; +} + +function updateGithubRelease(repo, tag, body) { + const tempDir = mkdtempSync(path.join(tmpdir(), "pi-release-notes-")); + try { + const notesPath = path.join(tempDir, "notes.md"); + writeFileSync(notesPath, body); + run("gh", ["release", "edit", tag, "--repo", repo, "--notes-file", notesPath], { capture: true }); + } finally { + rmSync(tempDir, { force: true, recursive: true }); + } +} + +function fixGithubReleases(options) { + const tagFilter = normalizeTag(options.tag); + const sinceTag = normalizeTag(options.sinceTag); + const matchingReleases = listGithubReleases(options.repo).filter((release) => !tagFilter || release.tag_name === tagFilter); + + if (tagFilter && matchingReleases.length === 0) { + throw new Error(`Release not found: ${tagFilter}`); + } + + const releases = matchingReleases.filter((release) => compareVersions(release.tag_name, sinceTag) >= 0); + if (tagFilter && releases.length === 0) { + console.log(`Skipping ${tagFilter}: older than ${sinceTag}.`); + console.log(`${options.dryRun ? "Would update" : "Updated"} 0 releases.`); + return; + } + + let changedCount = 0; + for (const release of releases) { + const tag = release.tag_name; + const body = release.body ?? ""; + const result = normalizeReleaseNoteLinks(body, { basePath: options.basePath, repo: options.repo, tag }); + if (result.markdown === body) { + continue; + } + + changedCount++; + const unique = uniqueChanges(result.changes); + console.log(`${options.dryRun ? "Would update" : "Updating"} ${tag} (${unique.length} link${unique.length === 1 ? "" : "s"})`); + for (const change of unique) { + console.log(` ${change.from}`); + console.log(` -> ${change.to}`); + } + + if (!options.dryRun) { + updateGithubRelease(options.repo, tag, result.markdown); + } + } + + const prefix = options.dryRun ? "Would update" : "Updated"; + console.log(`${prefix} ${changedCount} release${changedCount === 1 ? "" : "s"}.`); +} + +try { + const [command, ...args] = process.argv.slice(2); + if (!command || command === "--help") { + printUsage(); + process.exit(command ? 0 : 1); + } + + const options = parseOptions(args); + if (command === "extract") { + extractReleaseNotes(options); + } else if (command === "fix-github-releases") { + fixGithubReleases(options); + } else { + throw new Error(`Unknown command: ${command}`); + } +} catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +}