Merge branch 'main' into prompt-template-defaults

This commit is contained in:
Mario Zechner
2026-06-09 14:23:00 +02:00
committed by GitHub
65 changed files with 2021 additions and 551 deletions

View File

@@ -231,3 +231,9 @@ psoukie pr
vastxie pr vastxie pr
ItsumoSeito pr ItsumoSeito pr
davidlifschitz pr
vdxz pr
dangooddd pr

View File

@@ -51,15 +51,7 @@ jobs:
run: | run: |
VERSION="${RELEASE_TAG}" VERSION="${RELEASE_TAG}"
VERSION="${VERSION#v}" # Remove 'v' prefix VERSION="${VERSION#v}" # Remove 'v' prefix
node scripts/release-notes.mjs extract --version "${VERSION}" --tag "${RELEASE_TAG}" --out /tmp/release-notes.md
# 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
- name: Create GitHub Release and upload binaries - name: Create GitHub Release and upload binaries
env: env:
@@ -67,25 +59,29 @@ jobs:
run: | run: |
cd packages/coding-agent/binaries cd packages/coding-agent/binaries
# Create release with changelog notes (or update if exists) if gh release view "${RELEASE_TAG}" >/dev/null 2>&1; then
gh release create "${RELEASE_TAG}" \ gh release edit "${RELEASE_TAG}" \
--title "${RELEASE_TAG}" \ --title "${RELEASE_TAG}" \
--notes-file /tmp/release-notes.md \ --notes-file /tmp/release-notes.md
pi-darwin-arm64.tar.gz \ gh release upload "${RELEASE_TAG}" \
pi-darwin-x64.tar.gz \ pi-darwin-arm64.tar.gz \
pi-linux-x64.tar.gz \ pi-darwin-x64.tar.gz \
pi-linux-arm64.tar.gz \ pi-linux-x64.tar.gz \
pi-windows-x64.zip \ pi-linux-arm64.tar.gz \
pi-windows-arm64.zip \ pi-windows-x64.zip \
2>/dev/null || \ pi-windows-arm64.zip \
gh release upload "${RELEASE_TAG}" \ --clobber
pi-darwin-arm64.tar.gz \ else
pi-darwin-x64.tar.gz \ gh release create "${RELEASE_TAG}" \
pi-linux-x64.tar.gz \ --title "${RELEASE_TAG}" \
pi-linux-arm64.tar.gz \ --notes-file /tmp/release-notes.md \
pi-windows-x64.zip \ pi-darwin-arm64.tar.gz \
pi-windows-arm64.zip \ pi-darwin-x64.tar.gz \
--clobber pi-linux-x64.tar.gz \
pi-linux-arm64.tar.gz \
pi-windows-x64.zip \
pi-windows-arm64.zip
fi
publish-npm: publish-npm:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@@ -33,6 +33,7 @@
"release:patch": "node scripts/release.mjs patch", "release:patch": "node scripts/release.mjs patch",
"release:minor": "node scripts/release.mjs minor", "release:minor": "node scripts/release.mjs minor",
"release:major": "node scripts/release.mjs major", "release:major": "node scripts/release.mjs major",
"release:fix-links": "node scripts/release-notes.mjs fix-github-releases",
"prepare": "husky" "prepare": "husky"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -1,5 +1,7 @@
# Changelog # Changelog
## [Unreleased]
## [0.79.0] - 2026-06-08 ## [0.79.0] - 2026-06-08
### Fixed ### Fixed

View File

@@ -1,5 +1,14 @@
# Changelog # 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 ## [0.79.0] - 2026-06-08
### Fixed ### Fixed

View File

@@ -92,7 +92,6 @@ const TOGETHER_TOGGLE_REASONING_EFFORT_COMPAT: OpenAICompletionsCompat = {
}; };
const TOGETHER_REASONING_ONLY_MODELS = new Set([ const TOGETHER_REASONING_ONLY_MODELS = new Set([
"deepseek-ai/DeepSeek-R1", "deepseek-ai/DeepSeek-R1",
"MiniMaxAI/MiniMax-M2.5",
"MiniMaxAI/MiniMax-M2.7", "MiniMaxAI/MiniMax-M2.7",
]); ]);
const TOGETHER_REASONING_EFFORT_MODELS = new Set(["openai/gpt-oss-20b", "openai/gpt-oss-120b"]); const TOGETHER_REASONING_EFFORT_MODELS = new Set(["openai/gpt-oss-20b", "openai/gpt-oss-120b"]);
@@ -1058,6 +1057,10 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
} }
} }
if (api === "openai-completions") {
compat = { ...(compat ?? {}), maxTokensField: "max_tokens" };
}
models.push({ models.push({
id: modelId, id: modelId,
name: m.name || modelId, name: m.name || modelId,
@@ -1216,6 +1219,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
supportsReasoningEffort: false, supportsReasoningEffort: false,
maxTokensField: "max_tokens", maxTokensField: "max_tokens",
supportsStrictMode: false, supportsStrictMode: false,
thinkingFormat: "deepseek",
}; };
for (const { key, provider, baseUrl } of moonshotVariants) { for (const { key, provider, baseUrl } of moonshotVariants) {

View File

@@ -11,7 +11,7 @@ export const MODELS = {
api: "bedrock-converse-stream", api: "bedrock-converse-stream",
provider: "amazon-bedrock", provider: "amazon-bedrock",
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
reasoning: false, reasoning: true,
input: ["text", "image"], input: ["text", "image"],
cost: { cost: {
input: 0.33, input: 0.33,
@@ -1131,7 +1131,7 @@ export const MODELS = {
api: "bedrock-converse-stream", api: "bedrock-converse-stream",
provider: "amazon-bedrock", provider: "amazon-bedrock",
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
reasoning: false, reasoning: true,
input: ["text"], input: ["text"],
cost: { cost: {
input: 0.15, input: 0.15,
@@ -1148,7 +1148,7 @@ export const MODELS = {
api: "bedrock-converse-stream", api: "bedrock-converse-stream",
provider: "amazon-bedrock", provider: "amazon-bedrock",
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
reasoning: false, reasoning: true,
input: ["text"], input: ["text"],
cost: { cost: {
input: 0.15, input: 0.15,
@@ -1165,7 +1165,7 @@ export const MODELS = {
api: "bedrock-converse-stream", api: "bedrock-converse-stream",
provider: "amazon-bedrock", provider: "amazon-bedrock",
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
reasoning: false, reasoning: true,
input: ["text"], input: ["text"],
cost: { cost: {
input: 0.07, input: 0.07,
@@ -1182,7 +1182,7 @@ export const MODELS = {
api: "bedrock-converse-stream", api: "bedrock-converse-stream",
provider: "amazon-bedrock", provider: "amazon-bedrock",
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
reasoning: false, reasoning: true,
input: ["text"], input: ["text"],
cost: { cost: {
input: 0.07, input: 0.07,
@@ -6299,7 +6299,7 @@ export const MODELS = {
api: "openai-completions", api: "openai-completions",
provider: "moonshotai", provider: "moonshotai",
baseUrl: "https://api.moonshot.ai/v1", 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, reasoning: false,
input: ["text"], input: ["text"],
cost: { cost: {
@@ -6317,7 +6317,7 @@ export const MODELS = {
api: "openai-completions", api: "openai-completions",
provider: "moonshotai", provider: "moonshotai",
baseUrl: "https://api.moonshot.ai/v1", 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, reasoning: false,
input: ["text"], input: ["text"],
cost: { cost: {
@@ -6335,7 +6335,7 @@ export const MODELS = {
api: "openai-completions", api: "openai-completions",
provider: "moonshotai", provider: "moonshotai",
baseUrl: "https://api.moonshot.ai/v1", 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, reasoning: true,
input: ["text"], input: ["text"],
cost: { cost: {
@@ -6353,7 +6353,7 @@ export const MODELS = {
api: "openai-completions", api: "openai-completions",
provider: "moonshotai", provider: "moonshotai",
baseUrl: "https://api.moonshot.ai/v1", 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, reasoning: true,
input: ["text"], input: ["text"],
cost: { cost: {
@@ -6371,7 +6371,7 @@ export const MODELS = {
api: "openai-completions", api: "openai-completions",
provider: "moonshotai", provider: "moonshotai",
baseUrl: "https://api.moonshot.ai/v1", 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, reasoning: false,
input: ["text"], input: ["text"],
cost: { cost: {
@@ -6389,7 +6389,7 @@ export const MODELS = {
api: "openai-completions", api: "openai-completions",
provider: "moonshotai", provider: "moonshotai",
baseUrl: "https://api.moonshot.ai/v1", 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, reasoning: true,
input: ["text", "image"], input: ["text", "image"],
cost: { cost: {
@@ -6407,7 +6407,7 @@ export const MODELS = {
api: "openai-completions", api: "openai-completions",
provider: "moonshotai", provider: "moonshotai",
baseUrl: "https://api.moonshot.ai/v1", 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, reasoning: true,
input: ["text", "image"], input: ["text", "image"],
cost: { cost: {
@@ -6427,7 +6427,7 @@ export const MODELS = {
api: "openai-completions", api: "openai-completions",
provider: "moonshotai-cn", provider: "moonshotai-cn",
baseUrl: "https://api.moonshot.cn/v1", 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, reasoning: false,
input: ["text"], input: ["text"],
cost: { cost: {
@@ -6445,7 +6445,7 @@ export const MODELS = {
api: "openai-completions", api: "openai-completions",
provider: "moonshotai-cn", provider: "moonshotai-cn",
baseUrl: "https://api.moonshot.cn/v1", 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, reasoning: false,
input: ["text"], input: ["text"],
cost: { cost: {
@@ -6463,7 +6463,7 @@ export const MODELS = {
api: "openai-completions", api: "openai-completions",
provider: "moonshotai-cn", provider: "moonshotai-cn",
baseUrl: "https://api.moonshot.cn/v1", 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, reasoning: true,
input: ["text"], input: ["text"],
cost: { cost: {
@@ -6481,7 +6481,7 @@ export const MODELS = {
api: "openai-completions", api: "openai-completions",
provider: "moonshotai-cn", provider: "moonshotai-cn",
baseUrl: "https://api.moonshot.cn/v1", 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, reasoning: true,
input: ["text"], input: ["text"],
cost: { cost: {
@@ -6499,7 +6499,7 @@ export const MODELS = {
api: "openai-completions", api: "openai-completions",
provider: "moonshotai-cn", provider: "moonshotai-cn",
baseUrl: "https://api.moonshot.cn/v1", 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, reasoning: false,
input: ["text"], input: ["text"],
cost: { cost: {
@@ -6517,7 +6517,7 @@ export const MODELS = {
api: "openai-completions", api: "openai-completions",
provider: "moonshotai-cn", provider: "moonshotai-cn",
baseUrl: "https://api.moonshot.cn/v1", 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, reasoning: true,
input: ["text", "image"], input: ["text", "image"],
cost: { cost: {
@@ -6535,7 +6535,7 @@ export const MODELS = {
api: "openai-completions", api: "openai-completions",
provider: "moonshotai-cn", provider: "moonshotai-cn",
baseUrl: "https://api.moonshot.cn/v1", 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, reasoning: true,
input: ["text", "image"], input: ["text", "image"],
cost: { cost: {
@@ -7770,6 +7770,7 @@ export const MODELS = {
api: "openai-completions", api: "openai-completions",
provider: "opencode", provider: "opencode",
baseUrl: "https://opencode.ai/zen/v1", baseUrl: "https://opencode.ai/zen/v1",
compat: {"maxTokensField":"max_tokens"},
reasoning: true, reasoning: true,
input: ["text"], input: ["text"],
cost: { cost: {
@@ -7947,7 +7948,7 @@ export const MODELS = {
api: "openai-completions", api: "openai-completions",
provider: "opencode", provider: "opencode",
baseUrl: "https://opencode.ai/zen/v1", baseUrl: "https://opencode.ai/zen/v1",
compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, compat: {"maxTokensField":"max_tokens","requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"},
reasoning: true, reasoning: true,
thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"}, thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"},
input: ["text"], input: ["text"],
@@ -7966,7 +7967,7 @@ export const MODELS = {
api: "openai-completions", api: "openai-completions",
provider: "opencode", provider: "opencode",
baseUrl: "https://opencode.ai/zen/v1", baseUrl: "https://opencode.ai/zen/v1",
compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, compat: {"maxTokensField":"max_tokens","requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"},
reasoning: true, reasoning: true,
thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"}, thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"},
input: ["text"], input: ["text"],
@@ -8039,6 +8040,7 @@ export const MODELS = {
api: "openai-completions", api: "openai-completions",
provider: "opencode", provider: "opencode",
baseUrl: "https://opencode.ai/zen/v1", baseUrl: "https://opencode.ai/zen/v1",
compat: {"maxTokensField":"max_tokens"},
reasoning: true, reasoning: true,
input: ["text"], input: ["text"],
cost: { cost: {
@@ -8056,6 +8058,7 @@ export const MODELS = {
api: "openai-completions", api: "openai-completions",
provider: "opencode", provider: "opencode",
baseUrl: "https://opencode.ai/zen/v1", baseUrl: "https://opencode.ai/zen/v1",
compat: {"maxTokensField":"max_tokens"},
reasoning: true, reasoning: true,
input: ["text"], input: ["text"],
cost: { cost: {
@@ -8361,7 +8364,7 @@ export const MODELS = {
api: "openai-completions", api: "openai-completions",
provider: "opencode", provider: "opencode",
baseUrl: "https://opencode.ai/zen/v1", baseUrl: "https://opencode.ai/zen/v1",
compat: {"supportsReasoningEffort":false}, compat: {"supportsReasoningEffort":false,"maxTokensField":"max_tokens"},
reasoning: true, reasoning: true,
thinkingLevelMap: {"off":null,"minimal":null,"low":null,"medium":null}, thinkingLevelMap: {"off":null,"minimal":null,"low":null,"medium":null},
input: ["text", "image"], input: ["text", "image"],
@@ -8380,6 +8383,7 @@ export const MODELS = {
api: "openai-completions", api: "openai-completions",
provider: "opencode", provider: "opencode",
baseUrl: "https://opencode.ai/zen/v1", baseUrl: "https://opencode.ai/zen/v1",
compat: {"maxTokensField":"max_tokens"},
reasoning: true, reasoning: true,
input: ["text", "image"], input: ["text", "image"],
cost: { cost: {
@@ -8397,7 +8401,7 @@ export const MODELS = {
api: "openai-completions", api: "openai-completions",
provider: "opencode", provider: "opencode",
baseUrl: "https://opencode.ai/zen/v1", baseUrl: "https://opencode.ai/zen/v1",
compat: {"thinkingFormat":"deepseek","supportsReasoningEffort":false}, compat: {"thinkingFormat":"deepseek","supportsReasoningEffort":false,"maxTokensField":"max_tokens"},
reasoning: true, reasoning: true,
input: ["text", "image"], input: ["text", "image"],
cost: { cost: {
@@ -8415,6 +8419,7 @@ export const MODELS = {
api: "openai-completions", api: "openai-completions",
provider: "opencode", provider: "opencode",
baseUrl: "https://opencode.ai/zen/v1", baseUrl: "https://opencode.ai/zen/v1",
compat: {"maxTokensField":"max_tokens"},
reasoning: true, reasoning: true,
input: ["text", "image"], input: ["text", "image"],
cost: { cost: {
@@ -8432,6 +8437,7 @@ export const MODELS = {
api: "openai-completions", api: "openai-completions",
provider: "opencode", provider: "opencode",
baseUrl: "https://opencode.ai/zen/v1", baseUrl: "https://opencode.ai/zen/v1",
compat: {"maxTokensField":"max_tokens"},
reasoning: true, reasoning: true,
input: ["text"], input: ["text"],
cost: { cost: {
@@ -8449,6 +8455,7 @@ export const MODELS = {
api: "openai-completions", api: "openai-completions",
provider: "opencode", provider: "opencode",
baseUrl: "https://opencode.ai/zen/v1", baseUrl: "https://opencode.ai/zen/v1",
compat: {"maxTokensField":"max_tokens"},
reasoning: true, reasoning: true,
input: ["text"], input: ["text"],
cost: { cost: {
@@ -8466,6 +8473,7 @@ export const MODELS = {
api: "openai-completions", api: "openai-completions",
provider: "opencode", provider: "opencode",
baseUrl: "https://opencode.ai/zen/v1", baseUrl: "https://opencode.ai/zen/v1",
compat: {"maxTokensField":"max_tokens"},
reasoning: true, reasoning: true,
input: ["text"], input: ["text"],
cost: { cost: {
@@ -8519,7 +8527,7 @@ export const MODELS = {
api: "openai-completions", api: "openai-completions",
provider: "opencode-go", provider: "opencode-go",
baseUrl: "https://opencode.ai/zen/go/v1", baseUrl: "https://opencode.ai/zen/go/v1",
compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, compat: {"maxTokensField":"max_tokens","requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"},
reasoning: true, reasoning: true,
thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"}, thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"},
input: ["text"], input: ["text"],
@@ -8538,7 +8546,7 @@ export const MODELS = {
api: "openai-completions", api: "openai-completions",
provider: "opencode-go", provider: "opencode-go",
baseUrl: "https://opencode.ai/zen/go/v1", baseUrl: "https://opencode.ai/zen/go/v1",
compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, compat: {"maxTokensField":"max_tokens","requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"},
reasoning: true, reasoning: true,
thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"}, thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"},
input: ["text"], input: ["text"],
@@ -8557,6 +8565,7 @@ export const MODELS = {
api: "openai-completions", api: "openai-completions",
provider: "opencode-go", provider: "opencode-go",
baseUrl: "https://opencode.ai/zen/go/v1", baseUrl: "https://opencode.ai/zen/go/v1",
compat: {"maxTokensField":"max_tokens"},
reasoning: true, reasoning: true,
input: ["text"], input: ["text"],
cost: { cost: {
@@ -8574,6 +8583,7 @@ export const MODELS = {
api: "openai-completions", api: "openai-completions",
provider: "opencode-go", provider: "opencode-go",
baseUrl: "https://opencode.ai/zen/go/v1", baseUrl: "https://opencode.ai/zen/go/v1",
compat: {"maxTokensField":"max_tokens"},
reasoning: true, reasoning: true,
input: ["text"], input: ["text"],
cost: { cost: {
@@ -8591,6 +8601,7 @@ export const MODELS = {
api: "openai-completions", api: "openai-completions",
provider: "opencode-go", provider: "opencode-go",
baseUrl: "https://opencode.ai/zen/go/v1", baseUrl: "https://opencode.ai/zen/go/v1",
compat: {"maxTokensField":"max_tokens"},
reasoning: true, reasoning: true,
input: ["text", "image"], input: ["text", "image"],
cost: { cost: {
@@ -8608,7 +8619,7 @@ export const MODELS = {
api: "openai-completions", api: "openai-completions",
provider: "opencode-go", provider: "opencode-go",
baseUrl: "https://opencode.ai/zen/go/v1", baseUrl: "https://opencode.ai/zen/go/v1",
compat: {"thinkingFormat":"deepseek","supportsReasoningEffort":false}, compat: {"thinkingFormat":"deepseek","supportsReasoningEffort":false,"maxTokensField":"max_tokens"},
reasoning: true, reasoning: true,
thinkingLevelMap: {"minimal":null,"low":null,"medium":null}, thinkingLevelMap: {"minimal":null,"low":null,"medium":null},
input: ["text", "image"], input: ["text", "image"],
@@ -8627,6 +8638,7 @@ export const MODELS = {
api: "openai-completions", api: "openai-completions",
provider: "opencode-go", provider: "opencode-go",
baseUrl: "https://opencode.ai/zen/go/v1", baseUrl: "https://opencode.ai/zen/go/v1",
compat: {"maxTokensField":"max_tokens"},
reasoning: true, reasoning: true,
input: ["text", "image"], input: ["text", "image"],
cost: { cost: {
@@ -8644,6 +8656,7 @@ export const MODELS = {
api: "openai-completions", api: "openai-completions",
provider: "opencode-go", provider: "opencode-go",
baseUrl: "https://opencode.ai/zen/go/v1", baseUrl: "https://opencode.ai/zen/go/v1",
compat: {"maxTokensField":"max_tokens"},
reasoning: true, reasoning: true,
input: ["text"], input: ["text"],
cost: { cost: {
@@ -8678,6 +8691,7 @@ export const MODELS = {
api: "openai-completions", api: "openai-completions",
provider: "opencode-go", provider: "opencode-go",
baseUrl: "https://opencode.ai/zen/go/v1", baseUrl: "https://opencode.ai/zen/go/v1",
compat: {"maxTokensField":"max_tokens"},
reasoning: true, reasoning: true,
input: ["text"], input: ["text"],
cost: { cost: {
@@ -8712,7 +8726,7 @@ export const MODELS = {
api: "openai-completions", api: "openai-completions",
provider: "opencode-go", provider: "opencode-go",
baseUrl: "https://opencode.ai/zen/go/v1", baseUrl: "https://opencode.ai/zen/go/v1",
compat: {"thinkingFormat":"qwen"}, compat: {"thinkingFormat":"qwen","maxTokensField":"max_tokens"},
reasoning: true, reasoning: true,
input: ["text", "image"], input: ["text", "image"],
cost: { cost: {
@@ -10490,6 +10504,23 @@ export const MODELS = {
contextWindow: 262144, contextWindow: 262144,
maxTokens: 4096, maxTokens: 4096,
} satisfies Model<"openai-completions">, } 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": { "nvidia/llama-3.3-nemotron-super-49b-v1.5": {
id: "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", name: "NVIDIA: Llama 3.3 Nemotron Super 49B V1.5",
@@ -13111,9 +13142,9 @@ export const MODELS = {
api: "openai-completions", api: "openai-completions",
provider: "together", provider: "together",
baseUrl: "https://api.together.ai/v1", 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, reasoning: true,
thinkingLevelMap: {"off":null,"minimal":null,"low":null,"medium":null}, thinkingLevelMap: {"minimal":null,"low":null,"medium":null},
input: ["text"], input: ["text"],
cost: { cost: {
input: 0.3, input: 0.3,
@@ -13508,8 +13539,8 @@ export const MODELS = {
reasoning: true, reasoning: true,
input: ["text"], input: ["text"],
cost: { cost: {
input: 0.08, input: 0.12,
output: 0.29, output: 0.5,
cacheRead: 0, cacheRead: 0,
cacheWrite: 0, cacheWrite: 0,
}, },
@@ -16373,7 +16404,7 @@ export const MODELS = {
cacheRead: 0.2, cacheRead: 0.2,
cacheWrite: 0, cacheWrite: 0,
}, },
contextWindow: 2000000, contextWindow: 1000000,
maxTokens: 30000, maxTokens: 30000,
} satisfies Model<"openai-completions">, } satisfies Model<"openai-completions">,
"grok-4.20-0309-reasoning": { "grok-4.20-0309-reasoning": {
@@ -16390,7 +16421,7 @@ export const MODELS = {
cacheRead: 0.2, cacheRead: 0.2,
cacheWrite: 0, cacheWrite: 0,
}, },
contextWindow: 2000000, contextWindow: 1000000,
maxTokens: 30000, maxTokens: 30000,
} satisfies Model<"openai-completions">, } satisfies Model<"openai-completions">,
"grok-4.3": { "grok-4.3": {

View File

@@ -143,10 +143,13 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt
// in Node.js/Bun environment only // in Node.js/Bun environment only
if (typeof process !== "undefined" && (process.versions?.node || process.versions?.bun)) { if (typeof process !== "undefined" && (process.versions?.node || process.versions?.bun)) {
// Region resolution: explicit option > env vars > SDK default chain. // Region resolution: ARN-embedded > explicit option > env vars > SDK default chain.
// When AWS_PROFILE is set, we leave region undefined so the SDK can // When the model ID is an inference profile ARN, extract the region from it.
// resovle it from aws profile configs. Otherwise fall back to us-east-1. // This avoids conflicts with AWS_REGION set for other services.
if (configuredRegion) { 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; config.region = configuredRegion;
} else if (endpointRegion && useExplicitEndpoint) { } else if (endpointRegion && useExplicitEndpoint) {
config.region = endpointRegion; config.region = endpointRegion;

View File

@@ -256,6 +256,7 @@ function buildParams(
input: messages, input: messages,
stream: true, stream: true,
prompt_cache_key: clampOpenAIPromptCacheKey(options?.sessionId), prompt_cache_key: clampOpenAIPromptCacheKey(options?.sessionId),
store: false,
}; };
if (options?.maxTokens) { if (options?.maxTokens) {

View File

@@ -554,7 +554,8 @@ function buildParams(
} }
if (compat.thinkingFormat === "zai" && model.reasoning) { 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) { } else if (compat.thinkingFormat === "qwen" && model.reasoning) {
(params as any).enable_thinking = !!options?.reasoningEffort; (params as any).enable_thinking = !!options?.reasoningEffort;
} else if (compat.thinkingFormat === "qwen-chat-template" && model.reasoning) { } else if (compat.thinkingFormat === "qwen-chat-template" && model.reasoning) {

View File

@@ -392,7 +392,7 @@ export interface OpenAICompletionsCompat {
requiresThinkingAsText?: boolean; requiresThinkingAsText?: boolean;
/** Whether all replayed assistant messages must include an empty reasoning_content field when reasoning is enabled. Default: auto-detected from URL. */ /** Whether all replayed assistant messages must include an empty reasoning_content field when reasoning is enabled. Default: auto-detected from URL. */
requiresReasoningContentOnAssistantMessages?: boolean; 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?: thinkingFormat?:
| "openai" | "openai"
| "openrouter" | "openrouter"

View File

@@ -13,6 +13,7 @@ interface CapturedAzureClientOptions {
interface CapturedAzureResponsesPayload { interface CapturedAzureResponsesPayload {
prompt_cache_key?: string; prompt_cache_key?: string;
store?: boolean;
} }
const azureMock = vi.hoisted(() => ({ 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)); 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 () => { it("builds correct default URL from AZURE_OPENAI_RESOURCE_NAME", async () => {
process.env.AZURE_OPENAI_RESOURCE_NAME = "my-resource"; process.env.AZURE_OPENAI_RESOURCE_NAME = "my-resource";
const model = getModel("azure-openai-responses", "gpt-4o-mini"); const model = getModel("azure-openai-responses", "gpt-4o-mini");

View File

@@ -128,4 +128,30 @@ describe("bedrock endpoint resolution", () => {
expect(config.endpoint).toBe("https://bedrock-vpc.example.com"); expect(config.endpoint).toBe("https://bedrock-vpc.example.com");
expect(config.region).toBe("us-west-2"); 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");
});
}); });

View File

@@ -1120,6 +1120,33 @@ describe("openai-completions tool_choice", () => {
expect(params.reasoning_effort).toBeUndefined(); 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 () => { it("omits reasoning effort for OpenCode Grok Build", async () => {
const model = getModel("opencode", "grok-build-0.1")!; const model = getModel("opencode", "grok-build-0.1")!;
let payload: unknown; let payload: unknown;

View File

@@ -5,6 +5,18 @@
### Added ### Added
- Added default-value expansion for prompt template positional arguments, e.g. `${1:-7}` ([#5507](https://github.com/earendil-works/pi/issues/5507)). - 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 ## [0.79.0] - 2026-06-08

View File

@@ -291,15 +291,17 @@ See [docs/settings.md](docs/settings.md) for all options.
### Project Trust ### 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 ### 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 loads `AGENTS.md` (or `CLAUDE.md`) at startup from:
- `~/.pi/agent/AGENTS.md` (global) - `~/.pi/agent/AGENTS.md` (global)
- Parent directories (walking up from cwd, only when the project is trusted) - Parent directories (walking up from cwd)
- Current directory (only when the project is trusted) - Current directory
Use for project instructions (`AGENTS.md`/`CLAUDE.md`), conventions, common commands. All matching files are concatenated. 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 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 ### Modes

View File

@@ -339,7 +339,7 @@ exit (Ctrl+C, Ctrl+D, SIGHUP, SIGTERM)
#### project_trust #### 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 ```typescript
pi.on("project_trust", async (event, ctx) => { 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 ### Resource Events
@@ -892,6 +892,12 @@ Current run mode: `"tui"`, `"rpc"`, `"json"`, or `"print"`. Use `ctx.mode === "t
Current working directory. 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 ### ctx.sessionManager
Read-only access to session state. See [Session Format](session-format.md) for the full SessionManager API and entry types. 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 // Stack custom autocomplete behavior on top of the built-in provider
ctx.ui.addAutocompleteProvider((current) => ({ ctx.ui.addAutocompleteProvider((current) => ({
triggerCharacters: ["#"],
async getSuggestions(lines, line, col, options) { async getSuggestions(lines, line, col, options) {
const beforeCursor = (lines[line] ?? "").slice(0, col); const beforeCursor = (lines[line] ?? "").slice(0, col);
const match = beforeCursor.match(/(?:^|[ \t])#([^\s#]*)$/); 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 ### 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: Typical pattern:
@@ -2335,6 +2342,7 @@ Typical pattern:
```typescript ```typescript
pi.on("session_start", (_event, ctx) => { pi.on("session_start", (_event, ctx) => {
ctx.ui.addAutocompleteProvider((current) => ({ ctx.ui.addAutocompleteProvider((current) => ({
triggerCharacters: ["#"],
async getSuggestions(lines, cursorLine, cursorCol, options) { async getSuggestions(lines, cursorLine, cursorCol, options) {
const line = lines[cursorLine] ?? ""; const line = lines[cursorLine] ?? "";
const beforeCursor = line.slice(0, cursorCol); const beforeCursor = line.slice(0, cursorCol);

View File

@@ -198,7 +198,7 @@ If your command is slow, expensive, rate-limited, or should keep using a previou
| Field | Required | Default | Description | | Field | Required | Default | Description |
|-------|----------|---------|-------------| |-------|----------|---------|-------------|
| `id` | Yes | — | Model identifier (passed to the API) | | `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 | | `api` | No | provider's `api` | Override provider's API for this model |
| `reasoning` | No | `false` | Supports extended thinking | | `reasoning` | No | `false` | Supports extended thinking |
| `thinkingLevelMap` | No | omitted | Maps pi thinking levels to provider values and marks unsupported levels (see below) | | `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. | | `compat` | No | provider `compat` | Provider compatibility overrides. Merged with provider-level `compat` when both are set. |
Current behavior: Current behavior:
- `/model` and `--list-models` list entries by model `id`. - `/model`, `--list-models`, and the interactive footer display entries by model `id`.
- The configured `name` is used for model matching and detail/status text. - 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 ### Thinking Level Map
@@ -320,6 +320,7 @@ Behavior notes:
- `modelOverrides` are applied to built-in provider models. - `modelOverrides` are applied to built-in provider models.
- Unknown model IDs are ignored. - Unknown model IDs are ignored.
- You can combine provider-level `baseUrl`/`headers` with `modelOverrides`. - 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. - 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 ## Anthropic Messages Compatibility

View File

@@ -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. 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: To try a package without installing it, use `--extension` or `-e`. This installs to a temporary directory for the current run only:
```bash ```bash

View File

@@ -4,27 +4,25 @@ Pi is a local coding agent. It runs with the permissions of the user account tha
## Project Trust ## 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 considers a project to have trust inputs when it finds any of these from the current working directory:
- `.pi/` in the current 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 - `.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/settings.json`
- `.pi` resources such as extensions, skills, prompt templates, themes, and system prompt files - `.pi` resources such as extensions, skills, prompt templates, themes, and system prompt files
- missing project packages configured through project settings - missing project packages configured through project settings
- project-local extensions and project package-managed extensions - 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 ## 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. 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 ## Running Untrusted or Unmonitored Work

View File

@@ -11,13 +11,15 @@ Edit directly or use `/settings` for common options.
## Project Trust ## 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 ## 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) | | `theme` | string | `"dark"` | Theme name (`"dark"`, `"light"`, or custom) |
| `quietStartup` | boolean | `false` | Hide startup header | | `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 | | `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 | | `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"` | | `doubleEscapeAction` | string | `"tree"` | Action for double-escape: `"tree"`, `"fork"`, or `"none"` |

View File

@@ -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 loads `AGENTS.md` or `CLAUDE.md` at startup from:
- `~/.pi/agent/AGENTS.md` for global instructions - `~/.pi/agent/AGENTS.md` for global instructions
- parent directories, walking up from the current working directory when the project is trusted - parent directories, walking up from the current working directory
- the current directory when the project is trusted - the current directory
Use context files for project conventions, commands, safety rules, and preferences. Disable loading with `--no-context-files` or `-nc`. 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 ### 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 ## Exporting and Sharing Sessions
@@ -148,7 +153,7 @@ pi list # List installed packages
pi config # Enable/disable package resources 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. See [Pi Packages](packages.md) for package sources and security notes.

View File

@@ -230,10 +230,8 @@ ${chalk.bold("Commands:")}
${APP_NAME} remove <source> [-l] Remove extension source from settings ${APP_NAME} remove <source> [-l] Remove extension source from settings
${APP_NAME} uninstall <source> [-l] Alias for remove ${APP_NAME} uninstall <source> [-l] Alias for remove
${APP_NAME} update [source|self|pi] Update pi and installed extensions ${APP_NAME} update [source|self|pi] Update pi and installed extensions
${APP_NAME} list [--approve|--no-approve] ${APP_NAME} list List installed extensions from settings
List installed extensions from settings ${APP_NAME} config Open TUI to enable/disable package resources
${APP_NAME} config [--no-approve]
Open TUI to enable/disable package resources
${APP_NAME} <command> --help Show help for install/remove/uninstall/update/list ${APP_NAME} <command> --help Show help for install/remove/uninstall/update/list
${chalk.bold("Options:")} ${chalk.bold("Options:")}

View File

@@ -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));
}
},
},
};
}

View File

@@ -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<void> {
ui.clear();
ui.requestRender();
await new Promise((resolve) => setTimeout(resolve, 25));
}
export async function showStartupSelector<T>(
settingsManager: SettingsManager,
title: string,
options: Array<{ label: string; value: T }>,
): Promise<T | undefined> {
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<string | undefined> {
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();
});
}

View File

@@ -232,7 +232,9 @@ export class AgentSessionRuntime {
const previousSessionFile = this.session.sessionFile; const previousSessionFile = this.session.sessionFile;
const sessionDir = this.session.sessionManager.getSessionDir(); 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) { if (options?.parentSession) {
sessionManager.newSession({ parentSession: options.parentSession }); sessionManager.newSession({ parentSession: options.parentSession });
} }

View File

@@ -1606,6 +1606,11 @@ export class AgentSession {
// Queue Mode Management // Queue Mode Management
// ========================================================================= // =========================================================================
private syncQueueModesFromSettings(): void {
this.agent.steeringMode = this.settingsManager.getSteeringMode();
this.agent.followUpMode = this.settingsManager.getFollowUpMode();
}
/** /**
* Set steering message mode. * Set steering message mode.
* Saves to settings. * Saves to settings.
@@ -2239,6 +2244,7 @@ export class AgentSession {
{ {
getModel: () => this.model, getModel: () => this.model,
isIdle: () => !this.isStreaming, isIdle: () => !this.isStreaming,
isProjectTrusted: () => this.settingsManager.isProjectTrusted(),
getSignal: () => this.agent.signal, getSignal: () => this.agent.signal,
abort: () => { abort: () => {
if (this._extensionAbortHandler) { if (this._extensionAbortHandler) {
@@ -2430,6 +2436,7 @@ export class AgentSession {
const previousFlagValues = this._extensionRunner.getFlagValues(); const previousFlagValues = this._extensionRunner.getFlagValues();
await emitSessionShutdownEvent(this._extensionRunner, { type: "session_shutdown", reason: "reload" }); await emitSessionShutdownEvent(this._extensionRunner, { type: "session_shutdown", reason: "reload" });
await this.settingsManager.reload(); await this.settingsManager.reload();
this.syncQueueModesFromSettings();
resetApiProviders(); resetApiProviders();
await this._resourceLoader.reload(); await this._resourceLoader.reload();
this._buildRuntime({ this._buildRuntime({

View File

@@ -0,0 +1,3 @@
export function areExperimentalFeaturesEnabled(): boolean {
return process.env.PI_EXPERIMENTAL === "1";
}

View File

@@ -270,6 +270,7 @@ export class ExtensionRunner {
private errorListeners: Set<ExtensionErrorListener> = new Set(); private errorListeners: Set<ExtensionErrorListener> = new Set();
private getModel: () => Model<any> | undefined = () => undefined; private getModel: () => Model<any> | undefined = () => undefined;
private isIdleFn: () => boolean = () => true; private isIdleFn: () => boolean = () => true;
private isProjectTrustedFn: () => boolean = () => true;
private getSignalFn: () => AbortSignal | undefined = () => undefined; private getSignalFn: () => AbortSignal | undefined = () => undefined;
private waitForIdleFn: () => Promise<void> = async () => {}; private waitForIdleFn: () => Promise<void> = async () => {};
private abortFn: () => void = () => {}; private abortFn: () => void = () => {};
@@ -330,6 +331,7 @@ export class ExtensionRunner {
// Context actions (required) // Context actions (required)
this.getModel = contextActions.getModel; this.getModel = contextActions.getModel;
this.isIdleFn = contextActions.isIdle; this.isIdleFn = contextActions.isIdle;
this.isProjectTrustedFn = contextActions.isProjectTrusted;
this.getSignalFn = contextActions.getSignal; this.getSignalFn = contextActions.getSignal;
this.abortFn = contextActions.abort; this.abortFn = contextActions.abort;
this.hasPendingMessagesFn = contextActions.hasPendingMessages; this.hasPendingMessagesFn = contextActions.hasPendingMessages;
@@ -648,6 +650,10 @@ export class ExtensionRunner {
runner.assertActive(); runner.assertActive();
return runner.isIdleFn(); return runner.isIdleFn();
}, },
isProjectTrusted: () => {
runner.assertActive();
return runner.isProjectTrustedFn();
},
get signal() { get signal() {
runner.assertActive(); runner.assertActive();
return runner.getSignalFn(); return runner.getSignalFn();

View File

@@ -314,6 +314,8 @@ export interface ExtensionContext {
model: Model<any> | undefined; model: Model<any> | undefined;
/** Whether the agent is idle (not streaming) */ /** Whether the agent is idle (not streaming) */
isIdle(): boolean; 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. */ /** The current abort signal, or undefined when the agent is not streaming. */
signal: AbortSignal | undefined; signal: AbortSignal | undefined;
/** Abort the current agent operation */ /** Abort the current agent operation */
@@ -1528,6 +1530,7 @@ export interface ExtensionActions {
export interface ExtensionContextActions { export interface ExtensionContextActions {
getModel: () => Model<any> | undefined; getModel: () => Model<any> | undefined;
isIdle: () => boolean; isIdle: () => boolean;
isProjectTrusted: () => boolean;
getSignal: () => AbortSignal | undefined; getSignal: () => AbortSignal | undefined;
abort: () => void; abort: () => void;
hasPendingMessages: () => boolean; hasPendingMessages: () => boolean;

View File

@@ -28,6 +28,7 @@ export {
export { type BashExecutorOptions, type BashResult, executeBashWithOperations } from "./bash-executor.ts"; export { type BashExecutorOptions, type BashResult, executeBashWithOperations } from "./bash-executor.ts";
export type { CompactionResult } from "./compaction/index.ts"; export type { CompactionResult } from "./compaction/index.ts";
export { createEventBus, type EventBus, type EventBusController } from "./event-bus.ts"; export { createEventBus, type EventBus, type EventBusController } from "./event-bus.ts";
export { areExperimentalFeaturesEnabled } from "./experimental.ts";
// Extensions system // Extensions system
export { export {
type AgentEndEvent, type AgentEndEvent,

View File

@@ -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<ProjectTrustOption | undefined> {
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<boolean> {
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;
}

View File

@@ -79,7 +79,6 @@ function loadContextFileFromDir(dir: string): { path: string; content: string }
export function loadProjectContextFiles(options: { export function loadProjectContextFiles(options: {
cwd: string; cwd: string;
agentDir: string; agentDir: string;
projectTrusted?: boolean;
}): Array<{ path: string; content: string }> { }): Array<{ path: string; content: string }> {
const resolvedCwd = resolvePath(options.cwd); const resolvedCwd = resolvePath(options.cwd);
const resolvedAgentDir = resolvePath(options.agentDir); const resolvedAgentDir = resolvePath(options.agentDir);
@@ -93,29 +92,27 @@ export function loadProjectContextFiles(options: {
seenPaths.add(globalContext.path); 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; let currentDir = resolvedCwd;
const root = resolve("/"); const root = resolve("/");
while (true) { while (true) {
const contextFile = loadContextFileFromDir(currentDir); const contextFile = loadContextFileFromDir(currentDir);
if (contextFile && !seenPaths.has(contextFile.path)) { if (contextFile && !seenPaths.has(contextFile.path)) {
ancestorContextFiles.unshift(contextFile); ancestorContextFiles.unshift(contextFile);
seenPaths.add(contextFile.path); seenPaths.add(contextFile.path);
}
if (currentDir === root) break;
const parentDir = resolve(currentDir, "..");
if (parentDir === currentDir) break;
currentDir = parentDir;
} }
contextFiles.push(...ancestorContextFiles); if (currentDir === root) break;
const parentDir = resolve(currentDir, "..");
if (parentDir === currentDir) break;
currentDir = parentDir;
} }
contextFiles.push(...ancestorContextFiles);
return contextFiles; return contextFiles;
} }
@@ -325,14 +322,18 @@ export class DefaultResourceLoader implements ResourceLoader {
} }
} }
async loadProjectTrustExtensions(): Promise<LoadExtensionsResult> {
// 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<void> { async reload(options?: ResourceLoaderReloadOptions): Promise<void> {
let preTrustExtensions: LoadExtensionsResult | undefined; let preTrustExtensions: LoadExtensionsResult | undefined;
if (options?.resolveProjectTrust) { if (options?.resolveProjectTrust) {
// Force untrusted project settings for the bootstrap pass. This keeps project-local preTrustExtensions = await this.loadProjectTrustExtensions();
// 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 });
const projectTrusted = await options.resolveProjectTrust({ extensionsResult: preTrustExtensions }); const projectTrusted = await options.resolveProjectTrust({ extensionsResult: preTrustExtensions });
this.settingsManager.setProjectTrusted(projectTrusted); this.settingsManager.setProjectTrusted(projectTrusted);
} }
@@ -454,7 +455,6 @@ export class DefaultResourceLoader implements ResourceLoader {
: loadProjectContextFiles({ : loadProjectContextFiles({
cwd: this.cwd, cwd: this.cwd,
agentDir: this.agentDir, agentDir: this.agentDir,
projectTrusted: this.settingsManager.isProjectTrusted(),
}), }),
}; };
const resolvedAgentsFiles = this.agentsFilesOverride ? this.agentsFilesOverride(agentsFiles) : agentsFiles; const resolvedAgentsFiles = this.agentsFilesOverride ? this.agentsFilesOverride(agentsFiles) : agentsFiles;

View File

@@ -57,6 +57,8 @@ export interface WarningSettings {
anthropicExtraUsage?: boolean; // default: true anthropicExtraUsage?: boolean; // default: true
} }
export type DefaultProjectTrust = "ask" | "always" | "never";
export type TransportSetting = Transport; export type TransportSetting = Transport;
/** /**
@@ -89,6 +91,7 @@ export interface Settings {
hideThinkingBlock?: boolean; hideThinkingBlock?: boolean;
shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows) shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows)
quietStartup?: boolean; 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) 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"]) 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) collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full)
@@ -853,6 +856,17 @@ export class SettingsManager {
this.save(); 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 { getShellCommandPrefix(): string | undefined {
return this.settings.shellCommandPrefix; return this.settings.shellCommandPrefix;
} }

View File

@@ -6,14 +6,87 @@ import { canonicalizePath, resolvePath } from "../utils/paths.ts";
export type ProjectTrustDecision = boolean | null; export type ProjectTrustDecision = boolean | null;
type TrustFile = Record<string, boolean | null | undefined>; 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<string, boolean | null | undefined>;
function normalizeCwd(cwd: string): string { function normalizeCwd(cwd: string): string {
return canonicalizePath(resolvePath(cwd)); 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 { function readTrustFile(path: string): TrustFile {
if (!existsSync(path)) { if (!existsSync(path)) {
return {}; return {};
@@ -105,11 +178,6 @@ export function hasProjectTrustInputs(cwd: string): boolean {
} }
while (true) { while (true) {
for (const filename of CONTEXT_FILE_NAMES) {
if (existsSync(join(currentDir, filename))) {
return true;
}
}
if (existsSync(join(currentDir, ".agents", "skills"))) { if (existsSync(join(currentDir, ".agents", "skills"))) {
return true; return true;
} }
@@ -130,21 +198,30 @@ export class ProjectTrustStore {
} }
get(cwd: string): ProjectTrustDecision { get(cwd: string): ProjectTrustDecision {
return this.getEntry(cwd)?.decision ?? null;
}
getEntry(cwd: string): ProjectTrustStoreEntry | null {
return withTrustFileLock(this.trustPath, () => { return withTrustFileLock(this.trustPath, () => {
const data = readTrustFile(this.trustPath); const data = readTrustFile(this.trustPath);
const value = data[normalizeCwd(cwd)]; return findNearestTrustEntry(data, cwd);
return value === true || value === false ? value : null;
}); });
} }
set(cwd: string, decision: ProjectTrustDecision): void { set(cwd: string, decision: ProjectTrustDecision): void {
this.setMany([{ path: cwd, decision }]);
}
setMany(decisions: ProjectTrustUpdate[]): void {
withTrustFileLock(this.trustPath, () => { withTrustFileLock(this.trustPath, () => {
const data = readTrustFile(this.trustPath); const data = readTrustFile(this.trustPath);
const key = normalizeCwd(cwd); for (const { path, decision } of decisions) {
if (decision === null) { const key = normalizeCwd(path);
delete data[key]; if (decision === null) {
} else { delete data[key];
data[key] = decision; } else {
data[key] = decision;
}
} }
writeTrustFile(this.trustPath, data); writeTrustFile(this.trustPath, data);
}); });

View File

@@ -220,6 +220,7 @@ export {
} from "./core/session-manager.ts"; } from "./core/session-manager.ts";
export { export {
type CompactionSettings, type CompactionSettings,
type DefaultProjectTrust,
type ImageSettings, type ImageSettings,
type PackageSource, type PackageSource,
type RetrySettings, type RetrySettings,
@@ -287,7 +288,13 @@ export {
type WriteToolOptions, type WriteToolOptions,
withFileMutationQueue, withFileMutationQueue,
} from "./core/tools/index.ts"; } 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 // Main entry point
export { type MainOptions, main } from "./main.ts"; export { type MainOptions, main } from "./main.ts";
// Run modes for programmatic SDK usage // Run modes for programmatic SDK usage

View File

@@ -7,13 +7,14 @@
import { createInterface } from "node:readline"; import { createInterface } from "node:readline";
import { type ImageContent, modelsAreEqual } from "@earendil-works/pi-ai"; import { type ImageContent, modelsAreEqual } from "@earendil-works/pi-ai";
import { ProcessTerminal, setKeybindings, TUI } from "@earendil-works/pi-tui";
import chalk from "chalk"; import chalk from "chalk";
import { type Args, type Mode, parseArgs, printHelp } from "./cli/args.ts"; import { type Args, type Mode, parseArgs, printHelp } from "./cli/args.ts";
import { processFileArguments } from "./cli/file-processor.ts"; import { processFileArguments } from "./cli/file-processor.ts";
import { buildInitialMessage } from "./cli/initial-message.ts"; import { buildInitialMessage } from "./cli/initial-message.ts";
import { listModels } from "./cli/list-models.ts"; import { listModels } from "./cli/list-models.ts";
import { createProjectTrustContext } from "./cli/project-trust.ts";
import { selectSession } from "./cli/session-picker.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 { ENV_SESSION_DIR, expandTildePath, getAgentDir, getPackageDir, VERSION } from "./config.ts";
import { type CreateAgentSessionRuntimeFactory, createAgentSessionRuntime } from "./core/agent-session-runtime.ts"; import { type CreateAgentSessionRuntimeFactory, createAgentSessionRuntime } from "./core/agent-session-runtime.ts";
import { import {
@@ -24,13 +25,12 @@ import {
import { formatNoModelsAvailableMessage } from "./core/auth-guidance.ts"; import { formatNoModelsAvailableMessage } from "./core/auth-guidance.ts";
import { AuthStorage } from "./core/auth-storage.ts"; import { AuthStorage } from "./core/auth-storage.ts";
import { exportFromFile } from "./core/export-html/index.ts"; import { exportFromFile } from "./core/export-html/index.ts";
import { emitProjectTrustEvent } from "./core/extensions/runner.ts"; import type { ExtensionFactory } from "./core/extensions/types.ts";
import type { ExtensionFactory, LoadExtensionsResult, ProjectTrustContext } from "./core/extensions/types.ts";
import { configureHttpDispatcher } from "./core/http-dispatcher.ts"; import { configureHttpDispatcher } from "./core/http-dispatcher.ts";
import { KeybindingsManager } from "./core/keybindings.ts";
import type { ModelRegistry } from "./core/model-registry.ts"; import type { ModelRegistry } from "./core/model-registry.ts";
import { resolveCliModel, resolveModelScope, type ScopedModel } from "./core/model-resolver.ts"; import { resolveCliModel, resolveModelScope, type ScopedModel } from "./core/model-resolver.ts";
import { restoreStdout, takeOverStdout } from "./core/output-guard.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 type { CreateAgentSessionOptions } from "./core/sdk.ts";
import { import {
formatMissingSessionCwdPrompt, formatMissingSessionCwdPrompt,
@@ -44,8 +44,6 @@ import { printTimings, resetTimings, time } from "./core/timings.ts";
import { hasProjectTrustInputs, ProjectTrustStore } from "./core/trust-manager.ts"; import { hasProjectTrustInputs, ProjectTrustStore } from "./core/trust-manager.ts";
import { runMigrations, showDeprecationWarnings } from "./migrations.ts"; import { runMigrations, showDeprecationWarnings } from "./migrations.ts";
import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.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 { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.ts";
import { handleConfigCommand, handlePackageCommand } from "./package-manager-cli.ts"; import { handleConfigCommand, handlePackageCommand } from "./package-manager-cli.ts";
import { isLocalPath, normalizePath, resolvePath } from "./utils/paths.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"; return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes";
} }
type AppMode = "interactive" | "print" | "json" | "rpc"; function resolveAppMode(parsed: Args, stdinIsTTY: boolean, stdoutIsTTY: boolean): AppMode {
function resolveAppMode(parsed: Args, stdinIsTTY: boolean): AppMode {
if (parsed.mode === "rpc") { if (parsed.mode === "rpc") {
return "rpc"; return "rpc";
} }
if (parsed.mode === "json") { if (parsed.mode === "json") {
return "json"; return "json";
} }
if (parsed.print || !stdinIsTTY) { if (parsed.print || !stdinIsTTY || !stdoutIsTTY) {
return "print"; return "print";
} }
return "interactive"; return "interactive";
@@ -116,6 +112,10 @@ function toPrintOutputMode(appMode: AppMode): Exclude<Mode, "rpc"> {
return appMode === "json" ? "json" : "text"; 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( async function prepareInitialMessage(
parsed: Args, parsed: Args,
autoResizeImages: boolean, 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)); 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<void> {
ui.clear();
ui.requestRender();
await new Promise((resolve) => setTimeout(resolve, 25));
}
async function showStartupSelector<T>(
settingsManager: SettingsManager,
title: string,
options: Array<{ label: string; value: T }>,
): Promise<T | undefined> {
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<string | undefined> {
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( async function promptForMissingSessionCwd(
issue: SessionCwdIssue, issue: SessionCwdIssue,
settingsManager: SettingsManager, 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<ProjectTrustPromptResult | undefined> {
return showStartupSelector(settingsManager, formatProjectTrustPrompt(cwd), PROJECT_TRUST_PROMPT_OPTIONS);
}
async function promptForProjectTrustWithContext(
cwd: string,
ctx: ProjectTrustContext,
): Promise<ProjectTrustPromptResult | undefined> {
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<boolean> {
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 { export interface MainOptions {
extensionFactories?: ExtensionFactory[]; extensionFactories?: ExtensionFactory[];
} }
@@ -700,11 +465,11 @@ export async function main(args: string[], options?: MainOptions) {
cleanupWindowsSelfUpdateQuarantine(getPackageDir()); cleanupWindowsSelfUpdateQuarantine(getPackageDir());
} }
if (await handlePackageCommand(args)) { if (await handlePackageCommand(args, { extensionFactories: options?.extensionFactories })) {
return; return;
} }
if (await handleConfigCommand(args)) { if (await handleConfigCommand(args, { extensionFactories: options?.extensionFactories })) {
return; return;
} }
@@ -719,11 +484,6 @@ export async function main(args: string[], options?: MainOptions) {
} }
} }
time("parseArgs"); time("parseArgs");
let appMode = resolveAppMode(parsed, process.stdin.isTTY);
const shouldTakeOverStdout = appMode !== "interactive";
if (shouldTakeOverStdout) {
takeOverStdout();
}
if (parsed.version) { if (parsed.version) {
console.log(VERSION); console.log(VERSION);
@@ -744,6 +504,12 @@ export async function main(args: string[], options?: MainOptions) {
process.exit(0); 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) { if (parsed.mode === "rpc" && parsed.fileArgs.length > 0) {
console.error(chalk.red("Error: @file arguments are not supported in RPC mode")); console.error(chalk.red("Error: @file arguments are not supported in RPC mode"));
process.exit(1); process.exit(1);
@@ -837,8 +603,7 @@ export async function main(args: string[], options?: MainOptions) {
cwd, cwd,
trustStore, trustStore,
trustOverride: parsed.projectTrustOverride, trustOverride: parsed.projectTrustOverride,
appMode: isInitialRuntime ? trustPromptMode : "print", defaultProjectTrust: startupSettingsManager.getDefaultProjectTrust(),
settingsManagerForPrompt: startupSettingsManager,
extensionsResult, extensionsResult,
projectTrustContext: projectTrustContext:
projectTrustContext ?? projectTrustContext ??

View File

@@ -144,47 +144,52 @@ function migrateModelsJsonConfigValues(agentDir: string): ConfigValueMigration[]
const modelsPath = join(agentDir, "models.json"); const modelsPath = join(agentDir, "models.json");
if (!existsSync(modelsPath)) return []; if (!existsSync(modelsPath)) return [];
const parsed = JSON.parse(stripJsonComments(readFileSync(modelsPath, "utf-8"))) as unknown; try {
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return []; const parsed = JSON.parse(stripJsonComments(readFileSync(modelsPath, "utf-8"))) as unknown;
const modelsData = parsed as Record<string, unknown>; if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return [];
const providers = modelsData.providers; const modelsData = parsed as Record<string, unknown>;
if (typeof providers !== "object" || providers === null || Array.isArray(providers)) return []; const providers = modelsData.providers;
if (typeof providers !== "object" || providers === null || Array.isArray(providers)) return [];
const migrations: ConfigValueMigration[] = []; const migrations: ConfigValueMigration[] = [];
for (const [provider, providerConfig] of Object.entries(providers)) { for (const [provider, providerConfig] of Object.entries(providers)) {
if (typeof providerConfig !== "object" || providerConfig === null || Array.isArray(providerConfig)) continue; if (typeof providerConfig !== "object" || providerConfig === null || Array.isArray(providerConfig)) continue;
const providerRecord = providerConfig as Record<string, unknown>; const providerRecord = providerConfig as Record<string, unknown>;
const providerLocation = `models.json.providers[${JSON.stringify(provider)}]`; const providerLocation = `models.json.providers[${JSON.stringify(provider)}]`;
migrateStringProperty(providerRecord, "apiKey", `${providerLocation}.apiKey`, migrations); migrateStringProperty(providerRecord, "apiKey", `${providerLocation}.apiKey`, migrations);
migrateHeadersConfig(providerRecord.headers, `${providerLocation}.headers`, migrations); migrateHeadersConfig(providerRecord.headers, `${providerLocation}.headers`, migrations);
if (Array.isArray(providerRecord.models)) { if (Array.isArray(providerRecord.models)) {
for (let index = 0; index < providerRecord.models.length; index++) { for (let index = 0; index < providerRecord.models.length; index++) {
const modelConfig = providerRecord.models[index]; const modelConfig = providerRecord.models[index];
if (typeof modelConfig !== "object" || modelConfig === null || Array.isArray(modelConfig)) continue; if (typeof modelConfig !== "object" || modelConfig === null || Array.isArray(modelConfig)) continue;
const modelRecord = modelConfig as Record<string, unknown>; const modelRecord = modelConfig as Record<string, unknown>;
const modelKey = typeof modelRecord.id === "string" ? JSON.stringify(modelRecord.id) : String(index); const modelKey = typeof modelRecord.id === "string" ? JSON.stringify(modelRecord.id) : String(index);
migrateHeadersConfig(modelRecord.headers, `${providerLocation}.models[${modelKey}].headers`, migrations); 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<string, unknown>;
migrateHeadersConfig(
modelOverrideRecord.headers,
`${providerLocation}.modelOverrides[${JSON.stringify(modelId)}].headers`,
migrations,
);
}
} }
} }
const modelOverrides = providerRecord.modelOverrides; if (migrations.length === 0) return [];
if (typeof modelOverrides === "object" && modelOverrides !== null && !Array.isArray(modelOverrides)) { writeFileSync(modelsPath, `${JSON.stringify(parsed, null, 2)}\n`, "utf-8");
for (const [modelId, modelOverride] of Object.entries(modelOverrides)) { return migrations;
if (typeof modelOverride !== "object" || modelOverride === null || Array.isArray(modelOverride)) continue; } catch {
const modelOverrideRecord = modelOverride as Record<string, unknown>; return [];
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;
} }
function migrateExplicitEnvVarConfigValues(): void { function migrateExplicitEnvVarConfigValues(): void {

View File

@@ -56,7 +56,9 @@ export class LoginDialogComponent extends Container implements Focusable {
this.input = new Input(); this.input = new Input();
this.input.onSubmit = () => { this.input.onSubmit = () => {
if (this.inputResolver) { if (this.inputResolver) {
this.inputResolver(this.input.getValue()); const value = this.input.getValue();
this.replaceInputWithSubmittedText(value);
this.inputResolver(value);
this.inputResolver = undefined; this.inputResolver = undefined;
this.inputRejecter = undefined; this.inputRejecter = undefined;
} }
@@ -73,6 +75,12 @@ export class LoginDialogComponent extends Container implements Focusable {
return this.abortController.signal; 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 { private cancel(): void {
this.abortController.abort(); this.abortController.abort();
if (this.inputRejecter) { 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) * Show input for manual code/URL entry (for callback server providers)
*/ */
showManualInput(prompt: string): Promise<string> { showManualInput(prompt: string): Promise<string> {
this.input.setValue("");
this.contentContainer.addChild(new Spacer(1)); this.contentContainer.addChild(new Spacer(1));
this.contentContainer.addChild(new Text(theme.fg("dim", prompt), 1, 0)); this.contentContainer.addChild(new Text(theme.fg("dim", prompt), 1, 0));
this.contentContainer.addChild(this.input); this.contentContainer.addChild(this.input);

View File

@@ -12,7 +12,7 @@ import {
Text, Text,
} from "@earendil-works/pi-tui"; } from "@earendil-works/pi-tui";
import { formatHttpIdleTimeoutMs, HTTP_IDLE_TIMEOUT_CHOICES } from "../../../core/http-dispatcher.ts"; 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 { getSelectListTheme, getSettingsListTheme, theme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts"; import { DynamicBorder } from "./dynamic-border.ts";
import { keyDisplayText } from "./keybinding-hints.ts"; import { keyDisplayText } from "./keybinding-hints.ts";
@@ -31,6 +31,16 @@ const THINKING_DESCRIPTIONS: Record<ThinkingLevel, string> = {
xhigh: "Maximum reasoning (~32k tokens)", xhigh: "Maximum reasoning (~32k tokens)",
}; };
const DEFAULT_PROJECT_TRUST_LABELS: Record<DefaultProjectTrust, string> = {
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 { export interface SettingsConfig {
autoCompact: boolean; autoCompact: boolean;
showImages: boolean; showImages: boolean;
@@ -55,6 +65,7 @@ export interface SettingsConfig {
editorPaddingX: number; editorPaddingX: number;
autocompleteMaxVisible: number; autocompleteMaxVisible: number;
quietStartup: boolean; quietStartup: boolean;
defaultProjectTrust: DefaultProjectTrust;
clearOnShrink: boolean; clearOnShrink: boolean;
showTerminalProgress: boolean; showTerminalProgress: boolean;
warnings: WarningSettings; warnings: WarningSettings;
@@ -83,6 +94,7 @@ export interface SettingsCallbacks {
onEditorPaddingXChange: (padding: number) => void; onEditorPaddingXChange: (padding: number) => void;
onAutocompleteMaxVisibleChange: (maxVisible: number) => void; onAutocompleteMaxVisibleChange: (maxVisible: number) => void;
onQuietStartupChange: (enabled: boolean) => void; onQuietStartupChange: (enabled: boolean) => void;
onDefaultProjectTrustChange: (defaultProjectTrust: DefaultProjectTrust) => void;
onClearOnShrinkChange: (enabled: boolean) => void; onClearOnShrinkChange: (enabled: boolean) => void;
onShowTerminalProgressChange: (enabled: boolean) => void; onShowTerminalProgressChange: (enabled: boolean) => void;
onWarningsChange: (warnings: WarningSettings) => void; onWarningsChange: (warnings: WarningSettings) => void;
@@ -277,6 +289,13 @@ export class SettingsSelectorComponent extends Container {
currentValue: config.enableInstallTelemetry ? "true" : "false", currentValue: config.enableInstallTelemetry ? "true" : "false",
values: ["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", id: "double-escape-action",
label: "Double-escape action", label: "Double-escape action",
@@ -512,6 +531,13 @@ export class SettingsSelectorComponent extends Container {
case "install-telemetry": case "install-telemetry":
callbacks.onEnableInstallTelemetryChange(newValue === "true"); callbacks.onEnableInstallTelemetryChange(newValue === "true");
break; break;
case "default-project-trust": {
const defaultProjectTrust = DEFAULT_PROJECT_TRUST_BY_LABEL.get(newValue);
if (defaultProjectTrust) {
callbacks.onDefaultProjectTrustChange(defaultProjectTrust);
}
break;
}
case "double-escape-action": case "double-escape-action":
callbacks.onDoubleEscapeActionChange(newValue as "fork" | "tree"); callbacks.onDoubleEscapeActionChange(newValue as "fork" | "tree");
break; break;

View File

@@ -1,51 +1,51 @@
import { Container, getKeybindings, Spacer, Text } from "@earendil-works/pi-tui"; 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 { theme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts"; import { DynamicBorder } from "./dynamic-border.ts";
import { keyHint, rawKeyHint } from "./keybinding-hints.ts"; import { keyHint, rawKeyHint } from "./keybinding-hints.ts";
interface TrustOption { export type TrustSelection = Pick<ProjectTrustOption, "trusted" | "updates">;
label: string;
trusted: boolean;
}
export interface TrustSelectorOptions { export interface TrustSelectorOptions {
cwd: string; cwd: string;
savedDecision: ProjectTrustDecision; savedDecision: ProjectTrustStoreEntry | null;
projectTrusted: boolean; projectTrusted: boolean;
onSelect: (trusted: boolean) => void; onSelect: (selection: TrustSelection) => void;
onCancel: () => void; onCancel: () => void;
} }
const TRUST_OPTIONS: TrustOption[] = [ function formatDecision(cwd: string, decision: ProjectTrustStoreEntry | null): string {
{ label: "Trust", trusted: true }, if (decision === null) {
{ label: "Do not trust", trusted: false }, return "none";
];
function formatDecision(decision: ProjectTrustDecision): string {
if (decision === true) {
return "trusted";
} }
if (decision === false) { const label = decision.decision ? "trusted" : "untrusted";
return "untrusted"; if (decision.path !== getProjectTrustPath(cwd)) {
return `${label} (inherited from ${decision.path})`;
} }
return "none"; return `${label} (${decision.path})`;
} }
export class TrustSelectorComponent extends Container { export class TrustSelectorComponent extends Container {
private selectedIndex: number; private selectedIndex: number;
private readonly listContainer: Container; private readonly listContainer: Container;
private readonly savedDecision: ProjectTrustDecision; private readonly trustOptions: ProjectTrustOption[];
private readonly onSelectCallback: (trusted: boolean) => void; private readonly savedDecision: ProjectTrustStoreEntry | null;
private readonly onSelectCallback: (selection: TrustSelection) => void;
private readonly onCancelCallback: () => void; private readonly onCancelCallback: () => void;
constructor(options: TrustSelectorOptions) { constructor(options: TrustSelectorOptions) {
super(); super();
this.savedDecision = options.savedDecision; this.savedDecision = options.savedDecision;
this.trustOptions = getProjectTrustOptions(options.cwd);
this.selectedIndex = Math.max( this.selectedIndex = Math.max(
0, 0,
TRUST_OPTIONS.findIndex((option) => option.trusted === options.savedDecision), this.trustOptions.findIndex((option) => this.isSavedOption(option)),
); );
this.onSelectCallback = options.onSelect; this.onSelectCallback = options.onSelect;
this.onCancelCallback = options.onCancel; 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("accent", theme.bold("Project trust")), 1, 0));
this.addChild(new Text(theme.fg("muted", options.cwd), 1, 0)); this.addChild(new Text(theme.fg("muted", options.cwd), 1, 0));
this.addChild(new Spacer(1)); 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( this.addChild(
new Text(theme.fg("muted", `Current session: ${options.projectTrusted ? "trusted" : "untrusted"}`), 1, 0), new Text(theme.fg("muted", `Current session: ${options.projectTrusted ? "trusted" : "untrusted"}`), 1, 0),
); );
@@ -81,16 +83,24 @@ export class TrustSelectorComponent extends Container {
this.updateList(); this.updateList();
} }
private isSavedOption(option: ProjectTrustOption): boolean {
return (
option.savedPath !== undefined &&
this.savedDecision?.decision === option.trusted &&
this.savedDecision.path === option.savedPath
);
}
private updateList(): void { private updateList(): void {
this.listContainer.clear(); this.listContainer.clear();
for (let i = 0; i < TRUST_OPTIONS.length; i++) { for (let i = 0; i < this.trustOptions.length; i++) {
const option = TRUST_OPTIONS[i]; const option = this.trustOptions[i];
if (!option) { if (!option) {
continue; continue;
} }
const isSelected = i === this.selectedIndex; const isSelected = i === this.selectedIndex;
const isCurrent = option.trusted === this.savedDecision; const isCurrent = this.isSavedOption(option);
const checkmark = isCurrent ? theme.fg("success", " ✓") : ""; const checkmark = isCurrent ? theme.fg("success", " ✓") : "";
const prefix = isSelected ? theme.fg("accent", "→ ") : " "; const prefix = isSelected ? theme.fg("accent", "→ ") : " ";
const label = isSelected ? theme.fg("accent", option.label) : theme.fg("text", option.label); 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.selectedIndex = Math.max(0, this.selectedIndex - 1);
this.updateList(); this.updateList();
} else if (kb.matches(keyData, "tui.select.down") || keyData === "j") { } 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(); this.updateList();
} else if (kb.matches(keyData, "tui.select.confirm") || keyData === "\n") { } else if (kb.matches(keyData, "tui.select.confirm") || keyData === "\n") {
const selected = TRUST_OPTIONS[this.selectedIndex]; const selected = this.trustOptions[this.selectedIndex];
if (selected) { if (selected) {
this.onSelectCallback(selected.trusted); this.onSelectCallback({ trusted: selected.trusted, updates: selected.updates });
} }
} else if (kb.matches(keyData, "tui.select.cancel")) { } else if (kb.matches(keyData, "tui.select.cancel")) {
this.onCancelCallback(); this.onCancelCallback();

View File

@@ -87,7 +87,7 @@ import type { SourceInfo } from "../../core/source-info.ts";
import { isInstallTelemetryEnabled } from "../../core/telemetry.ts"; import { isInstallTelemetryEnabled } from "../../core/telemetry.ts";
import type { TruncationResult } from "../../core/tools/truncate.ts"; import type { TruncationResult } from "../../core/tools/truncate.ts";
import { hasProjectConfigDir, hasProjectTrustInputs, ProjectTrustStore } from "../../core/trust-manager.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 { copyToClipboard } from "../../utils/clipboard.ts";
import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.ts"; import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.ts";
import { parseGitUrl } from "../../utils/git.ts"; import { parseGitUrl } from "../../utils/git.ts";
@@ -555,8 +555,13 @@ export class InteractiveMode {
private setupAutocompleteProvider(): void { private setupAutocompleteProvider(): void {
let provider = this.createBaseAutocompleteProvider(); let provider = this.createBaseAutocompleteProvider();
const triggerCharacters: string[] = [];
for (const wrapProvider of this.autocompleteProviderWrappers) { for (const wrapProvider of this.autocompleteProviderWrappers) {
provider = wrapProvider(provider); provider = wrapProvider(provider);
triggerCharacters.push(...(provider.triggerCharacters ?? []));
}
if (triggerCharacters.length > 0) {
provider.triggerCharacters = [...new Set(triggerCharacters)];
} }
this.autocompleteProvider = provider; this.autocompleteProvider = provider;
@@ -909,7 +914,7 @@ export class InteractiveMode {
if (newEntries.length > 0) { if (newEntries.length > 0) {
this.settingsManager.setLastChangelogVersion(VERSION); this.settingsManager.setLastChangelogVersion(VERSION);
this.reportInstallTelemetry(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; return undefined;
@@ -1669,6 +1674,7 @@ export class InteractiveMode {
modelRegistry: this.session.modelRegistry, modelRegistry: this.session.modelRegistry,
model: this.session.model, model: this.session.model,
isIdle: () => !this.session.isStreaming, isIdle: () => !this.session.isStreaming,
isProjectTrusted: () => this.settingsManager.isProjectTrusted(),
signal: this.session.agent.signal, signal: this.session.agent.signal,
abort: () => { abort: () => {
this.restoreQueuedMessagesToEditor({ abort: true }); this.restoreQueuedMessagesToEditor({ abort: true });
@@ -3276,7 +3282,7 @@ export class InteractiveMode {
new Text( new Text(
theme.fg( theme.fg(
"warning", "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, 1,
0, 0,
@@ -3965,6 +3971,7 @@ export class InteractiveMode {
doubleEscapeAction: this.settingsManager.getDoubleEscapeAction(), doubleEscapeAction: this.settingsManager.getDoubleEscapeAction(),
treeFilterMode: this.settingsManager.getTreeFilterMode(), treeFilterMode: this.settingsManager.getTreeFilterMode(),
showHardwareCursor: this.settingsManager.getShowHardwareCursor(), showHardwareCursor: this.settingsManager.getShowHardwareCursor(),
defaultProjectTrust: this.settingsManager.getDefaultProjectTrust(),
editorPaddingX: this.settingsManager.getEditorPaddingX(), editorPaddingX: this.settingsManager.getEditorPaddingX(),
autocompleteMaxVisible: this.settingsManager.getAutocompleteMaxVisible(), autocompleteMaxVisible: this.settingsManager.getAutocompleteMaxVisible(),
quietStartup: this.settingsManager.getQuietStartup(), quietStartup: this.settingsManager.getQuietStartup(),
@@ -4058,6 +4065,9 @@ export class InteractiveMode {
onQuietStartupChange: (enabled) => { onQuietStartupChange: (enabled) => {
this.settingsManager.setQuietStartup(enabled); this.settingsManager.setQuietStartup(enabled);
}, },
onDefaultProjectTrustChange: (defaultProjectTrust) => {
this.settingsManager.setDefaultProjectTrust(defaultProjectTrust);
},
onDoubleEscapeActionChange: (action) => { onDoubleEscapeActionChange: (action) => {
this.settingsManager.setDoubleEscapeAction(action); this.settingsManager.setDoubleEscapeAction(action);
}, },
@@ -4212,17 +4222,17 @@ export class InteractiveMode {
private showTrustSelector(): void { private showTrustSelector(): void {
const cwd = this.sessionManager.getCwd(); const cwd = this.sessionManager.getCwd();
const trustStore = new ProjectTrustStore(this.runtimeHost.services.agentDir); const trustStore = new ProjectTrustStore(this.runtimeHost.services.agentDir);
const savedDecision = trustStore.get(cwd); const savedDecision = trustStore.getEntry(cwd);
this.showSelector((done) => { this.showSelector((done) => {
const selector = new TrustSelectorComponent({ const selector = new TrustSelectorComponent({
cwd, cwd,
savedDecision, savedDecision,
projectTrusted: this.settingsManager.isProjectTrusted(), projectTrusted: this.settingsManager.isProjectTrusted(),
onSelect: (trusted) => { onSelect: (selection) => {
trustStore.set(cwd, trusted); trustStore.setMany(selection.updates);
done(); done();
this.showStatus( 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: () => { onCancel: () => {
@@ -5380,7 +5390,7 @@ export class InteractiveMode {
allEntries.length > 0 allEntries.length > 0
? allEntries ? allEntries
.reverse() .reverse()
.map((e) => e.content) .map((e) => normalizeChangelogLinks(e.content, e))
.join("\n\n") .join("\n\n")
: "No changelog entries found."; : "No changelog entries found.";
@@ -5454,7 +5464,7 @@ export class InteractiveMode {
**Navigation** **Navigation**
| Key | Action | | 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 | | \`${cursorWordLeft}\` / \`${cursorWordRight}\` | Move by word |
| \`${cursorLineStart}\` | Start of line | | \`${cursorLineStart}\` | Start of line |
| \`${cursorLineEnd}\` | End of line | | \`${cursorLineEnd}\` | End of line |

View File

@@ -1,6 +1,7 @@
import { Markdown, type MarkdownTheme } from "@earendil-works/pi-tui"; import { Markdown, type MarkdownTheme } from "@earendil-works/pi-tui";
import chalk from "chalk"; import chalk from "chalk";
import { selectConfig } from "./cli/config-selector.ts"; import { selectConfig } from "./cli/config-selector.ts";
import { createProjectTrustContext } from "./cli/project-trust.ts";
import { import {
APP_NAME, APP_NAME,
detectInstallMethod, detectInstallMethod,
@@ -12,7 +13,10 @@ import {
type SelfUpdateCommand, type SelfUpdateCommand,
VERSION, VERSION,
} from "./config.ts"; } from "./config.ts";
import type { ExtensionFactory } from "./core/extensions/types.ts";
import { DefaultPackageManager } from "./core/package-manager.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 { SettingsManager } from "./core/settings-manager.ts";
import { hasProjectTrustInputs, ProjectTrustStore } from "./core/trust-manager.ts"; import { hasProjectTrustInputs, ProjectTrustStore } from "./core/trust-manager.ts";
import { spawnProcess } from "./utils/child-process.ts"; import { spawnProcess } from "./utils/child-process.ts";
@@ -425,22 +429,82 @@ function parseProjectTrustOverride(args: readonly string[]): boolean | undefined
return trustOverride; return trustOverride;
} }
function resolveProjectTrusted(cwd: string, agentDir: string, trustOverride: boolean | undefined): boolean { export interface PackageCommandRuntimeOptions {
if (trustOverride !== undefined) { extensionFactories?: ExtensionFactory[];
return trustOverride;
}
return !hasProjectTrustInputs(cwd) || new ProjectTrustStore(agentDir).get(cwd) === true;
} }
export async function handleConfigCommand(args: string[]): Promise<boolean> { 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<CommandSettingsResult> {
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<boolean> {
if (args[0] !== "config") { if (args[0] !== "config") {
return false; return false;
} }
const cwd = process.cwd(); const cwd = process.cwd();
const agentDir = getAgentDir(); const agentDir = getAgentDir();
const projectTrusted = parseProjectTrustOverride(args) ?? true; const { settingsManager, projectTrustWarnings } = await createCommandSettingsManager({
const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted }); cwd,
agentDir,
projectTrustOverride: parseProjectTrustOverride(args),
extensionFactories: runtimeOptions.extensionFactories,
});
reportProjectTrustWarnings(projectTrustWarnings);
reportSettingsErrors(settingsManager, "config command"); reportSettingsErrors(settingsManager, "config command");
const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager }); const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager });
const resolvedPaths = await packageManager.resolve(); const resolvedPaths = await packageManager.resolve();
@@ -455,7 +519,10 @@ export async function handleConfigCommand(args: string[]): Promise<boolean> {
process.exit(0); process.exit(0);
} }
export async function handlePackageCommand(args: string[]): Promise<boolean> { export async function handlePackageCommand(
args: string[],
runtimeOptions: PackageCommandRuntimeOptions = {},
): Promise<boolean> {
const options = parsePackageCommand(args); const options = parsePackageCommand(args);
if (!options) { if (!options) {
return false; return false;
@@ -505,13 +572,18 @@ export async function handlePackageCommand(args: string[]): Promise<boolean> {
const cwd = process.cwd(); const cwd = process.cwd();
const agentDir = getAgentDir(); const agentDir = getAgentDir();
const writesProjectPackageConfig = (options.command === "install" || options.command === "remove") && options.local; const writesProjectPackageConfig = (options.command === "install" || options.command === "remove") && options.local;
const projectTrusted = resolveProjectTrusted(cwd, agentDir, options.projectTrustOverride); const { settingsManager, projectTrustWarnings } = await createCommandSettingsManager({
if (!projectTrusted && writesProjectPackageConfig) { 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.")); console.error(chalk.red("Project is not trusted. Use --approve to modify local package config."));
process.exitCode = 1; process.exitCode = 1;
return true; return true;
} }
const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted });
reportSettingsErrors(settingsManager, "package command"); reportSettingsErrors(settingsManager, "package command");
const selfUpdateNpmCommand = settingsManager.getGlobalSettings().npmCommand; const selfUpdateNpmCommand = settingsManager.getGlobalSettings().npmCommand;

View File

@@ -1,3 +1,4 @@
import path from "node:path";
import { existsSync, readFileSync } from "fs"; import { existsSync, readFileSync } from "fs";
export interface ChangelogEntry { export interface ChangelogEntry {
@@ -7,6 +8,102 @@ export interface ChangelogEntry {
content: string; 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 * Parse changelog entries from CHANGELOG.md
* Scans for ## lines and collects content until next ## or EOF * Scans for ## lines and collects content until next ## or EOF

View File

@@ -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"),
);
});
});

View File

@@ -3,6 +3,8 @@ import * as os from "node:os";
import * as path from "node:path"; import * as path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { ENV_AGENT_DIR } from "../src/config.ts"; 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"; import { runMigrations } from "../src/migrations.ts";
describe("config value env var syntax migration", () => { 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'); 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", () => { it("rewrites legacy uppercase models.json API key and header values", () => {
const agentDir = createAgentDir(); const agentDir = createAgentDir();
fs.writeFileSync( fs.writeFileSync(

View File

@@ -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);
});
});

View File

@@ -76,6 +76,7 @@ describe("ExtensionRunner", () => {
const extensionContextActions: ExtensionContextActions = { const extensionContextActions: ExtensionContextActions = {
getModel: () => undefined, getModel: () => undefined,
isIdle: () => true, isIdle: () => true,
isProjectTrusted: () => true,
getSignal: () => undefined, getSignal: () => undefined,
abort: () => {}, abort: () => {},
hasPendingMessages: () => false, hasPendingMessages: () => false,
@@ -496,6 +497,18 @@ describe("ExtensionRunner", () => {
expect(ctx.hasUI).toBe(false); 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 () => { it("exposes rpc mode with hasUI true when an RPC UI context is provided", async () => {
const result = await discoverAndLoadExtensions([], tempDir, tempDir); const result = await discoverAndLoadExtensions([], tempDir, tempDir);
const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry);

View File

@@ -324,6 +324,36 @@ describe("InteractiveMode.setupAutocompleteProvider", () => {
expect(provider.shouldTriggerFileCompletion?.(["foo"], 0, 3)).toBe(true); expect(provider.shouldTriggerFileCompletion?.(["foo"], 0, 3)).toBe(true);
expect(calls).toEqual(["shouldTrigger:wrap2", "shouldTrigger:wrap1"]); 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", () => { describe("InteractiveMode.showLoadedResources", () => {

View File

@@ -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 () => { it("blocks local package changes when project is untrusted", async () => {
mkdirSync(join(projectDir, ".pi"), { recursive: true }); mkdirSync(join(projectDir, ".pi"), { recursive: true });
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});

View File

@@ -376,7 +376,7 @@ Content`,
expect(loader.getSystemPrompt()).toBe("You are a helpful assistant."); 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 piDir = join(cwd, ".pi");
const extensionsDir = join(piDir, "extensions"); const extensionsDir = join(piDir, "extensions");
const skillDir = join(piDir, "skills", "project-skill"); 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( expect(loader.getAgentsFiles().agentsFiles.some((file) => file.path === join(agentDir, "AGENTS.md"))).toBe(
true, 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().extensions).toHaveLength(0);
expect(loader.getExtensions().errors).toEqual([]); expect(loader.getExtensions().errors).toEqual([]);
expect(loader.getSkills().skills.some((skill) => skill.name === "project-skill")).toBe(false); expect(loader.getSkills().skills.some((skill) => skill.name === "project-skill")).toBe(false);

View File

@@ -250,6 +250,23 @@ describe("SettingsManager", () => {
expect(manager.getProjectSettings()).toEqual({}); expect(manager.getProjectSettings()).toEqual({});
expect(JSON.parse(readFileSync(projectSettingsPath, "utf-8"))).toEqual({ packages: ["npm:existing"] }); 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", () => { describe("project settings directory creation", () => {

View File

@@ -80,6 +80,22 @@ async function runCli(args: string[]): Promise<{ stdout: string; stderr: string;
} }
describe("stdout cleanliness in non-interactive modes", () => { 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 () => { it("keeps stdout empty for --mode json --help while routing trusted startup chatter to stderr", async () => {
const result = await runCli(["--mode", "json", "--help", "--approve"]); const result = await runCli(["--mode", "json", "--help", "--approve"]);

View File

@@ -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");
});
});

View File

@@ -12,6 +12,7 @@ function createContext(tokens: number | null, compact = vi.fn()): ExtensionConte
modelRegistry: {} as ExtensionContext["modelRegistry"], modelRegistry: {} as ExtensionContext["modelRegistry"],
model: undefined, model: undefined,
isIdle: () => true, isIdle: () => true,
isProjectTrusted: () => true,
signal: undefined, signal: undefined,
abort: vi.fn(), abort: vi.fn(),
hasPendingMessages: () => false, hasPendingMessages: () => false,

View File

@@ -2,7 +2,12 @@ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest"; 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", () => { describe("ProjectTrustStore", () => {
let tempDir: string; let tempDir: string;
@@ -25,12 +30,52 @@ describe("ProjectTrustStore", () => {
const store = new ProjectTrustStore(agentDir); const store = new ProjectTrustStore(agentDir);
expect(store.get(cwd)).toBeNull(); expect(store.get(cwd)).toBeNull();
expect(store.getEntry(cwd)).toBeNull();
store.set(cwd, true); store.set(cwd, true);
expect(store.get(cwd)).toBe(true); expect(store.get(cwd)).toBe(true);
expect(store.getEntry(cwd)).toEqual({ path: getProjectTrustPath(cwd), decision: true });
store.set(cwd, false); store.set(cwd, false);
expect(store.get(cwd)).toBe(false); expect(store.get(cwd)).toBe(false);
expect(store.getEntry(cwd)).toEqual({ path: getProjectTrustPath(cwd), decision: false });
store.set(cwd, null); store.set(cwd, null);
expect(store.get(cwd)).toBeNull(); 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", () => { it("fails loudly without overwriting malformed trust stores", () => {
@@ -53,9 +98,13 @@ describe("ProjectTrustStore", () => {
rmSync(join(cwd, ".pi"), { recursive: true, force: true }); rmSync(join(cwd, ".pi"), { recursive: true, force: true });
writeFileSync(join(cwd, "AGENTS.md"), "Project instructions"); writeFileSync(join(cwd, "AGENTS.md"), "Project instructions");
expect(hasProjectTrustInputs(cwd)).toBe(true); expect(hasProjectTrustInputs(cwd)).toBe(false);
rmSync(join(cwd, "AGENTS.md"), { force: true }); 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 }); mkdirSync(join(cwd, ".agents", "skills"), { recursive: true });
expect(hasProjectTrustInputs(cwd)).toBe(true); expect(hasProjectTrustInputs(cwd)).toBe(true);
}); });

View File

@@ -17,7 +17,7 @@ describe("TrustSelectorComponent", () => {
it("marks the saved trusted decision", () => { it("marks the saved trusted decision", () => {
const selector = new TrustSelectorComponent({ const selector = new TrustSelectorComponent({
cwd: "/project", cwd: "/project",
savedDecision: true, savedDecision: { path: "/project", decision: true },
projectTrusted: true, projectTrusted: true,
onSelect: () => {}, onSelect: () => {},
onCancel: () => {}, onCancel: () => {},
@@ -25,7 +25,7 @@ describe("TrustSelectorComponent", () => {
const output = stripAnsi(selector.render(120).join("\n")); 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("Current session: trusted");
expect(output).toContain("Trust ✓"); expect(output).toContain("Trust ✓");
expect(output).not.toContain("Do not trust ✓"); expect(output).not.toContain("Do not trust ✓");
@@ -43,6 +43,45 @@ describe("TrustSelectorComponent", () => {
selector.handleInput("\n"); 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 },
],
});
}); });
}); });

View File

@@ -4,6 +4,7 @@ import { defineConfig } from "vitest/config";
const aiSrcIndex = fileURLToPath(new URL("../ai/src/index.ts", import.meta.url)); 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 aiSrcOAuth = fileURLToPath(new URL("../ai/src/oauth.ts", import.meta.url));
const agentSrcIndex = fileURLToPath(new URL("../agent/src/index.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({ export default defineConfig({
test: { test: {
@@ -21,9 +22,11 @@ export default defineConfig({
{ find: /^@earendil-works\/pi-ai$/, replacement: aiSrcIndex }, { find: /^@earendil-works\/pi-ai$/, replacement: aiSrcIndex },
{ find: /^@earendil-works\/pi-ai\/oauth$/, replacement: aiSrcOAuth }, { find: /^@earendil-works\/pi-ai\/oauth$/, replacement: aiSrcOAuth },
{ find: /^@earendil-works\/pi-agent-core$/, replacement: agentSrcIndex }, { 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$/, replacement: aiSrcIndex },
{ find: /^@mariozechner\/pi-ai\/oauth$/, replacement: aiSrcOAuth }, { find: /^@mariozechner\/pi-ai\/oauth$/, replacement: aiSrcOAuth },
{ find: /^@mariozechner\/pi-agent-core$/, replacement: agentSrcIndex }, { find: /^@mariozechner\/pi-agent-core$/, replacement: agentSrcIndex },
{ find: /^@mariozechner\/pi-tui$/, replacement: tuiSrcIndex },
], ],
}, },
}); });

View File

@@ -1,5 +1,16 @@
# Changelog # 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 ## [0.79.0] - 2026-06-08
### Fixed ### Fixed

View File

@@ -239,6 +239,9 @@ export interface AutocompleteSuggestions {
} }
export interface AutocompleteProvider { export interface AutocompleteProvider {
/** Characters that should naturally trigger this provider at token boundaries. */
triggerCharacters?: string[];
// Get autocomplete suggestions for current text/cursor position // Get autocomplete suggestions for current text/cursor position
// Returns null if no suggestions available // Returns null if no suggestions available
getSuggestions( getSuggestions(

View File

@@ -219,6 +219,20 @@ const SLASH_COMMAND_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
}; };
const ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS = 20; 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 { export class Editor implements Component, Focusable {
private state: EditorState = { private state: EditorState = {
@@ -245,6 +259,9 @@ export class Editor implements Component, Focusable {
// Autocomplete support // Autocomplete support
private autocompleteProvider?: AutocompleteProvider; private autocompleteProvider?: AutocompleteProvider;
private autocompleteTriggerCharacters = [...DEFAULT_AUTOCOMPLETE_TRIGGER_CHARACTERS];
private autocompleteTriggerPattern = buildTriggerPattern(this.autocompleteTriggerCharacters);
private autocompleteDebouncePattern = buildDebouncePattern(this.autocompleteTriggerCharacters);
private autocompleteList?: SelectList; private autocompleteList?: SelectList;
private autocompleteState: "regular" | "force" | null = null; private autocompleteState: "regular" | "force" | null = null;
private autocompletePrefix: string = ""; private autocompletePrefix: string = "";
@@ -266,6 +283,7 @@ export class Editor implements Component, Focusable {
// Prompt history for up/down navigation // Prompt history for up/down navigation
private history: string[] = []; private history: string[] = [];
private historyIndex: number = -1; // -1 = not browsing, 0 = most recent, 1 = older, etc. 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 // Kill ring for Emacs-style kill/yank operations
private killRing = new KillRing(); private killRing = new KillRing();
@@ -338,6 +356,7 @@ export class Editor implements Component, Focusable {
setAutocompleteProvider(provider: AutocompleteProvider): void { setAutocompleteProvider(provider: AutocompleteProvider): void {
this.cancelAutocomplete(); this.cancelAutocomplete();
this.autocompleteProvider = provider; 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 { private isOnFirstVisualLine(): boolean {
const visualLines = this.buildVisualLineMap(this.lastWidth); const visualLines = this.buildVisualLineMap(this.lastWidth);
const currentVisualLine = this.findCurrentVisualLine(visualLines); const currentVisualLine = this.findCurrentVisualLine(visualLines);
@@ -382,18 +397,33 @@ export class Editor implements Component, Focusable {
// Capture state when first entering history browsing mode // Capture state when first entering history browsing mode
if (this.historyIndex === -1 && newIndex >= 0) { if (this.historyIndex === -1 && newIndex >= 0) {
this.pushUndoSnapshot(); this.pushUndoSnapshot();
this.historyDraft = structuredClone(this.state);
} }
this.historyIndex = newIndex; this.historyIndex = newIndex;
if (this.historyIndex === -1) { if (this.historyIndex === -1) {
// Returned to "current" state - clear editor const draft = this.historyDraft;
this.setTextInternal(""); 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 { } else {
this.setTextInternal(this.history[this.historyIndex] || "", direction === -1 ? "start" : "end"); 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 */ /** Internal setText that doesn't reset history state - used by navigateHistory */
private setTextInternal(text: string, cursorPlacement: "start" | "end" = "end"): void { private setTextInternal(text: string, cursorPlacement: "start" | "end" = "end"): void {
const lines = text.split("\n"); const lines = text.split("\n");
@@ -469,8 +499,10 @@ export class Editor implements Component, Focusable {
} }
// Render each visible layout line // Render each visible layout line
// Emit hardware cursor marker only when focused and not showing autocomplete // Emit hardware cursor marker when focused so TUI can position the
const emitCursorMarker = this.focused && !this.autocompleteState; // 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) { for (const layoutLine of visibleLines) {
let displayText = layoutLine.text; let displayText = layoutLine.text;
@@ -756,9 +788,7 @@ export class Editor implements Component, Focusable {
// Arrow key navigation (with history support) // Arrow key navigation (with history support)
if (kb.matches(data, "tui.editor.cursorUp")) { if (kb.matches(data, "tui.editor.cursorUp")) {
if (this.isEditorEmpty()) { if (this.isOnFirstVisualLine() && this.history.length > 0) {
this.navigateHistory(-1);
} else if (this.historyIndex > -1 && this.isOnFirstVisualLine()) {
this.navigateHistory(-1); this.navigateHistory(-1);
} else if (this.isOnFirstVisualLine()) { } else if (this.isOnFirstVisualLine()) {
// Already at top - jump to start of line // Already at top - jump to start of line
@@ -946,7 +976,7 @@ export class Editor implements Component, Focusable {
setText(text: string): void { setText(text: string): void {
this.cancelAutocomplete(); this.cancelAutocomplete();
this.lastAction = null; this.lastAction = null;
this.historyIndex = -1; // Exit history browsing mode this.exitHistoryBrowsing();
const normalized = this.normalizeText(text); const normalized = this.normalizeText(text);
// Push undo snapshot if content differs (makes programmatic changes undoable) // Push undo snapshot if content differs (makes programmatic changes undoable)
if (this.getText() !== normalized) { if (this.getText() !== normalized) {
@@ -965,7 +995,7 @@ export class Editor implements Component, Focusable {
this.cancelAutocomplete(); this.cancelAutocomplete();
this.pushUndoSnapshot(); this.pushUndoSnapshot();
this.lastAction = null; this.lastAction = null;
this.historyIndex = -1; this.exitHistoryBrowsing();
this.insertTextAtCursorInternal(text); this.insertTextAtCursorInternal(text);
} }
@@ -1028,7 +1058,7 @@ export class Editor implements Component, Focusable {
// All the editor methods from before... // All the editor methods from before...
private insertCharacter(char: string, skipUndoCoalescing?: boolean): void { private insertCharacter(char: string, skipUndoCoalescing?: boolean): void {
this.historyIndex = -1; // Exit history browsing mode this.exitHistoryBrowsing();
// Undo coalescing (fish-style): // Undo coalescing (fish-style):
// - Consecutive word chars coalesce into one undo unit // - Consecutive word chars coalesce into one undo unit
@@ -1060,8 +1090,8 @@ export class Editor implements Component, Focusable {
if (char === "/" && this.isAtStartOfMessage()) { if (char === "/" && this.isAtStartOfMessage()) {
this.tryTriggerAutocomplete(); this.tryTriggerAutocomplete();
} }
// Auto-trigger for symbol-based completion like @ or # at token boundaries // Auto-trigger for symbol-based completion like @, #, or provider triggers at token boundaries
else if (char === "@" || char === "#") { else if (this.autocompleteTriggerCharacters.includes(char)) {
const currentLine = this.state.lines[this.state.cursorLine] || ""; const currentLine = this.state.lines[this.state.cursorLine] || "";
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol); const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
const charBeforeSymbol = textBeforeCursor[textBeforeCursor.length - 2]; const charBeforeSymbol = textBeforeCursor[textBeforeCursor.length - 2];
@@ -1077,8 +1107,8 @@ export class Editor implements Component, Focusable {
if (this.isInSlashCommandContext(textBeforeCursor)) { if (this.isInSlashCommandContext(textBeforeCursor)) {
this.tryTriggerAutocomplete(); this.tryTriggerAutocomplete();
} }
// Check if we're in a symbol-based completion context like @ or # // Check if we're in a symbol-based completion context like @, #, or provider triggers
else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) { else if (this.autocompleteTriggerPattern.test(textBeforeCursor)) {
this.tryTriggerAutocomplete(); this.tryTriggerAutocomplete();
} }
} }
@@ -1089,7 +1119,7 @@ export class Editor implements Component, Focusable {
private handlePaste(pastedText: string): void { private handlePaste(pastedText: string): void {
this.cancelAutocomplete(); this.cancelAutocomplete();
this.historyIndex = -1; // Exit history browsing mode this.exitHistoryBrowsing();
this.lastAction = null; this.lastAction = null;
this.pushUndoSnapshot(); this.pushUndoSnapshot();
@@ -1157,7 +1187,7 @@ export class Editor implements Component, Focusable {
private addNewLine(): void { private addNewLine(): void {
this.cancelAutocomplete(); this.cancelAutocomplete();
this.historyIndex = -1; // Exit history browsing mode this.exitHistoryBrowsing();
this.lastAction = null; this.lastAction = null;
this.pushUndoSnapshot(); this.pushUndoSnapshot();
@@ -1198,7 +1228,7 @@ export class Editor implements Component, Focusable {
this.state = { lines: [""], cursorLine: 0, cursorCol: 0 }; this.state = { lines: [""], cursorLine: 0, cursorCol: 0 };
this.pastes.clear(); this.pastes.clear();
this.pasteCounter = 0; this.pasteCounter = 0;
this.historyIndex = -1; this.exitHistoryBrowsing();
this.scrollOffset = 0; this.scrollOffset = 0;
this.undoStack.clear(); this.undoStack.clear();
this.lastAction = null; this.lastAction = null;
@@ -1208,7 +1238,7 @@ export class Editor implements Component, Focusable {
} }
private handleBackspace(): void { private handleBackspace(): void {
this.historyIndex = -1; // Exit history browsing mode this.exitHistoryBrowsing();
this.lastAction = null; this.lastAction = null;
if (this.state.cursorCol > 0) { if (this.state.cursorCol > 0) {
@@ -1257,8 +1287,8 @@ export class Editor implements Component, Focusable {
if (this.isInSlashCommandContext(textBeforeCursor)) { if (this.isInSlashCommandContext(textBeforeCursor)) {
this.tryTriggerAutocomplete(); this.tryTriggerAutocomplete();
} }
// Symbol-based completion context like @ or # // Symbol-based completion context like @, #, or provider triggers
else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) { else if (this.autocompleteTriggerPattern.test(textBeforeCursor)) {
this.tryTriggerAutocomplete(); this.tryTriggerAutocomplete();
} }
} }
@@ -1425,7 +1455,7 @@ export class Editor implements Component, Focusable {
} }
private deleteToStartOfLine(): void { private deleteToStartOfLine(): void {
this.historyIndex = -1; // Exit history browsing mode this.exitHistoryBrowsing();
const currentLine = this.state.lines[this.state.cursorLine] || ""; const currentLine = this.state.lines[this.state.cursorLine] || "";
@@ -1460,7 +1490,7 @@ export class Editor implements Component, Focusable {
} }
private deleteToEndOfLine(): void { private deleteToEndOfLine(): void {
this.historyIndex = -1; // Exit history browsing mode this.exitHistoryBrowsing();
const currentLine = this.state.lines[this.state.cursorLine] || ""; const currentLine = this.state.lines[this.state.cursorLine] || "";
@@ -1492,7 +1522,7 @@ export class Editor implements Component, Focusable {
} }
private deleteWordBackwards(): void { private deleteWordBackwards(): void {
this.historyIndex = -1; // Exit history browsing mode this.exitHistoryBrowsing();
const currentLine = this.state.lines[this.state.cursorLine] || ""; const currentLine = this.state.lines[this.state.cursorLine] || "";
@@ -1537,7 +1567,7 @@ export class Editor implements Component, Focusable {
} }
private deleteWordForward(): void { private deleteWordForward(): void {
this.historyIndex = -1; // Exit history browsing mode this.exitHistoryBrowsing();
const currentLine = this.state.lines[this.state.cursorLine] || ""; const currentLine = this.state.lines[this.state.cursorLine] || "";
@@ -1579,7 +1609,7 @@ export class Editor implements Component, Focusable {
} }
private handleForwardDelete(): void { private handleForwardDelete(): void {
this.historyIndex = -1; // Exit history browsing mode this.exitHistoryBrowsing();
this.lastAction = null; this.lastAction = null;
const currentLine = this.state.lines[this.state.cursorLine] || ""; const currentLine = this.state.lines[this.state.cursorLine] || "";
@@ -1621,8 +1651,8 @@ export class Editor implements Component, Focusable {
if (this.isInSlashCommandContext(textBeforeCursor)) { if (this.isInSlashCommandContext(textBeforeCursor)) {
this.tryTriggerAutocomplete(); this.tryTriggerAutocomplete();
} }
// Symbol-based completion context like @ or # // Symbol-based completion context like @, #, or provider triggers
else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) { else if (this.autocompleteTriggerPattern.test(textBeforeCursor)) {
this.tryTriggerAutocomplete(); this.tryTriggerAutocomplete();
} }
} }
@@ -1835,7 +1865,7 @@ export class Editor implements Component, Focusable {
* Insert text at cursor position (used by yank operations). * Insert text at cursor position (used by yank operations).
*/ */
private insertYankedText(text: string): void { private insertYankedText(text: string): void {
this.historyIndex = -1; // Exit history browsing mode this.exitHistoryBrowsing();
const lines = text.split("\n"); const lines = text.split("\n");
if (lines.length === 1) { if (lines.length === 1) {
@@ -1920,7 +1950,7 @@ export class Editor implements Component, Focusable {
} }
private undo(): void { private undo(): void {
this.historyIndex = -1; // Exit history browsing mode this.exitHistoryBrowsing();
const snapshot = this.undoStack.pop(); const snapshot = this.undoStack.pop();
if (!snapshot) return; if (!snapshot) return;
Object.assign(this.state, snapshot); Object.assign(this.state, snapshot);
@@ -2120,6 +2150,19 @@ export class Editor implements Component, Focusable {
await this.autocompleteRequestTask; 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 { private getAutocompleteDebounceMs(options: { force: boolean; explicitTab: boolean }): number {
if (options.explicitTab || options.force) { if (options.explicitTab || options.force) {
return 0; return 0;
@@ -2127,8 +2170,7 @@ export class Editor implements Component, Focusable {
const currentLine = this.state.lines[this.state.cursorLine] || ""; const currentLine = this.state.lines[this.state.cursorLine] || "";
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol); const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
const isSymbolAutocompleteContext = /(?:^|[ \t])(?:@(?:"[^"]*|[^\s]*)|#[^\s]*)$/.test(textBeforeCursor); return this.autocompleteDebouncePattern.test(textBeforeCursor) ? ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS : 0;
return isSymbolAutocompleteContext ? ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS : 0;
} }
private async runAutocompleteRequest( private async runAutocompleteRequest(

View File

@@ -45,6 +45,9 @@ const rgiEmojiRegex = /^\p{RGI_Emoji}$/v;
const WIDTH_CACHE_SIZE = 512; const WIDTH_CACHE_SIZE = 512;
const widthCache = new Map<string, number>(); const widthCache = new Map<string, number>();
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 { function isPrintableAscii(str: string): boolean {
for (let i = 0; i < str.length; i++) { for (let i = 0; i < str.length; i++) {
const code = str.charCodeAt(i); const code = str.charCodeAt(i);
@@ -605,9 +608,18 @@ function splitIntoTokensWithAnsi(text: string): string[] {
const tokens: string[] = []; const tokens: string[] = [];
let current = ""; let current = "";
let pendingAnsi = ""; // ANSI codes waiting to be attached to next visible content let pendingAnsi = ""; // ANSI codes waiting to be attached to next visible content
let inWhitespace = false; let currentKind: "space" | "word" | null = null;
let i = 0; let i = 0;
const flushCurrent = (): void => {
if (!current) {
return;
}
tokens.push(current);
current = "";
currentKind = null;
};
while (i < text.length) { while (i < text.length) {
const ansiResult = extractAnsiCode(text, i); const ansiResult = extractAnsiCode(text, i);
if (ansiResult) { if (ansiResult) {
@@ -617,29 +629,48 @@ function splitIntoTokensWithAnsi(text: string): string[] {
continue; continue;
} }
const char = text[i]; let end = i;
const charIsSpace = char === " "; while (end < text.length && !extractAnsiCode(text, end)) {
end++;
if (charIsSpace !== inWhitespace && current) {
// Switching between whitespace and non-whitespace, push current token
tokens.push(current);
current = "";
} }
// Attach any pending ANSI codes to this visible character for (const { segment } of graphemeSegmenter.segment(text.slice(i, end))) {
if (pendingAnsi) { const segmentIsSpace = segment === " ";
current += pendingAnsi; if (!segmentIsSpace && cjkBreakRegex.test(segment)) {
pendingAnsi = ""; 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; i = end;
current += char;
i++;
} }
// Handle any remaining pending ANSI codes (attach to last token) // Handle any remaining pending ANSI codes (attach to last token)
if (pendingAnsi) { if (pendingAnsi) {
current += pendingAnsi; if (current) {
current += pendingAnsi;
} else if (tokens.length > 0) {
tokens[tokens.length - 1] += pendingAnsi;
} else {
current = pendingAnsi;
}
} }
if (current) { if (current) {

View File

@@ -79,16 +79,20 @@ describe("Editor component", () => {
assert.strictEqual(editor.getText(), "first"); 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); const editor = new Editor(createTestTUI(), defaultEditorTheme);
editor.addToHistory("prompt"); editor.addToHistory("prompt");
editor.setText("draft");
editor.handleInput("\x1b[D");
editor.handleInput("\x1b[D");
editor.handleInput("\x1b[A"); // Up - shows "prompt" editor.handleInput("\x1b[A"); // Up - shows "prompt"
assert.strictEqual(editor.getText(), "prompt"); assert.strictEqual(editor.getText(), "prompt");
editor.handleInput("\x1b[B"); // Down - clears editor editor.handleInput("\x1b[B"); // Down - restores draft
assert.strictEqual(editor.getText(), ""); assert.strictEqual(editor.getText(), "draft");
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 3 });
}); });
it("navigates forward through history with Down arrow", () => { it("navigates forward through history with Down arrow", () => {
@@ -97,6 +101,7 @@ describe("Editor component", () => {
editor.addToHistory("first"); editor.addToHistory("first");
editor.addToHistory("second"); editor.addToHistory("second");
editor.addToHistory("third"); editor.addToHistory("third");
editor.setText("draft");
// Go to oldest // Go to oldest
editor.handleInput("\x1b[A"); // third editor.handleInput("\x1b[A"); // third
@@ -110,8 +115,8 @@ describe("Editor component", () => {
editor.handleInput("\x1b[B"); // third editor.handleInput("\x1b[B"); // third
assert.strictEqual(editor.getText(), "third"); assert.strictEqual(editor.getText(), "third");
editor.handleInput("\x1b[B"); // empty editor.handleInput("\x1b[B"); // draft
assert.strictEqual(editor.getText(), ""); assert.strictEqual(editor.getText(), "draft");
}); });
it("exits history mode when typing a character", () => { it("exits history mode when typing a character", () => {
@@ -2342,6 +2347,58 @@ describe("Editor component", () => {
assert.strictEqual(editor.isShowingAutocomplete(), true); 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 () => { it("aborts active @ autocomplete when typing continues", async () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme); const editor = new Editor(createTestTUI(), defaultEditorTheme);
let aborts = 0; let aborts = 0;

View File

@@ -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", () => { it("should ignore OSC 133 semantic markers in visible width", () => {
const text = "\x1b]133;A\x07hello\x1b]133;B\x07"; const text = "\x1b]133;A\x07hello\x1b]133;B\x07";
assert.strictEqual(visibleWidth(text), 5); assert.strictEqual(visibleWidth(text), 5);

364
scripts/release-notes.mjs Normal file
View File

@@ -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 <command> [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 <x.y.z> Version to extract
--tag <vX.Y.Z> Release tag used for repository links (defaults to v<version>)
--changelog <path> Changelog path (default: ${DEFAULT_CHANGELOG})
--out <path> Output file (default: stdout)
--repo <owner/repo> GitHub repository for generated links (default: ${DEFAULT_REPO})
--base-path <path> Base path for relative changelog links (default: ${DEFAULT_BASE_PATH})
fix-github-releases options:
--repo <owner/repo> GitHub repository to patch (default: ${DEFAULT_REPO})
--tag <vX.Y.Z> Patch only one release tag
--since-tag <vX.Y.Z> Oldest release tag to patch (default: ${DEFAULT_FIX_SINCE_TAG})
--base-path <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);
}