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.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 6fb973c9..860fdb43 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.79.0] - 2026-06-08 ### Fixed diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index dd4ef7cd..13a5f688 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [Unreleased] + +### Fixed + +- 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)). + ## [0.79.0] - 2026-06-08 ### Fixed diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index d10feb12..408a8034 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"]); @@ -1058,6 +1057,10 @@ async function loadModelsDevData(): Promise[]> { } } + if (api === "openai-completions") { + compat = { ...(compat ?? {}), maxTokensField: "max_tokens" }; + } + models.push({ id: modelId, name: m.name || modelId, @@ -1216,6 +1219,7 @@ async function loadModelsDevData(): Promise[]> { supportsReasoningEffort: false, maxTokensField: "max_tokens", supportsStrictMode: false, + thinkingFormat: "deepseek", }; for (const { key, provider, baseUrl } of moonshotVariants) { diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 07d06ef5..9ebd12da 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, @@ -1131,7 +1131,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"], cost: { input: 0.15, @@ -1148,7 +1148,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"], cost: { input: 0.15, @@ -1165,7 +1165,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"], cost: { input: 0.07, @@ -1182,7 +1182,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"], cost: { input: 0.07, @@ -6299,7 +6299,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: { @@ -6317,7 +6317,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: { @@ -6335,7 +6335,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: { @@ -6353,7 +6353,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: { @@ -6371,7 +6371,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: { @@ -6389,7 +6389,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: { @@ -6407,7 +6407,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: { @@ -6427,7 +6427,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: { @@ -6445,7 +6445,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: { @@ -6463,7 +6463,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: { @@ -6481,7 +6481,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: { @@ -6499,7 +6499,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: { @@ -6517,7 +6517,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: { @@ -6535,7 +6535,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: { @@ -7770,6 +7770,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", + compat: {"maxTokensField":"max_tokens"}, reasoning: true, input: ["text"], cost: { @@ -7947,7 +7948,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"], @@ -7966,7 +7967,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"], @@ -8039,6 +8040,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", + compat: {"maxTokensField":"max_tokens"}, reasoning: true, input: ["text"], cost: { @@ -8056,6 +8058,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", + compat: {"maxTokensField":"max_tokens"}, reasoning: true, input: ["text"], cost: { @@ -8361,7 +8364,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"], @@ -8380,6 +8383,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: { @@ -8397,7 +8401,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: { @@ -8415,6 +8419,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: { @@ -8432,6 +8437,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", + compat: {"maxTokensField":"max_tokens"}, reasoning: true, input: ["text"], cost: { @@ -8449,6 +8455,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", + compat: {"maxTokensField":"max_tokens"}, reasoning: true, input: ["text"], cost: { @@ -8466,6 +8473,7 @@ export const MODELS = { api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", + compat: {"maxTokensField":"max_tokens"}, reasoning: true, input: ["text"], cost: { @@ -8519,7 +8527,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"], @@ -8538,7 +8546,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"], @@ -8557,6 +8565,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: { @@ -8574,6 +8583,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: { @@ -8591,6 +8601,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: { @@ -8608,7 +8619,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"], @@ -8627,6 +8638,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: { @@ -8644,6 +8656,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: { @@ -8678,6 +8691,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: { @@ -8712,7 +8726,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: { @@ -10490,6 +10504,23 @@ export const MODELS = { contextWindow: 262144, maxTokens: 4096, } satisfies Model<"openai-completions">, + "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: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + 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", name: "NVIDIA: Llama 3.3 Nemotron Super 49B V1.5", @@ -13111,9 +13142,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, @@ -13508,8 +13539,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, }, @@ -16373,7 +16404,7 @@ export const MODELS = { cacheRead: 0.2, cacheWrite: 0, }, - contextWindow: 2000000, + contextWindow: 1000000, maxTokens: 30000, } satisfies Model<"openai-completions">, "grok-4.20-0309-reasoning": { @@ -16390,7 +16421,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..e357bac5 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; 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 802b8b39..897e87d9 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -392,7 +392,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/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/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/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 23fe5f82..f71130db 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -5,6 +5,18 @@ ### Added - Added default-value expansion for prompt template positional arguments, e.g. `${1:-7}` ([#5507](https://github.com/earendil-works/pi/issues/5507)). +- Added `areExperimentalFeaturesEnabled` feature guard to allow users to opt-in to early features. +- 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)). + +### Fixed + +- 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)). +- 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 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/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/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/security.md b/packages/coding-agent/docs/security.md index 1e70a2d5..0c6d387a 100644 --- a/packages/coding-agent/docs/security.md +++ b/packages/coding-agent/docs/security.md @@ -4,27 +4,25 @@ Pi is a local coding agent. It runs with the permissions of the user account tha ## Project Trust -Project trust controls whether pi loads project-local inputs. 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. +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.md` or `CLAUDE.md` in the current directory or an ancestor directory - `.agents/skills` in the current directory or an ancestor directory -When an interactive session starts in a project with trust inputs and no saved decision, pi asks whether to trust the project. Saved decisions are stored per canonical working directory in `~/.pi/agent/trust.json`. +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 project-local inputs, including: +Trusting a project allows pi to load trust-gated project inputs, including: -- project instructions from `AGENTS.md` or `CLAUDE.md` - `.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 those project-local inputs. Before trust is resolved, pi only loads 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. +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 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, `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 @@ -32,7 +30,7 @@ Pi does not include a built-in sandbox. Built-in tools can read files, write fil 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 instructions, 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, or build output is expected local-agent risk and cannot be reliably prevented by pi. +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 diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index df2d0bd6..3c4e9e6f 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,6 +52,7 @@ 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 | | `doubleEscapeAction` | string | `"tree"` | Action for double-escape: `"tree"`, `"fork"`, or `"none"` | 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/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..1f17013c --- /dev/null +++ b/packages/coding-agent/src/cli/startup-ui.ts @@ -0,0 +1,87 @@ +import { ProcessTerminal, setKeybindings, TUI } from "@earendil-works/pi-tui"; +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 { initTheme } 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)); +} + +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(); + }); +} + +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/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/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..058e84e6 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -57,6 +57,8 @@ export interface WarningSettings { anthropicExtraUsage?: boolean; // default: true } +export type DefaultProjectTrust = "ask" | "always" | "never"; + export type TransportSetting = Transport; /** @@ -89,6 +91,7 @@ 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) @@ -853,6 +856,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; } 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..38f95246 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 { 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, @@ -439,87 +439,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 +449,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 +465,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 +484,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 +504,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); @@ -837,8 +603,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/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/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/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/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 98c618b3..b3cfcef2 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [Unreleased] + +### 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 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 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..128254b2 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -219,6 +219,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 +259,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 +283,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 +356,7 @@ export class Editor implements Component, Focusable { setAutocompleteProvider(provider: AutocompleteProvider): void { this.cancelAutocomplete(); this.autocompleteProvider = provider; + this.setAutocompleteTriggerCharacters(provider.triggerCharacters ?? []); } /** @@ -356,10 +375,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 +397,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 +499,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 +788,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 +976,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 +995,7 @@ export class Editor implements Component, Focusable { this.cancelAutocomplete(); this.pushUndoSnapshot(); this.lastAction = null; - this.historyIndex = -1; + this.exitHistoryBrowsing(); this.insertTextAtCursorInternal(text); } @@ -1028,7 +1058,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 +1090,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 +1107,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 +1119,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 +1187,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 +1228,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 +1238,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 +1287,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 +1455,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 +1490,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 +1522,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 +1567,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 +1609,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 +1651,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 +1865,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 +1950,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 +2150,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 +2170,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/utils.ts b/packages/tui/src/utils.ts index 02c40c79..3f27639e 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(); +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/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); +}