diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index b9e7397ce..2db836841 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,13 @@ ## [Unreleased] +### Fixed + +- Fixed GitHub Copilot model context window detection by correcting fallback priority for maxContextWindowTokens and maxPromptTokens +- Fixed Gemini 2.5 Pro context window detection in GitHub Copilot model limits test +- Fixed Claude Opus 4.6 context window detection in GitHub Copilot model limits test + +- Added environment-based credential fallback for the OpenAI Codex provider. ## [13.17.6] - 2026-04-01 ### Fixed @@ -1965,4 +1972,4 @@ _Dedicated to Peter's shoulder ([@steipete](https://twitter.com/steipete))_ ## [0.9.4] - 2025-11-26 -Initial release with multi-provider LLM support. +Initial release with multi-provider LLM support. \ No newline at end of file diff --git a/packages/ai/src/model-thinking.ts b/packages/ai/src/model-thinking.ts index 621ec677a..ee675275a 100644 --- a/packages/ai/src/model-thinking.ts +++ b/packages/ai/src/model-thinking.ts @@ -289,6 +289,13 @@ function applyAnthropicCatalogPolicy(model: ApiModel, parsedModel: Anthropi model.cost.cacheWrite = 6.25; } + // GitHub Copilot Opus 4.6: discovery currently reports a stale 144K prompt window, + // but the model supports a 1M context window. Keep the bundled catalog truthful + // until Copilot fixes the upstream metadata. + if (model.provider === "github-copilot" && parsedModel.kind === "opus" && semverEqual(parsedModel.version, "4.6")) { + model.contextWindow = 1000000; + } + // Bedrock Opus 4.6: upstream metadata is stale for cache pricing and context. if (model.provider === "amazon-bedrock" && parsedModel.kind === "opus" && semverEqual(parsedModel.version, "4.6")) { model.cost.cacheRead = 0.5; diff --git a/packages/ai/src/models.json b/packages/ai/src/models.json index 456c78258..0e6beb02c 100644 --- a/packages/ai/src/models.json +++ b/packages/ai/src/models.json @@ -202,6 +202,34 @@ "minLevel": "minimal", "maxLevel": "high" } + }, + "qwen3.6-plus": { + "id": "qwen3.6-plus", + "name": "Qwen3.6 Plus", + "api": "openai-completions", + "provider": "alibaba-coding-plan", + "baseUrl": "https://coding-intl.dashscope.aliyuncs.com/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 1000000, + "maxTokens": 65536, + "compat": { + "supportsDeveloperRole": false + }, + "thinking": { + "mode": "effort", + "minLevel": "minimal", + "maxLevel": "high" + } } }, "amazon-bedrock": { @@ -4605,7 +4633,7 @@ "cacheRead": 0, "cacheWrite": 0 }, - "contextWindow": 144000, + "contextWindow": 1000000, "maxTokens": 64000, "headers": { "User-Agent": "GitHubCopilotChat/0.35.0", @@ -6290,6 +6318,56 @@ }, "contextWindow": 131072, "maxTokens": 8192 + }, + "gemma-4-26b": { + "id": "gemma-4-26b", + "name": "Gemma 4 26B", + "api": "google-generative-ai", + "provider": "google", + "baseUrl": "https://generativelanguage.googleapis.com/v1beta", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 256000, + "maxTokens": 8192, + "thinking": { + "mode": "budget", + "minLevel": "minimal", + "maxLevel": "high" + } + }, + "gemma-4-31b": { + "id": "gemma-4-31b", + "name": "Gemma 4 31B", + "api": "google-generative-ai", + "provider": "google", + "baseUrl": "https://generativelanguage.googleapis.com/v1beta", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 256000, + "maxTokens": 8192, + "thinking": { + "mode": "budget", + "minLevel": "minimal", + "maxLevel": "high" + } } }, "google-antigravity": { @@ -6813,6 +6891,31 @@ "minLevel": "low", "maxLevel": "high" } + }, + "gemini-3.1-flash-lite-preview": { + "id": "gemini-3.1-flash-lite-preview", + "name": "Gemini 3.1 Flash Lite Preview", + "api": "google-gemini-cli", + "provider": "google-gemini-cli", + "baseUrl": "https://cloudcode-pa.googleapis.com", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 1048576, + "maxTokens": 65536, + "thinking": { + "mode": "google-level", + "minLevel": "minimal", + "maxLevel": "high" + } } }, "google-vertex": { @@ -8518,6 +8621,25 @@ "contextWindow": 222222, "maxTokens": 8888 }, + "bytedance-seed/dola-seed-2.0-pro:free": { + "id": "bytedance-seed/dola-seed-2.0-pro:free", + "name": "ByteDance Seed: Dola Seed 2.0 Pro (free)", + "api": "openai-completions", + "provider": "kilo", + "baseUrl": "https://api.kilo.ai/api/gateway", + "reasoning": false, + "input": [ + "text" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 222222, + "maxTokens": 8888 + }, "bytedance-seed/seed-1.6": { "id": "bytedance-seed/seed-1.6", "name": "ByteDance Seed: Seed 1.6", @@ -9504,6 +9626,50 @@ "contextWindow": 128000, "maxTokens": 4096 }, + "google/gemma-4-26b-a4b-it": { + "id": "google/gemma-4-26b-a4b-it", + "name": "Google: Gemma 4 26B A4B", + "api": "openai-completions", + "provider": "kilo", + "baseUrl": "https://api.kilo.ai/api/gateway", + "reasoning": false, + "input": [ + "text" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 222222, + "maxTokens": 8888 + }, + "google/gemma-4-31b-it": { + "id": "google/gemma-4-31b-it", + "name": "Gemma-4-31B-IT", + "api": "openai-completions", + "provider": "kilo", + "baseUrl": "https://api.kilo.ai/api/gateway", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 256000, + "maxTokens": 16384, + "thinking": { + "mode": "effort", + "minLevel": "minimal", + "maxLevel": "xhigh" + } + }, "google/lyria-3-clip-preview": { "id": "google/lyria-3-clip-preview", "name": "Google: Lyria 3 Clip Preview", @@ -13889,6 +14055,25 @@ "contextWindow": 222222, "maxTokens": 8888 }, + "qwen/qwen3.6-plus:free": { + "id": "qwen/qwen3.6-plus:free", + "name": "Qwen: Qwen3.6 Plus (free)", + "api": "openai-completions", + "provider": "kilo", + "baseUrl": "https://api.kilo.ai/api/gateway", + "reasoning": false, + "input": [ + "text" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 222222, + "maxTokens": 8888 + }, "qwen/qwq-32b": { "id": "qwen/qwq-32b", "name": "Qwen: QwQ 32B", @@ -14969,13 +15154,14 @@ }, "z-ai/glm-5v-turbo": { "id": "z-ai/glm-5v-turbo", - "name": "Z.ai: GLM 5V Turbo", + "name": "GLM 5V Turbo", "api": "openai-completions", "provider": "kilo", "baseUrl": "https://api.kilo.ai/api/gateway", - "reasoning": false, + "reasoning": true, "input": [ - "text" + "text", + "image" ], "cost": { "input": 0, @@ -14983,8 +15169,13 @@ "cacheRead": 0, "cacheWrite": 0 }, - "contextWindow": 222222, - "maxTokens": 8888 + "contextWindow": 200000, + "maxTokens": 128000, + "thinking": { + "mode": "effort", + "minLevel": "minimal", + "maxLevel": "xhigh" + } } }, "kimi-code": { @@ -18257,6 +18448,30 @@ "contextWindow": 222222, "maxTokens": 8888 }, + "arcee-ai/trinity-large-thinking": { + "id": "arcee-ai/trinity-large-thinking", + "name": "Arcee AI: Trinity Large Thinking", + "api": "openai-completions", + "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", + "reasoning": true, + "input": [ + "text" + ], + "cost": { + "input": 0.25, + "output": 0.8999999999999999, + "cacheRead": 0.06, + "cacheWrite": 0 + }, + "contextWindow": 262144, + "maxTokens": 80000, + "thinking": { + "mode": "effort", + "minLevel": "minimal", + "maxLevel": "xhigh" + } + }, "arcee-ai/trinity-mini": { "id": "arcee-ai/trinity-mini", "name": "Arcee AI: Trinity Mini", @@ -21562,6 +21777,25 @@ "contextWindow": 222222, "maxTokens": 8888 }, + "glm-4.7-flash-heretic": { + "id": "glm-4.7-flash-heretic", + "name": "glm-4.7-flash-heretic", + "api": "openai-completions", + "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", + "reasoning": false, + "input": [ + "text" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 222222, + "maxTokens": 8888 + }, "glm-z1-air": { "id": "glm-z1-air", "name": "glm-z1-air", @@ -21847,6 +22081,56 @@ "contextWindow": 222222, "maxTokens": 8888 }, + "google/gemma-4-26b-a4b-it": { + "id": "google/gemma-4-26b-a4b-it", + "name": "Google: Gemma 4 26B A4B", + "api": "openai-completions", + "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0.13, + "output": 0.39999999999999997, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 262144, + "maxTokens": 262144, + "thinking": { + "mode": "effort", + "minLevel": "minimal", + "maxLevel": "xhigh" + } + }, + "google/gemma-4-31b-it": { + "id": "google/gemma-4-31b-it", + "name": "Gemma-4-31B-IT", + "api": "openai-completions", + "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 256000, + "maxTokens": 16384, + "thinking": { + "mode": "effort", + "minLevel": "minimal", + "maxLevel": "xhigh" + } + }, "grok-3-beta": { "id": "grok-3-beta", "name": "grok-3-beta", @@ -25945,6 +26229,25 @@ "contextWindow": 222222, "maxTokens": 8888 }, + "qwen-3.6-plus": { + "id": "qwen-3.6-plus", + "name": "qwen-3.6-plus", + "api": "openai-completions", + "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", + "reasoning": false, + "input": [ + "text" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 222222, + "maxTokens": 8888 + }, "qwen-long": { "id": "qwen-long", "name": "qwen-long", @@ -27514,6 +27817,25 @@ "contextWindow": 222222, "maxTokens": 8888 }, + "TEE/gemma4-31b": { + "id": "TEE/gemma4-31b", + "name": "TEE/gemma4-31b", + "api": "openai-completions", + "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", + "reasoning": false, + "input": [ + "text" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 222222, + "maxTokens": 8888 + }, "TEE/glm-4.6": { "id": "TEE/glm-4.6", "name": "TEE/glm-4.6", @@ -28543,22 +28865,28 @@ }, "x-ai/grok-4.20": { "id": "x-ai/grok-4.20", - "name": "x-ai/grok-4.20", + "name": "xAI: Grok 4.20", "api": "openai-completions", "provider": "nanogpt", "baseUrl": "https://nano-gpt.com/api/v1", - "reasoning": false, + "reasoning": true, "input": [ - "text" + "text", + "image" ], "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, + "input": 2, + "output": 6, + "cacheRead": 0.19999999999999998, "cacheWrite": 0 }, - "contextWindow": 222222, - "maxTokens": 8888 + "contextWindow": 2000000, + "maxTokens": 8888, + "thinking": { + "mode": "effort", + "minLevel": "minimal", + "maxLevel": "xhigh" + } }, "x-ai/grok-4.20-beta-non-reasoning": { "id": "x-ai/grok-4.20-beta-non-reasoning", @@ -28916,6 +29244,31 @@ "maxLevel": "xhigh" } }, + "z-ai/glm-5v-turbo": { + "id": "z-ai/glm-5v-turbo", + "name": "GLM 5V Turbo", + "api": "openai-completions", + "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 200000, + "maxTokens": 128000, + "thinking": { + "mode": "effort", + "minLevel": "minimal", + "maxLevel": "xhigh" + } + }, "zai-org/glm-4.5": { "id": "zai-org/glm-4.5", "name": "zai-org/glm-4.5", @@ -29486,6 +29839,31 @@ "contextWindow": 128000, "maxTokens": 4096 }, + "google/gemma-4-31b-it": { + "id": "google/gemma-4-31b-it", + "name": "Gemma-4-31B-IT", + "api": "openai-completions", + "provider": "nvidia", + "baseUrl": "https://integrate.api.nvidia.com/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 256000, + "maxTokens": 16384, + "thinking": { + "mode": "effort", + "minLevel": "minimal", + "maxLevel": "xhigh" + } + }, "meta/llama-3.1-405b-instruct": { "id": "meta/llama-3.1-405b-instruct", "name": "Llama 3.1 405b Instruct", @@ -32042,12 +32420,61 @@ "maxLevel": "xhigh" } }, + "mimo-v2-omni": { + "id": "mimo-v2-omni", + "name": "MiMo V2 Omni", + "api": "openai-completions", + "provider": "opencode-go", + "baseUrl": "https://opencode.ai/zen/go/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0.4, + "output": 2, + "cacheRead": 0.08, + "cacheWrite": 0 + }, + "contextWindow": 262144, + "maxTokens": 64000, + "thinking": { + "mode": "effort", + "minLevel": "minimal", + "maxLevel": "xhigh" + } + }, + "mimo-v2-pro": { + "id": "mimo-v2-pro", + "name": "MiMo V2 Pro", + "api": "openai-completions", + "provider": "opencode-go", + "baseUrl": "https://opencode.ai/zen/go/v1", + "reasoning": true, + "input": [ + "text" + ], + "cost": { + "input": 1, + "output": 3, + "cacheRead": 0.2, + "cacheWrite": 0 + }, + "contextWindow": 1048576, + "maxTokens": 64000, + "thinking": { + "mode": "effort", + "minLevel": "minimal", + "maxLevel": "xhigh" + } + }, "minimax-m2.5": { "id": "minimax-m2.5", "name": "MiniMax M2.5", - "api": "anthropic-messages", + "api": "openai-completions", "provider": "opencode-go", - "baseUrl": "https://opencode.ai/zen/go", + "baseUrl": "https://opencode.ai/zen/go/v1", "reasoning": true, "input": [ "text" @@ -32061,7 +32488,7 @@ "contextWindow": 204800, "maxTokens": 131072, "thinking": { - "mode": "budget", + "mode": "effort", "minLevel": "minimal", "maxLevel": "xhigh" } @@ -33062,7 +33489,7 @@ "cacheRead": 0, "cacheWrite": 0 }, - "contextWindow": 1000000, + "contextWindow": 204800, "maxTokens": 128000, "thinking": { "mode": "effort", @@ -33631,13 +34058,13 @@ "text" ], "cost": { - "input": 0.25, - "output": 0.8999999999999999, + "input": 0.22, + "output": 0.85, "cacheRead": 0.06, "cacheWrite": 0 }, "contextWindow": 262144, - "maxTokens": 80000, + "maxTokens": 262144, "thinking": { "mode": "effort", "minLevel": "minimal", @@ -34251,7 +34678,7 @@ "cacheWrite": 0.08333333333333334 }, "contextWindow": 1048576, - "maxTokens": 65536, + "maxTokens": 65535, "thinking": { "mode": "effort", "minLevel": "minimal", @@ -34523,6 +34950,56 @@ "contextWindow": 131072, "maxTokens": 8192 }, + "google/gemma-4-26b-a4b-it": { + "id": "google/gemma-4-26b-a4b-it", + "name": "Google: Gemma 4 26B A4B", + "api": "openai-completions", + "provider": "openrouter", + "baseUrl": "https://openrouter.ai/api/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0.13, + "output": 0.39999999999999997, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 262144, + "maxTokens": 262144, + "thinking": { + "mode": "effort", + "minLevel": "minimal", + "maxLevel": "high" + } + }, + "google/gemma-4-31b-it": { + "id": "google/gemma-4-31b-it", + "name": "Gemma-4-31B-IT", + "api": "openai-completions", + "provider": "openrouter", + "baseUrl": "https://openrouter.ai/api/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0.14, + "output": 0.39999999999999997, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 256000, + "maxTokens": 16384, + "thinking": { + "mode": "effort", + "minLevel": "minimal", + "maxLevel": "high" + } + }, "inception/mercury": { "id": "inception/mercury", "name": "Inception: Mercury", @@ -34879,9 +35356,9 @@ "text" ], "cost": { - "input": 0.12, - "output": 1, - "cacheRead": 0.06, + "input": 0.118, + "output": 0.9900000000000001, + "cacheRead": 0.059, "cacheWrite": 0 }, "contextWindow": 204800, @@ -35527,9 +36004,9 @@ "image" ], "cost": { - "input": 0.42, - "output": 2.2, - "cacheRead": 0.21, + "input": 0.3827, + "output": 1.72, + "cacheRead": 0.1935, "cacheWrite": 0 }, "contextWindow": 262144, @@ -38279,6 +38756,31 @@ "maxLevel": "high" } }, + "qwen/qwen3.6-plus:free": { + "id": "qwen/qwen3.6-plus:free", + "name": "Qwen: Qwen3.6 Plus (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": 1000000, + "maxTokens": 65536, + "thinking": { + "mode": "effort", + "minLevel": "minimal", + "maxLevel": "high" + } + }, "qwen/qwq-32b": { "id": "qwen/qwq-32b", "name": "Qwen: QwQ 32B", @@ -39174,7 +39676,7 @@ }, "z-ai/glm-5v-turbo": { "id": "z-ai/glm-5v-turbo", - "name": "Z.ai: GLM 5V Turbo", + "name": "GLM 5V Turbo", "api": "openai-completions", "provider": "openrouter", "baseUrl": "https://openrouter.ai/api/v1", @@ -39189,8 +39691,8 @@ "cacheRead": 0.24, "cacheWrite": 0 }, - "contextWindow": 202752, - "maxTokens": 131072, + "contextWindow": 200000, + "maxTokens": 128000, "thinking": { "mode": "effort", "minLevel": "minimal", @@ -39813,6 +40315,28 @@ "supportsUsageInStreaming": false } }, + "arcee-trinity-large-thinking": { + "id": "arcee-trinity-large-thinking", + "name": "arcee-trinity-large-thinking", + "api": "openai-completions", + "provider": "venice", + "baseUrl": "https://api.venice.ai/api/v1", + "reasoning": false, + "input": [ + "text" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 222222, + "maxTokens": 8888, + "compat": { + "supportsUsageInStreaming": false + } + }, "claude-opus-4-5": { "id": "claude-opus-4-5", "name": "Claude Opus 4.5 (latest)", @@ -40351,6 +40875,50 @@ "supportsUsageInStreaming": false } }, + "google.gemma-4-26b-a4b-it": { + "id": "google.gemma-4-26b-a4b-it", + "name": "google.gemma-4-26b-a4b-it", + "api": "openai-completions", + "provider": "venice", + "baseUrl": "https://api.venice.ai/api/v1", + "reasoning": false, + "input": [ + "text" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 222222, + "maxTokens": 8888, + "compat": { + "supportsUsageInStreaming": false + } + }, + "google.gemma-4-31b-it": { + "id": "google.gemma-4-31b-it", + "name": "google.gemma-4-31b-it", + "api": "openai-completions", + "provider": "venice", + "baseUrl": "https://api.venice.ai/api/v1", + "reasoning": false, + "input": [ + "text" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 222222, + "maxTokens": 8888, + "compat": { + "supportsUsageInStreaming": false + } + }, "grok-4-20-beta": { "id": "grok-4-20-beta", "name": "grok-4-20-beta", @@ -45167,6 +45735,31 @@ "minLevel": "minimal", "maxLevel": "xhigh" } + }, + "glm-5v-turbo": { + "id": "glm-5v-turbo", + "name": "glm-5v-turbo", + "api": "anthropic-messages", + "provider": "zai", + "baseUrl": "https://api.z.ai/api/anthropic", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 200000, + "maxTokens": 131072, + "thinking": { + "mode": "budget", + "minLevel": "minimal", + "maxLevel": "xhigh" + } } }, "zenmux": { @@ -45514,7 +46107,7 @@ "cacheRead": 0.056, "cacheWrite": 0 }, - "contextWindow": 128000, + "contextWindow": 163840, "maxTokens": 8888 }, "deepseek/deepseek-r1-0528": { @@ -46089,7 +46682,7 @@ }, "kuaishou/kat-coder-pro-v2": { "id": "kuaishou/kat-coder-pro-v2", - "name": "KwaiKAT: KAT-Coder-Pro-V2", + "name": "KAT-Coder-Pro-V2", "api": "openai-completions", "provider": "zenmux", "baseUrl": "https://zenmux.ai/api/v1", @@ -46104,7 +46697,7 @@ "cacheWrite": 0 }, "contextWindow": 256000, - "maxTokens": 8888 + "maxTokens": 80000 }, "meta/llama-3.3-70b-instruct": { "id": "meta/llama-3.3-70b-instruct", @@ -46344,7 +46937,7 @@ "cacheRead": 0.2, "cacheWrite": 0.003 }, - "contextWindow": 128000, + "contextWindow": 32768, "maxTokens": 8888 }, "moonshotai/kimi-k2-0905": { @@ -47258,6 +47851,30 @@ "maxLevel": "high" } }, + "qwen/qwen3.6-plus": { + "id": "qwen/qwen3.6-plus", + "name": "Qwen3.6-Plus", + "api": "openai-completions", + "provider": "zenmux", + "baseUrl": "https://zenmux.ai/api/v1", + "reasoning": true, + "input": [ + "text" + ], + "cost": { + "input": 0.5, + "output": 3, + "cacheRead": 0.05, + "cacheWrite": 0.625 + }, + "contextWindow": 1000000, + "maxTokens": 64000, + "thinking": { + "mode": "effort", + "minLevel": "minimal", + "maxLevel": "high" + } + }, "sapiens-ai/agnes-1.5-lite": { "id": "sapiens-ai/agnes-1.5-lite", "name": "Sapiens AI: Agnes-1.5-Lite", @@ -48111,6 +48728,31 @@ "minLevel": "minimal", "maxLevel": "xhigh" } + }, + "z-ai/glm-5v-turbo": { + "id": "z-ai/glm-5v-turbo", + "name": "GLM 5V Turbo", + "api": "openai-completions", + "provider": "zenmux", + "baseUrl": "https://zenmux.ai/api/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0.726, + "output": 3.1946, + "cacheRead": 0.1743, + "cacheWrite": 0 + }, + "contextWindow": 200000, + "maxTokens": 128000, + "thinking": { + "mode": "effort", + "minLevel": "minimal", + "maxLevel": "xhigh" + } } } } \ No newline at end of file diff --git a/packages/ai/src/provider-models/openai-compat.ts b/packages/ai/src/provider-models/openai-compat.ts index b885ebfd9..9fd289a6e 100644 --- a/packages/ai/src/provider-models/openai-compat.ts +++ b/packages/ai/src/provider-models/openai-compat.ts @@ -1519,9 +1519,9 @@ export function githubCopilotModelManagerOptions(config?: GithubCopilotModelMana const contextWindow = toPositiveNumber( entry.context_length, toPositiveNumber( - copilotLimits.maxPromptTokens, + copilotLimits.maxContextWindowTokens, toPositiveNumber( - copilotLimits.maxContextWindowTokens, + copilotLimits.maxPromptTokens, reference?.contextWindow ?? defaults.contextWindow, ), ), diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts index b3988794f..d45d8b76f 100644 --- a/packages/ai/src/stream.ts +++ b/packages/ai/src/stream.ts @@ -78,6 +78,7 @@ const serviceProviderMap: Record = { "opencode-go": "OPENCODE_API_KEY", "opencode-zen": "OPENCODE_API_KEY", cursor: "CURSOR_ACCESS_TOKEN", + "openai-codex": "OPENAI_CODEX_OAUTH_TOKEN", "azure-openai-responses": "AZURE_OPENAI_API_KEY", exa: "EXA_API_KEY", jina: "JINA_API_KEY", diff --git a/packages/ai/test/github-copilot-model-limits.test.ts b/packages/ai/test/github-copilot-model-limits.test.ts index 907978dc7..da3fc5424 100644 --- a/packages/ai/test/github-copilot-model-limits.test.ts +++ b/packages/ai/test/github-copilot-model-limits.test.ts @@ -87,7 +87,7 @@ describe("github copilot model limits mapping", () => { const model = models.find(candidate => candidate.id === "gemini-2.5-pro"); expect(model).toBeDefined(); - expect(model?.contextWindow).toBe(128_000); + expect(model?.contextWindow).toBe(1_048_576); expect(model?.maxTokens).toBe(64_000); expect(fetchMock).toHaveBeenCalledTimes(1); }); @@ -137,7 +137,7 @@ describe("github copilot model limits mapping", () => { const model = models.find(candidate => candidate.id === "claude-opus-4.6"); expect(model).toBeDefined(); - expect(model?.contextWindow).toBe(128_000); + expect(model?.contextWindow).toBe(200_000); expect(model?.maxTokens).toBe(16_000); }); it("inherits bundled GPT-5.4 mini reasoning metadata during discovery", async () => { diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ade98768b..313e2affd 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,18 +1,9 @@ # Changelog ## [Unreleased] + ### Added -- Added `edit.manageImports` setting to enable/disable post-edit import and include management -- Added import/include management for TypeScript, JavaScript, Python, Rust, Go, and C/C++ in edit operations -- Added `imports` parameter to patch, hashline, and replace edit operations to declare imports/includes needed after code changes -- Added `ImportSpec` type to specify structured import requests with support for named imports, default imports, namespace imports, aliases, and system includes -- Added comprehensive import handler modules for each supported language with deduplication and grouping logic -- Added `edit.manageImports` setting to enable/disable post-edit import and include management -- Added import/include management for TypeScript, JavaScript, Python, Rust, Go, and C/C++ in edit operations -- Added `imports` parameter to patch, hashline, and replace edit operations to declare imports/includes needed after code changes -- Added `ImportSpec` type to specify structured import requests with support for named imports, default imports, namespace imports, aliases, and system includes -- Added comprehensive import handler modules for each supported language with deduplication and grouping logic - Added `onExternalEditor` callback to extension UI dialog options for handling external editor shortcut in select dialogs - Added external editor shortcut support in plan review selector, allowing users to open and edit the plan in their configured editor - Added `matchesAppExternalEditor` keybinding matcher to detect external editor shortcut (Ctrl+G or configured binding) @@ -23,9 +14,6 @@ ### Changed -- Updated edit tool prompts to document import management feature when enabled -- Modified edit tool schemas to conditionally include `imports` parameter based on `edit.manageImports` setting -- Enhanced patch, hashline, and replace tool documentation with import management examples and guidance - Updated hook editor and hook selector components to use `matchesAppExternalEditor` matcher for consistent external editor keybinding detection - Modified plan review flow to read the latest plan content from disk before approval, allowing changes made in external editor to be reflected - Enhanced plan review help text to dynamically display the configured external editor keybinding @@ -47,6 +35,8 @@ ### Fixed - Fixed isolated task merge-back to preserve task outputs on merge failure and stash dirty worktrees before cherry-pick. +- Fixed web search source rendering to truncate long title, metadata, and URL lines before they overflow the UI. +- Fixed PR checkout tool to resolve symlinks in worktree paths, ensuring consistent path references in results and metadata - Fixed `read` output for file-backed internal URLs like `local://...` to include hashline prefixes in hashline edit mode, preserving usable line refs for follow-up edits - Fixed the plan review selector to support the external editor shortcut for opening and updating the current plan from the approval screen @@ -402,6 +392,9 @@ - Fixed resumed and session-switched GitHub Copilot/OpenAI Responses conversations replaying stale assistant native history from older saved sessions by sanitizing persisted assistant replay metadata on rehydration and resetting provider session state across live session boundaries ([#505](https://github.com/can1357/oh-my-pi/issues/505)) +### Added + +- Session observer overlay (`Ctrl+S`): view running subagent sessions with a picker and read-only transcript showing thinking, text, tool calls, and results ## [13.14.0] - 2026-03-20 ### Added diff --git a/packages/coding-agent/src/config/keybindings.ts b/packages/coding-agent/src/config/keybindings.ts index c7b92e832..3b80149c4 100644 --- a/packages/coding-agent/src/config/keybindings.ts +++ b/packages/coding-agent/src/config/keybindings.ts @@ -37,6 +37,7 @@ interface AppKeybindings { "app.session.tree": true; "app.session.fork": true; "app.session.resume": true; + "app.session.observe": true; "app.session.togglePath": true; "app.session.toggleSort": true; "app.session.rename": true; @@ -144,6 +145,10 @@ export const KEYBINDINGS = { defaultKeys: [], description: "Resume session", }, + "app.session.observe": { + defaultKeys: "ctrl+s", + description: "Observe subagent sessions", + }, "app.session.togglePath": { defaultKeys: "ctrl+p", description: "Toggle session path display", @@ -214,6 +219,7 @@ const KEYBINDING_NAME_MIGRATIONS = { tree: "app.session.tree", fork: "app.session.fork", resume: "app.session.resume", + observeSessions: "app.session.observe", toggleSTT: "app.stt.toggle", // TUI editor (old names for backward compatibility) cursorUp: "tui.editor.cursorUp", @@ -260,9 +266,6 @@ function isLegacyKeybindingName(key: string): key is keyof typeof KEYBINDING_NAM return key in KEYBINDING_NAME_MIGRATIONS; } -/** - * Normalize input to KeybindingsConfig, validating types. - */ function toKeybindingsConfig(value: unknown): KeybindingsConfig { if (typeof value !== "object" || value === null) { return {}; @@ -270,15 +273,13 @@ function toKeybindingsConfig(value: unknown): KeybindingsConfig { const config: KeybindingsConfig = {}; for (const [key, val] of Object.entries(value)) { - // Allow undefined, string (KeyId), or array of strings if (val === undefined) { config[key] = undefined; } else if (typeof val === "string") { config[key] = val as KeyId; } else if (Array.isArray(val) && val.every(v => typeof v === "string")) { - config[key] = val as string[] as KeyId[]; + config[key] = val as KeyId[]; } - // Silently skip invalid entries } return config; } diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 32a8a1406..019063bba 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -958,16 +958,6 @@ export const SETTINGS_SCHEMA = { }, }, - "edit.manageImports": { - type: "boolean", - default: false, - ui: { - tab: "editing", - label: "Manage Imports After Edit", - description: "Expose post-edit import/include management for the edit tool", - }, - }, - readLineNumbers: { type: "boolean", default: false, diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index fa15b2d6f..c344e9d2c 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -48,6 +48,7 @@ import type { AgentSession } from "./session/agent-session"; import { resolveResumableSession, type SessionInfo, SessionManager } from "./session/session-manager"; import { resolvePromptInput } from "./system-prompt"; import { getChangelogPath, getNewEntries, parseChangelog } from "./utils/changelog"; +import type { EventBus } from "./utils/event-bus"; async function checkForNewVersion(currentVersion: string): Promise { if (!settings.get("startup.checkUpdate")) { @@ -119,10 +120,19 @@ async function runInteractiveMode( setExtensionUIContext: (uiContext: ExtensionUIContext, hasUI: boolean) => void, lspServers: Array<{ name: string; status: "ready" | "error"; fileTypes: string[]; error?: string }> | undefined, mcpManager: MCPManager | undefined, + eventBus?: EventBus, initialMessage?: string, initialImages?: ImageContent[], ): Promise { - const mode = new InteractiveMode(session, version, changelogMarkdown, setExtensionUIContext, lspServers, mcpManager); + const mode = new InteractiveMode( + session, + version, + changelogMarkdown, + setExtensionUIContext, + lspServers, + mcpManager, + eventBus, + ); await mode.init(); @@ -718,7 +728,7 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise createAgentSession(sessionOptions), ); @@ -806,6 +816,7 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise viewer + * - shortcut while in viewer -> back to picker + * - Esc from viewer -> back to picker + * - Esc from picker -> close overlay + * - Enter on main session -> close overlay (jump back) + */ +import type { AssistantMessage, ToolResultMessage } from "@oh-my-pi/pi-ai"; +import { Container, Markdown, matchesKey, type SelectItem, SelectList, Spacer, Text } from "@oh-my-pi/pi-tui"; +import { formatDuration, formatNumber, logger } from "@oh-my-pi/pi-utils"; +import type { KeyId } from "../../config/keybindings"; +import type { SessionMessageEntry } from "../../session/session-manager"; +import { parseSessionEntries } from "../../session/session-manager"; +import { replaceTabs, shortenPath, truncateToWidth } from "../../tools/render-utils"; +import type { ObservableSession, SessionObserverRegistry } from "../session-observer-registry"; +import { getMarkdownTheme, getSelectListTheme, theme } from "../theme/theme"; +import { DynamicBorder } from "./dynamic-border"; + +type Mode = "picker" | "viewer"; + +/** Max thinking characters to show (long thinking can be huge) */ +const MAX_THINKING_CHARS = 600; +/** Max tool args characters to display */ +const MAX_TOOL_ARGS_CHARS = 200; +/** Max tool result text to display */ +const MAX_TOOL_RESULT_CHARS = 300; + +export class SessionObserverOverlayComponent extends Container { + #registry: SessionObserverRegistry; + #onDone: () => void; + #mode: Mode = "picker"; + #selectList: SelectList; + #viewerContainer: Container; + #selectedSessionId?: string; + #observeKeys: KeyId[]; + /** Cached parsed transcript per session file to avoid reparsing on every refresh */ + #transcriptCache?: { path: string; bytesRead: number; entries: SessionMessageEntry[] }; + + constructor(registry: SessionObserverRegistry, onDone: () => void, observeKeys: KeyId[]) { + super(); + this.#registry = registry; + this.#onDone = onDone; + this.#observeKeys = observeKeys; + this.#selectList = new SelectList([], 0, getSelectListTheme()); + this.#viewerContainer = new Container(); + + this.#setupPicker(); + } + + #setupPicker(): void { + this.#mode = "picker"; + this.children = []; + + this.addChild(new DynamicBorder()); + this.addChild(new Text(theme.bold(theme.fg("accent", "Session Observer")), 1, 0)); + this.addChild(new Spacer(1)); + + const items = this.#buildPickerItems(); + this.#selectList = new SelectList(items, Math.min(items.length, 12), getSelectListTheme()); + + this.#selectList.onSelect = item => { + if (item.value === "main") { + this.#onDone(); + return; + } + this.#selectedSessionId = item.value; + this.#setupViewer(); + }; + + this.#selectList.onCancel = () => { + this.#onDone(); + }; + + this.addChild(this.#selectList); + this.addChild(new DynamicBorder()); + } + + #setupViewer(): void { + this.#mode = "viewer"; + this.children = []; + this.#viewerContainer = new Container(); + this.#refreshViewer(); + + this.addChild(new DynamicBorder()); + this.addChild(this.#viewerContainer); + this.addChild(new Spacer(1)); + this.addChild(new Text(theme.fg("dim", "Esc: back to picker | Ctrl+S: back to picker"), 1, 0)); + this.addChild(new DynamicBorder()); + } + + /** Rebuild content from live registry data */ + refreshFromRegistry(): void { + if (this.#mode === "picker") { + this.#refreshPickerItems(); + } else if (this.#mode === "viewer" && this.#selectedSessionId) { + this.#refreshViewer(); + } + } + + #refreshPickerItems(): void { + // Preserve selection across refresh by matching on value + const previousValue = this.#selectList.getSelectedItem()?.value; + + const items = this.#buildPickerItems(); + const newList = new SelectList(items, Math.min(items.length, 12), getSelectListTheme()); + newList.onSelect = this.#selectList.onSelect; + newList.onCancel = this.#selectList.onCancel; + + if (previousValue) { + const newIndex = items.findIndex(i => i.value === previousValue); + if (newIndex >= 0) newList.setSelectedIndex(newIndex); + } + + const idx = this.children.indexOf(this.#selectList); + if (idx >= 0) { + this.children[idx] = newList; + } + this.#selectList = newList; + } + + #refreshViewer(): void { + this.#viewerContainer.clear(); + + const sessions = this.#registry.getSessions(); + const session = sessions.find(s => s.id === this.#selectedSessionId); + if (!session) { + this.#viewerContainer.addChild(new Text(theme.fg("dim", "Session no longer available."), 1, 0)); + return; + } + + this.#renderSessionHeader(session); + this.#renderSessionTranscript(session); + } + + #renderSessionHeader(session: ObservableSession): void { + const c = this.#viewerContainer; + const progress = session.progress; + + // Header: label + status + [agent] + const statusColor = session.status === "active" ? "success" : session.status === "failed" ? "error" : "dim"; + const statusText = theme.fg(statusColor, session.status); + const agentTag = session.agent ? theme.fg("dim", ` [${session.agent}]`) : ""; + c.addChild(new Text(`${theme.bold(theme.fg("accent", session.label))} ${statusText}${agentTag}`, 1, 0)); + + if (session.description) { + c.addChild(new Text(theme.fg("muted", session.description), 1, 0)); + } + + // Stats from progress + if (progress) { + const stats: string[] = []; + if (progress.toolCount > 0) stats.push(`${formatNumber(progress.toolCount)} tools`); + if (progress.tokens > 0) stats.push(`${formatNumber(progress.tokens)} tokens`); + if (progress.durationMs > 0) stats.push(formatDuration(progress.durationMs)); + if (stats.length > 0) { + c.addChild(new Text(theme.fg("dim", stats.join(theme.sep.dot)), 1, 0)); + } + } + + if (session.sessionFile) { + c.addChild(new Text(theme.fg("dim", `Session: ${shortenPath(session.sessionFile)}`), 1, 0)); + } + + c.addChild(new DynamicBorder()); + } + + /** Incrementally read and parse the session JSONL, caching already-parsed entries. */ + #loadTranscript(sessionFile: string): SessionMessageEntry[] | null { + // Invalidate cache if session file changed (e.g. switched to different subagent) + if (this.#transcriptCache && this.#transcriptCache.path !== sessionFile) { + this.#transcriptCache = undefined; + } + + const fromByte = this.#transcriptCache?.bytesRead ?? 0; + const result = readFileIncremental(sessionFile, fromByte); + if (!result) { + logger.debug("Session observer: failed to read session file", { path: sessionFile }); + return this.#transcriptCache?.entries ?? null; + } + + // File shrank (compaction or pruning rewrote it) — invalidate and re-read from scratch + if (result.newSize < fromByte) { + this.#transcriptCache = undefined; + return this.#loadTranscript(sessionFile); + } + + if (!this.#transcriptCache) { + this.#transcriptCache = { path: sessionFile, bytesRead: 0, entries: [] }; + } + + // Parse only new bytes, but only up to the last complete line. + // A partial trailing record (mid-write) must not be consumed — + // we leave those bytes for the next refresh. + if (result.text.length > 0) { + const lastNewline = result.text.lastIndexOf("\n"); + if (lastNewline >= 0) { + const completeChunk = result.text.slice(0, lastNewline + 1); + const newEntries = parseSessionEntries(completeChunk); + for (const entry of newEntries) { + if (entry.type === "message") { + this.#transcriptCache.entries.push(entry as SessionMessageEntry); + } + } + this.#transcriptCache.bytesRead = fromByte + Buffer.byteLength(completeChunk, "utf-8"); + } + // If no newline found, the entire chunk is partial — leave bytesRead unchanged + } + return this.#transcriptCache.entries; + } + + #renderSessionTranscript(session: ObservableSession): void { + const c = this.#viewerContainer; + + if (!session.sessionFile) { + c.addChild(new Text(theme.fg("dim", "No session file available yet."), 1, 0)); + return; + } + + const messageEntries = this.#loadTranscript(session.sessionFile); + if (!messageEntries) { + c.addChild(new Text(theme.fg("dim", "Unable to read session file."), 1, 0)); + return; + } + if (messageEntries.length === 0) { + c.addChild(new Text(theme.fg("dim", "No messages yet."), 1, 0)); + return; + } + + // Build a tool call ID -> tool result map for matching + const toolResults = new Map(); + for (const entry of messageEntries) { + if (entry.message.role === "toolResult") { + toolResults.set(entry.message.toolCallId, entry.message); + } + } + + for (const entry of messageEntries) { + const msg = entry.message; + + if (msg.role === "assistant") { + this.#renderAssistantMessage(c, msg, toolResults); + } else if (msg.role === "user" || msg.role === "developer") { + // Show user/developer messages briefly + const text = + typeof msg.content === "string" + ? msg.content + : msg.content + .filter((b): b is { type: "text"; text: string } => b.type === "text") + .map(b => b.text) + .join("\n"); + if (text.trim()) { + const label = msg.role === "developer" ? "System" : "User"; + c.addChild(new Spacer(1)); + c.addChild( + new Text( + `${theme.fg("dim", `[${label}]`)} ${theme.fg("muted", truncateToWidth(text.trim(), 80))}`, + 1, + 0, + ), + ); + } + } + // toolResult entries are rendered inline with their tool calls above + } + } + + #renderAssistantMessage(c: Container, msg: AssistantMessage, toolResults: Map): void { + for (const content of msg.content) { + if (content.type === "thinking" && content.thinking.trim()) { + const thinking = content.thinking.trim(); + c.addChild(new Spacer(1)); + if (thinking.length > MAX_THINKING_CHARS) { + // Show truncated thinking as markdown for proper formatting + const truncated = `${thinking.slice(0, MAX_THINKING_CHARS)}...`; + c.addChild( + new Markdown(truncated, 1, 0, getMarkdownTheme(), { + color: (t: string) => theme.fg("thinkingText", t), + italic: true, + }), + ); + } else { + c.addChild( + new Markdown(thinking, 1, 0, getMarkdownTheme(), { + color: (t: string) => theme.fg("thinkingText", t), + italic: true, + }), + ); + } + } else if (content.type === "text" && content.text.trim()) { + c.addChild(new Spacer(1)); + c.addChild(new Markdown(content.text.trim(), 1, 0, getMarkdownTheme())); + } else if (content.type === "toolCall") { + this.#renderToolCall(c, content, toolResults); + } + } + } + + #renderToolCall( + c: Container, + call: { id: string; name: string; arguments: Record; intent?: string }, + toolResults: Map, + ): void { + c.addChild(new Spacer(1)); + + // Tool call header with intent + const intentStr = call.intent ? theme.fg("dim", ` ${truncateToWidth(call.intent, 50)}`) : ""; + c.addChild(new Text(`${theme.fg("accent", "▸")} ${theme.bold(theme.fg("muted", call.name))}${intentStr}`, 1, 0)); + + // Key arguments (skip very long ones, show summary) + const argEntries = Object.entries(call.arguments); + if (argEntries.length > 0) { + const argSummary = this.#formatToolArgs(call.name, call.arguments); + if (argSummary) { + c.addChild(new Text(` ${theme.fg("dim", argSummary)}`, 1, 0)); + } + } + + // Inline tool result + const result = toolResults.get(call.id); + if (result) { + this.#renderToolResult(c, result); + } + } + + #formatToolArgs(toolName: string, args: Record): string { + // Show the most relevant arg for common tools + switch (toolName) { + case "read": + return args.path ? `path: ${args.path}` : ""; + case "write": + return args.path ? `path: ${args.path}` : ""; + case "edit": + return args.path ? `path: ${args.path}` : ""; + case "grep": + return [args.pattern ? `pattern: ${args.pattern}` : "", args.path ? `path: ${args.path}` : ""] + .filter(Boolean) + .join(", "); + case "find": + return args.pattern ? `pattern: ${args.pattern}` : ""; + case "bash": { + const cmd = args.command; + if (typeof cmd === "string") { + return truncateToWidth(replaceTabs(cmd), 70); + } + return ""; + } + case "lsp": + return [args.action, args.file, args.symbol].filter(Boolean).join(" "); + case "ast_grep": + case "ast_edit": + return args.path ? `path: ${args.path}` : ""; + case "task": { + const tasks = args.tasks; + if (Array.isArray(tasks)) { + return `${tasks.length} task(s)`; + } + return ""; + } + default: { + // Generic: show first few args truncated + const parts: string[] = []; + let total = 0; + for (const [key, value] of Object.entries(args)) { + if (key.startsWith("_")) continue; + const v = typeof value === "string" ? value : JSON.stringify(value); + const entry = `${key}: ${truncateToWidth(replaceTabs(v ?? ""), 40)}`; + if (total + entry.length > MAX_TOOL_ARGS_CHARS) break; + parts.push(entry); + total += entry.length; + } + return parts.join(", "); + } + } + } + + #renderToolResult(c: Container, result: ToolResultMessage): void { + const textParts = result.content + .filter((p): p is { type: "text"; text: string } => p.type === "text") + .map(p => p.text); + const text = textParts.join("\n").trim(); + + if (result.isError) { + const preview = truncateToWidth(replaceTabs(text || "Error"), 70); + c.addChild(new Text(` ${theme.fg("error", `✗ ${preview}`)}`, 1, 0)); + } else if (text) { + // Show brief result preview + const lines = text.split("\n"); + if (lines.length === 1 && text.length < MAX_TOOL_RESULT_CHARS) { + c.addChild(new Text(` ${theme.fg("dim", `✓ ${truncateToWidth(replaceTabs(text), 70)}`)}`, 1, 0)); + } else { + c.addChild(new Text(` ${theme.fg("dim", `✓ ${lines.length} lines`)}`, 1, 0)); + } + } else { + c.addChild(new Text(` ${theme.fg("dim", "✓ done")}`, 1, 0)); + } + } + + #buildPickerItems(): SelectItem[] { + const sessions = this.#registry.getSessions(); + return sessions.map(s => { + const statusIcon = + s.status === "active" ? "●" : s.status === "completed" ? "✓" : s.status === "failed" ? "✗" : "○"; + const statusColor = s.status === "active" ? "success" : s.status === "failed" ? "error" : "dim"; + const prefix = theme.fg(statusColor, statusIcon); + const agentSuffix = s.agent ? theme.fg("dim", ` [${s.agent}]`) : ""; + const label = s.kind === "main" ? `${prefix} ${s.label} (return)` : `${prefix} ${s.label}${agentSuffix}`; + + // Show current activity in the picker description for subagents + let description = s.description; + if (s.progress?.currentTool) { + const intent = s.progress.lastIntent; + description = intent ? `${s.progress.currentTool}: ${truncateToWidth(intent, 40)}` : s.progress.currentTool; + } + + return { value: s.id, label, description }; + }); + } + + handleInput(keyData: string): void { + for (const key of this.#observeKeys) { + if (matchesKey(keyData, key)) { + if (this.#mode === "viewer") { + this.#setupPicker(); + return; + } + this.#onDone(); + return; + } + } + + if (this.#mode === "picker") { + this.#selectList.handleInput(keyData); + } else if (this.#mode === "viewer") { + if (matchesKey(keyData, "escape")) { + this.#setupPicker(); + return; + } + } + } +} + +// Sync helpers for render path — avoid async in component rendering +import * as fs from "node:fs"; + +/** + * Read new bytes from a file starting at the given byte offset. + * Returns the new text and updated file size, or null on error. + */ +function readFileIncremental(filePath: string, fromByte: number): { text: string; newSize: number } | null { + try { + const stat = fs.statSync(filePath); + if (stat.size <= fromByte) return { text: "", newSize: stat.size }; + const buf = Buffer.alloc(stat.size - fromByte); + const fd = fs.openSync(filePath, "r"); + try { + fs.readSync(fd, buf, 0, buf.length, fromByte); + } finally { + fs.closeSync(fd); + } + return { text: buf.toString("utf-8"), newSize: stat.size }; + } catch { + return null; + } +} diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts index f00be96ba..b60a5ca37 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -586,6 +586,7 @@ export class CommandController { } } await this.ctx.session.newSession(); + this.ctx.resetObserverRegistry(); setSessionTerminalTitle(this.ctx.sessionManager.getSessionName(), this.ctx.sessionManager.getCwd()); this.ctx.statusLine.invalidate(); diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index ed66f0a51..1ddae2c4f 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -158,6 +158,9 @@ export class InputController { for (const key of this.ctx.keybindings.getKeys("app.clipboard.copyLine")) { this.ctx.editor.setCustomKeyHandler(key, () => this.handleCopyCurrentLine()); } + for (const key of this.ctx.keybindings.getKeys("app.session.observe")) { + this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.showSessionObserver()); + } this.ctx.editor.onChange = (text: string) => { const wasBashMode = this.ctx.isBashMode; diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index bdfb777ba..aac5e9256 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -39,11 +39,13 @@ import { HistorySearchComponent } from "../components/history-search"; import { ModelSelectorComponent } from "../components/model-selector"; import { OAuthSelectorComponent } from "../components/oauth-selector"; import { PluginSelectorComponent } from "../components/plugin-selector"; +import { SessionObserverOverlayComponent } from "../components/session-observer-overlay"; import { SessionSelectorComponent } from "../components/session-selector"; import { SettingsSelectorComponent } from "../components/settings-selector"; import { ToolExecutionComponent } from "../components/tool-execution"; import { TreeSelectorComponent } from "../components/tree-selector"; import { UserMessageSelectorComponent } from "../components/user-message-selector"; +import type { SessionObserverRegistry } from "../session-observer-registry"; const CALLBACK_SERVER_PROVIDERS = new Set([ "anthropic", @@ -962,4 +964,28 @@ export class SelectorController { return { component: selector, focus: selector }; }); } + + showSessionObserver(registry: SessionObserverRegistry): void { + const observeKeys = this.ctx.keybindings.getKeys("app.session.observe"); + + this.showSelector(done => { + let cleanup: (() => void) | undefined; + + const selector = new SessionObserverOverlayComponent( + registry, + () => { + cleanup?.(); + done(); + }, + observeKeys, + ); + + cleanup = registry.onChange(() => { + selector.refreshFromRegistry(); + this.ctx.ui.requestRender(); + }); + + return { component: selector, focus: selector }; + }); + } } diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index ea9e5dbb7..e98b27aea 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -30,6 +30,7 @@ import type { SessionContext, SessionManager } from "../session/session-manager" import { getRecentSessions } from "../session/session-manager"; import { STTController, type SttState } from "../stt"; import type { ExitPlanModeDetails } from "../tools"; +import type { EventBus } from "../utils/event-bus"; import { getEditorCommand, openInEditor } from "../utils/external-editor"; import { popTerminalTitle, pushTerminalTitle, setSessionTerminalTitle } from "../utils/title-generator"; import type { AssistantMessageComponent } from "./components/assistant-message"; @@ -52,6 +53,7 @@ import { MCPCommandController } from "./controllers/mcp-command-controller"; import { SelectorController } from "./controllers/selector-controller"; import { SSHCommandController } from "./controllers/ssh-command-controller"; import { OAuthManualInputManager } from "./oauth-manual-input"; +import { SessionObserverRegistry } from "./session-observer-registry"; import { setMermaidRenderCallback } from "./theme/mermaid-cache"; import type { Theme } from "./theme/theme"; import { @@ -176,6 +178,8 @@ export class InteractiveMode implements InteractiveModeContext { #voicePreviousShowHardwareCursor: boolean | null = null; #voicePreviousUseTerminalCursor: boolean | null = null; #resizeHandler?: () => void; + #observerRegistry: SessionObserverRegistry; + #eventBus?: EventBus; constructor( session: AgentSession, @@ -186,6 +190,7 @@ export class InteractiveMode implements InteractiveModeContext { | Array<{ name: string; status: "ready" | "error"; fileTypes: string[]; error?: string }> | undefined = undefined, mcpManager?: import("../mcp").MCPManager, + eventBus?: EventBus, ) { this.session = session; this.sessionManager = session.sessionManager; @@ -197,6 +202,7 @@ export class InteractiveMode implements InteractiveModeContext { this.#toolUiContextSetter = setToolUIContext; this.lspServers = lspServers; this.mcpManager = mcpManager; + this.#eventBus = eventBus; this.ui = new TUI(new ProcessTerminal(), settings.get("showHardwareCursor")); this.ui.setClearOnShrink(settings.get("clearOnShrink")); @@ -271,6 +277,7 @@ export class InteractiveMode implements InteractiveModeContext { this.#commandController = new CommandController(this); this.#selectorController = new SelectorController(this); this.#inputController = new InputController(this); + this.#observerRegistry = new SessionObserverRegistry(); } async init(): Promise { @@ -355,6 +362,16 @@ export class InteractiveMode implements InteractiveModeContext { this.#inputController.setupKeyHandlers(); this.#inputController.setupEditorSubmitHandler(); + // Wire observer registry to EventBus + if (this.#eventBus) { + this.#observerRegistry.subscribeToEventBus(this.#eventBus); + } + this.#observerRegistry.setMainSession(this.sessionManager.getSessionFile() ?? undefined); + this.#observerRegistry.onChange(() => { + this.statusLine.setSubagentCount(this.#observerRegistry.getActiveSubagentCount()); + this.ui.requestRender(); + }); + // Load initial todos await this.#loadTodoList(); @@ -928,9 +945,13 @@ export class InteractiveMode implements InteractiveModeContext { return; } if (choice === "Refine plan") { - const refinement = await this.showHookInput("What should be refined?"); + const refinement = (await this.showHookInput("What should be refined?"))?.trim(); if (refinement) { - this.editor.setText(refinement); + if (this.onInputCallback) { + this.onInputCallback(this.startPendingSubmission({ text: refinement })); + } else { + this.editor.setText(refinement); + } } } } @@ -947,6 +968,7 @@ export class InteractiveMode implements InteractiveModeContext { } this.#extensionUiController.clearExtensionTerminalInputListeners(); this.#extensionUiController.clearHookWidgets(); + this.#observerRegistry.dispose(); this.statusLine.dispose(); if (this.#resizeHandler) { process.stdout.removeListener("resize", this.#resizeHandler); @@ -1286,6 +1308,20 @@ export class InteractiveMode implements InteractiveModeContext { this.#selectorController.showDebugSelector(); } + showSessionObserver(): void { + const sessions = this.#observerRegistry.getSessions(); + if (sessions.length <= 1) { + this.showStatus("No active subagent sessions"); + return; + } + this.#selectorController.showSessionObserver(this.#observerRegistry); + } + + resetObserverRegistry(): void { + this.#observerRegistry.resetSessions(); + this.#observerRegistry.setMainSession(this.sessionManager.getSessionFile() ?? undefined); + } + handleBashCommand(command: string, excludeFromContext?: boolean): Promise { return this.#commandController.handleBashCommand(command, excludeFromContext); } @@ -1359,6 +1395,7 @@ export class InteractiveMode implements InteractiveModeContext { handleResumeSession(sessionPath: string): Promise { this.#btwController.dispose(); + this.resetObserverRegistry(); return this.#selectorController.handleResumeSession(sessionPath); } diff --git a/packages/coding-agent/src/modes/session-observer-registry.ts b/packages/coding-agent/src/modes/session-observer-registry.ts new file mode 100644 index 000000000..c1c6bea6f --- /dev/null +++ b/packages/coding-agent/src/modes/session-observer-registry.ts @@ -0,0 +1,146 @@ +import type { AgentProgress, SubagentLifecyclePayload, SubagentProgressPayload } from "../task"; +import { TASK_SUBAGENT_LIFECYCLE_CHANNEL, TASK_SUBAGENT_PROGRESS_CHANNEL } from "../task"; +import type { EventBus } from "../utils/event-bus"; + +export interface ObservableSession { + id: string; + kind: "main" | "subagent"; + label: string; + agent?: string; + description?: string; + status: "active" | "completed" | "failed" | "aborted"; + sessionFile?: string; + lastUpdate: number; + /** Latest progress snapshot from the subagent executor */ + progress?: AgentProgress; +} + +const STATUS_MAP: Record = { + started: "active", + completed: "completed", + failed: "failed", + aborted: "aborted", +}; + +export class SessionObserverRegistry { + #sessions = new Map(); + #listeners = new Set<() => void>(); + #eventBusUnsubscribers: Array<() => void> = []; + + /** Add a change listener. Returns unsubscribe function. */ + onChange(cb: () => void): () => void { + this.#listeners.add(cb); + return () => this.#listeners.delete(cb); + } + + #notifyListeners(): void { + for (const cb of this.#listeners) cb(); + } + + setMainSession(sessionFile?: string): void { + const existing = this.#sessions.get("main"); + this.#sessions.set("main", { + id: "main", + kind: "main", + label: "Main Session", + status: "active", + sessionFile: sessionFile ?? existing?.sessionFile, + lastUpdate: Date.now(), + }); + this.#notifyListeners(); + } + + getSessions(): ObservableSession[] { + const sessions = [...this.#sessions.values()]; + sessions.sort((a, b) => { + if (a.kind === "main") return -1; + if (b.kind === "main") return 1; + return a.lastUpdate - b.lastUpdate; + }); + return sessions; + } + + getActiveSubagentCount(): number { + let count = 0; + for (const s of this.#sessions.values()) { + if (s.kind === "subagent" && s.status === "active") count++; + } + return count; + } + + /** Clear all tracked sessions (e.g. on session switch). Keeps EventBus subscriptions and listeners. */ + resetSessions(): void { + this.#sessions.clear(); + this.#notifyListeners(); + } + + dispose(): void { + for (const unsub of this.#eventBusUnsubscribers) unsub(); + this.#eventBusUnsubscribers = []; + this.#sessions.clear(); + this.#listeners.clear(); + } + + subscribeToEventBus(eventBus: EventBus): void { + // Dispose previous EventBus subscriptions if called again + for (const unsub of this.#eventBusUnsubscribers) unsub(); + this.#eventBusUnsubscribers = []; + + this.#eventBusUnsubscribers.push( + eventBus.on(TASK_SUBAGENT_LIFECYCLE_CHANNEL, data => { + const payload = data as SubagentLifecyclePayload; + const status = STATUS_MAP[payload.status]; + if (!status) return; + + const existing = this.#sessions.get(payload.id); + if (existing) { + existing.status = status; + existing.lastUpdate = Date.now(); + if (payload.description) existing.description = payload.description; + if (payload.sessionFile) existing.sessionFile = payload.sessionFile; + } else { + this.#sessions.set(payload.id, { + id: payload.id, + kind: "subagent", + label: payload.description ?? `Subagent #${payload.index}`, + agent: payload.agent, + description: payload.description, + status, + sessionFile: payload.sessionFile, + lastUpdate: Date.now(), + }); + } + this.#notifyListeners(); + }), + ); + + this.#eventBusUnsubscribers.push( + eventBus.on(TASK_SUBAGENT_PROGRESS_CHANNEL, data => { + const payload = data as SubagentProgressPayload; + const progress = payload.progress; + const id = progress.id; + const existing = this.#sessions.get(id); + + if (existing) { + existing.lastUpdate = Date.now(); + existing.progress = progress; + if (progress.description) existing.description = progress.description; + if (payload.sessionFile) existing.sessionFile = payload.sessionFile; + } else { + this.#sessions.set(id, { + id, + kind: "subagent", + label: progress.description ?? `Subagent #${payload.index}`, + agent: payload.agent, + description: progress.description, + status: "active", + sessionFile: payload.sessionFile, + lastUpdate: Date.now(), + progress, + }); + } + this.#notifyListeners(); + }), + ); + } +} diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index 2db98baae..d98052af2 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -208,6 +208,8 @@ export interface InteractiveModeContext { showOAuthSelector(mode: "login" | "logout", providerId?: string): Promise; showHookConfirm(title: string, message: string): Promise; showDebugSelector(): void; + showSessionObserver(): void; + resetObserverRegistry(): void; // Input handling handleCtrlC(): void; diff --git a/packages/coding-agent/src/patch/imports/cpp.ts b/packages/coding-agent/src/patch/imports/cpp.ts deleted file mode 100644 index ff730d216..000000000 --- a/packages/coding-agent/src/patch/imports/cpp.ts +++ /dev/null @@ -1,470 +0,0 @@ -import type { ImportSpec } from "../types"; -import { - defaultImportStyle, - type ImportHandler, - type ImportRegion, - type ImportStyle, - type ParsedImport, -} from "./types"; - -type IncludeGroup = "system" | "local"; -type LineKind = "blank" | "comment" | "include" | "other"; - -interface ParsedIncludeEntry extends ParsedImport { - lineIndex: number; - group: IncludeGroup; -} - -interface LineClassification { - kind: LineKind; - inBlockComment: boolean; - parsedImport?: ParsedImport; -} - -interface IncludeRegionAnalysis extends ImportRegion { - entries: ParsedIncludeEntry[]; - groupOrder: IncludeGroup[]; -} - -const INCLUDE_PATTERN = /^#\s*include\s*([<"])([^>"]+)[>"](?:\s*(?:\/\/.*|\/\*.*\*\/\s*)?)?$/; -const PRAGMA_ONCE_PATTERN = /^#\s*pragma\s+once\b/; -const IFNDEF_PATTERN = /^#\s*ifndef\s+([A-Za-z_]\w*)\b/; - -function isBlankLine(line: string): boolean { - return line.trim().length === 0; -} - -function lastItem(items: T[]): T | undefined { - return items[items.length - 1]; -} - -function parseIncludeLine(text: string): ParsedImport | null { - const match = INCLUDE_PATTERN.exec(text.trim()); - if (!match) { - return null; - } - - const delimiter = match[1]; - const source = match[2]?.trim(); - if (!source) { - return null; - } - - return { - raw: text, - source, - names: [], - system: delimiter === "<", - }; -} - -function classifyLine(line: string, inBlockComment: boolean): LineClassification { - const trimmed = line.trim(); - if (trimmed.length === 0) { - return { kind: "blank", inBlockComment }; - } - - if (inBlockComment) { - const blockEnd = trimmed.indexOf("*/"); - if (blockEnd === -1) { - return { kind: "comment", inBlockComment: true }; - } - - const remainder = trimmed.slice(blockEnd + 2).trim(); - if (remainder.length === 0) { - return { kind: "comment", inBlockComment: false }; - } - - return classifyLine(remainder, false); - } - - const parsedImport = parseIncludeLine(trimmed); - if (parsedImport) { - return { kind: "include", inBlockComment: false, parsedImport }; - } - - if (trimmed.startsWith("//")) { - return { kind: "comment", inBlockComment: false }; - } - - if (trimmed.startsWith("/*")) { - const blockEnd = trimmed.indexOf("*/", 2); - if (blockEnd === -1) { - return { kind: "comment", inBlockComment: true }; - } - - const remainder = trimmed.slice(blockEnd + 2).trim(); - if (remainder.length === 0) { - return { kind: "comment", inBlockComment: false }; - } - - return classifyLine(remainder, false); - } - - return { kind: "other", inBlockComment: false }; -} - -function findNextSubstantiveLine(lines: string[], startIndex: number): number | null { - let inBlockComment = false; - for (let index = startIndex; index < lines.length; index += 1) { - const classification = classifyLine(lines[index] ?? "", inBlockComment); - inBlockComment = classification.inBlockComment; - if (classification.kind === "blank" || classification.kind === "comment") { - continue; - } - return index; - } - return null; -} - -function findInsertionPoint(lines: string[]): number { - let index = 0; - if ((lines[0] ?? "").startsWith("#!")) { - index = 1; - } - - let inBlockComment = false; - while (index < lines.length) { - const classification = classifyLine(lines[index] ?? "", inBlockComment); - inBlockComment = classification.inBlockComment; - if (classification.kind === "blank" || classification.kind === "comment") { - index += 1; - continue; - } - break; - } - - const firstSubstantive = lines[index]?.trim() ?? ""; - if (PRAGMA_ONCE_PATTERN.test(firstSubstantive)) { - return index + 1; - } - - const guardMatch = IFNDEF_PATTERN.exec(firstSubstantive); - if (!guardMatch) { - return index; - } - - const macroName = guardMatch[1]; - const defineIndex = findNextSubstantiveLine(lines, index + 1); - if (defineIndex === null) { - return index; - } - - const definePattern = new RegExp(`^#\\s*define\\s+${macroName}\\b`); - return definePattern.test(lines[defineIndex]?.trim() ?? "") ? defineIndex + 1 : index; -} - -function detectGroupOrder(entries: ParsedIncludeEntry[]): IncludeGroup[] { - const firstSystem = entries.find(entry => entry.group === "system"); - const firstLocal = entries.find(entry => entry.group === "local"); - if (firstSystem && firstLocal) { - return firstSystem.lineIndex < firstLocal.lineIndex ? ["system", "local"] : ["local", "system"]; - } - return ["system", "local"]; -} - -function hasBlankBetweenGroups(lines: string[], entries: ParsedIncludeEntry[]): boolean { - const firstSystem = entries.find(entry => entry.group === "system"); - const firstLocal = entries.find(entry => entry.group === "local"); - if (!firstSystem || !firstLocal) { - return defaultImportStyle.groupSeparator; - } - - const start = Math.min(firstSystem.lineIndex, firstLocal.lineIndex); - const end = Math.max(firstSystem.lineIndex, firstLocal.lineIndex); - for (let index = start + 1; index < end; index += 1) { - if (isBlankLine(lines[index] ?? "")) { - return true; - } - } - return false; -} - -function isGroupSorted(entries: ParsedIncludeEntry[]): boolean { - for (let index = 1; index < entries.length; index += 1) { - if ((entries[index - 1]?.source ?? "").localeCompare(entries[index]?.source ?? "") > 0) { - return false; - } - } - return true; -} - -function analyzeIncludeRegion(content: string): IncludeRegionAnalysis | null { - const lines = content.split("\n"); - const insertionPoint = findInsertionPoint(lines); - - let index = insertionPoint; - let inBlockComment = false; - while (index < lines.length) { - const classification = classifyLine(lines[index] ?? "", inBlockComment); - inBlockComment = classification.inBlockComment; - if (classification.kind === "blank" || classification.kind === "comment") { - index += 1; - continue; - } - if (classification.kind !== "include") { - return null; - } - break; - } - - if (index >= lines.length) { - return null; - } - - const entries: ParsedIncludeEntry[] = []; - let endIndex = index; - inBlockComment = false; - - for (let lineIndex = index; lineIndex < lines.length; lineIndex += 1) { - const classification = classifyLine(lines[lineIndex] ?? "", inBlockComment); - inBlockComment = classification.inBlockComment; - if (classification.kind === "other") { - break; - } - - endIndex = lineIndex; - if (classification.kind !== "include" || !classification.parsedImport) { - continue; - } - - entries.push({ - ...classification.parsedImport, - group: classification.parsedImport.system ? "system" : "local", - lineIndex, - }); - } - - if (entries.length === 0) { - return null; - } - - const groupOrder = detectGroupOrder(entries); - const imports = entries.map(({ lineIndex, group, ...parsedImport }) => parsedImport); - const systemEntries = entries.filter(entry => entry.group === "system"); - const localEntries = entries.filter(entry => entry.group === "local"); - - return { - startLine: index + 1, - endLine: endIndex + 1, - imports, - entries, - groupOrder, - style: { - ...defaultImportStyle, - groupSeparator: hasBlankBetweenGroups(lines, entries), - groupOrder, - sorted: isGroupSorted(systemEntries) && isGroupSorted(localEntries), - }, - }; -} - -function parseExisting(content: string): ImportRegion | null { - const region = analyzeIncludeRegion(content); - if (!region) { - return null; - } - - return { - startLine: region.startLine, - endLine: region.endLine, - imports: region.imports, - style: region.style, - }; -} - -function specToLine(spec: ImportSpec, _style: ImportStyle): string { - return spec.system ? `#include <${spec.from}>` : `#include "${spec.from}"`; -} - -function makeSpecKey(spec: Pick): string { - return `${spec.system ? "system" : "local"}:${spec.from}`; -} - -function dedupeSpecs(specs: ImportSpec[], existing: Iterable>): ImportSpec[] { - const seen = new Set(); - for (const spec of existing) { - seen.add(makeSpecKey(spec)); - } - - const uniqueSpecs: ImportSpec[] = []; - for (const spec of specs) { - const key = makeSpecKey(spec); - if (seen.has(key)) { - continue; - } - seen.add(key); - uniqueSpecs.push(spec); - } - return uniqueSpecs; -} - -function addInsertion(insertions: Map, index: number, addedLines: string[]): void { - if (addedLines.length === 0) { - return; - } - - const existing = insertions.get(index); - if (existing) { - existing.push(...addedLines); - return; - } - - insertions.set(index, [...addedLines]); -} - -function insertChunk( - insertions: Map, - lines: string[], - index: number, - chunk: string[], - options: { blankBefore?: boolean; blankAfter?: boolean } = {}, -): void { - if (chunk.length === 0) { - return; - } - - const nextChunk = [...chunk]; - if (options.blankBefore && !isBlankLine(lines[index - 1] ?? "")) { - nextChunk.unshift(""); - } - if (options.blankAfter && !isBlankLine(lines[index] ?? "")) { - nextChunk.push(""); - } - - addInsertion(insertions, index, nextChunk); -} - -function sortSpecs(specs: ImportSpec[]): ImportSpec[] { - return [...specs].sort((left, right) => left.from.localeCompare(right.from)); -} - -function buildSectionLines(specs: ImportSpec[], style: ImportStyle): { sectionLines: string[]; addedLines: string[] } { - const systemLines = sortSpecs(specs.filter(spec => spec.system)).map(spec => specToLine(spec, style)); - const localLines = sortSpecs(specs.filter(spec => !spec.system)).map(spec => specToLine(spec, style)); - const sectionLines = [ - ...systemLines, - ...(systemLines.length > 0 && localLines.length > 0 && style.groupSeparator ? [""] : []), - ...localLines, - ]; - return { - sectionLines, - addedLines: [...systemLines, ...localLines], - }; -} - -function applyInsertions(lines: string[], insertions: Map): string[] { - const indexes = [...insertions.keys()].sort((left, right) => right - left); - for (const index of indexes) { - const chunk = insertions.get(index); - if (!chunk || chunk.length === 0) { - continue; - } - lines.splice(index, 0, ...chunk); - } - return lines; -} - -function applyExistingRegion( - content: string, - specs: ImportSpec[], - region: IncludeRegionAnalysis, -): { content: string; added: string[] } { - const lines = content.split("\n"); - const insertions = new Map(); - const groupedSpecs: Record = { - system: sortSpecs(specs.filter(spec => spec.system)), - local: sortSpecs(specs.filter(spec => !spec.system)), - }; - const groupedEntries: Record = { - system: region.entries.filter(entry => entry.group === "system"), - local: region.entries.filter(entry => entry.group === "local"), - }; - const addedLines: string[] = []; - - for (const group of region.groupOrder) { - const specsForGroup = groupedSpecs[group]; - if (specsForGroup.length === 0) { - continue; - } - - const entriesForGroup = groupedEntries[group]; - const otherGroup: IncludeGroup = group === "system" ? "local" : "system"; - const otherEntries = groupedEntries[otherGroup]; - const linesForGroup = specsForGroup.map(spec => specToLine(spec, region.style)); - addedLines.push(...linesForGroup); - - if (entriesForGroup.length === 0) { - const placeBeforeOther = region.groupOrder[0] === group; - if (placeBeforeOther) { - insertChunk(insertions, lines, otherEntries[0]?.lineIndex ?? region.startLine - 1, linesForGroup, { - blankAfter: otherEntries.length > 0 && region.style.groupSeparator, - }); - } else { - insertChunk( - insertions, - lines, - (lastItem(otherEntries)?.lineIndex ?? region.endLine - 1) + 1, - linesForGroup, - { - blankBefore: otherEntries.length > 0 && region.style.groupSeparator, - }, - ); - } - continue; - } - - if (!isGroupSorted(entriesForGroup)) { - addInsertion(insertions, (lastItem(entriesForGroup)?.lineIndex ?? region.endLine - 1) + 1, linesForGroup); - continue; - } - - const pendingByIndex = new Map(); - for (const spec of specsForGroup) { - const targetIndex = - entriesForGroup.find(entry => spec.from.localeCompare(entry.source) < 0)?.lineIndex ?? - (lastItem(entriesForGroup)?.lineIndex ?? region.endLine - 1) + 1; - addInsertion(pendingByIndex, targetIndex, [specToLine(spec, region.style)]); - } - - for (const [targetIndex, chunk] of pendingByIndex.entries()) { - addInsertion(insertions, targetIndex, chunk); - } - } - - const updatedLines = applyInsertions(lines, insertions); - return { content: updatedLines.join("\n"), added: addedLines }; -} - -export const cppImportHandler: ImportHandler = { - parseExisting, - specToLine, - apply(content, specs) { - const region = analyzeIncludeRegion(content); - const uniqueSpecs = dedupeSpecs( - specs, - region?.entries.map(entry => ({ from: entry.source, system: entry.system })) ?? [], - ); - if (uniqueSpecs.length === 0) { - return { content, added: [], warnings: [] }; - } - - if (!region) { - const lines = content.split("\n"); - const insertionPoint = findInsertionPoint(lines); - const { sectionLines, addedLines } = buildSectionLines(uniqueSpecs, defaultImportStyle); - const insertions = new Map(); - insertChunk(insertions, lines, insertionPoint, sectionLines, { - blankBefore: insertionPoint > 0, - blankAfter: insertionPoint < lines.length && !isBlankLine(lines[insertionPoint] ?? ""), - }); - const updatedLines = applyInsertions(lines, insertions); - return { content: updatedLines.join("\n"), added: addedLines, warnings: [] }; - } - - const result = applyExistingRegion(content, uniqueSpecs, region); - return { content: result.content, added: result.added, warnings: [] }; - }, -}; - -export { defaultImportStyle }; diff --git a/packages/coding-agent/src/patch/imports/go.ts b/packages/coding-agent/src/patch/imports/go.ts deleted file mode 100644 index 4d5b3367f..000000000 --- a/packages/coding-agent/src/patch/imports/go.ts +++ /dev/null @@ -1,435 +0,0 @@ -import type { ImportSpec } from "../types"; -import { - defaultImportStyle, - type ImportHandler, - type ImportRegion, - type ImportStyle, - type ParsedImport, -} from "./types"; - -type GoImportGroup = "stdlib" | "thirdParty"; - -interface GoImportEntry extends ParsedImport { - group: GoImportGroup; -} - -interface ParsedGoImportRegion extends ImportRegion { - entries: GoImportEntry[]; - hasBlock: boolean; - packageLine: number; - newline: string; -} - -const GROUP_ORDER: GoImportGroup[] = ["stdlib", "thirdParty"]; -const SINGLE_IMPORT_RE = /^\s*import\s+(?:(?[._]|[A-Za-z_]\w*)\s+)?"(?[^"]+)"(?:\s*\/\/.*)?\s*$/; -const BLOCK_IMPORT_START_RE = /^\s*import\s*\(\s*(?:\/\/.*)?\s*$/; -const BLOCK_IMPORT_END_RE = /^\s*\)\s*(?:\/\/.*)?\s*$/; -const BLOCK_IMPORT_LINE_RE = /^\s*(?:(?[._]|[A-Za-z_]\w*)\s+)?"(?[^"]+)"(?:\s*\/\/.*)?\s*$/; -const PACKAGE_RE = /^\s*package\s+[A-Za-z_]\w*\s*(?:\/\/.*)?\s*$/; - -function detectNewline(content: string): string { - return content.includes("\r\n") ? "\r\n" : "\n"; -} - -function hasTrailingNewline(content: string): boolean { - return content.endsWith("\n"); -} - -function splitLines(content: string): string[] { - return content.split(/\r?\n/); -} - -function joinLines(lines: string[], newline: string, trailingNewline: boolean): string { - let result = lines.join(newline); - if (trailingNewline && (lines.length === 0 || lines[lines.length - 1] !== "")) { - result += newline; - } - return result; -} - -function isBlank(line: string): boolean { - return line.trim().length === 0; -} - -function isCommentLine(line: string): boolean { - const trimmed = line.trim(); - return trimmed.startsWith("//") || trimmed.startsWith("/*") || trimmed.startsWith("*") || trimmed.startsWith("*/"); -} - -function isIgnorableTopLevelLine(line: string): boolean { - return isBlank(line) || isCommentLine(line); -} - -function classifyGroup(source: string): GoImportGroup { - const firstSegment = source.split("/")[0] ?? source; - return firstSegment.includes(".") ? "thirdParty" : "stdlib"; -} - -function parseImportSpec(line: string, expression: RegExp): GoImportEntry | null { - const match = expression.exec(line); - if (!match?.groups?.source) { - return null; - } - - const alias = match.groups.alias; - const source = match.groups.source; - return { - raw: line, - source, - names: [], - alias, - group: classifyGroup(source), - }; -} - -function findPackageLine(lines: string[]): number { - for (let index = 0; index < lines.length; index += 1) { - if (PACKAGE_RE.test(lines[index] ?? "")) { - return index + 1; - } - } - return -1; -} - -function detectGroupOrder(entries: GoImportEntry[]): string[] { - const seen = new Set(); - const order: string[] = []; - - for (const entry of entries) { - if (seen.has(entry.group)) { - continue; - } - seen.add(entry.group); - order.push(entry.group); - } - - return order.length > 0 ? order : [...GROUP_ORDER]; -} - -function parseExistingRegion(content: string): ParsedGoImportRegion | null { - const lines = splitLines(content); - const packageLine = findPackageLine(lines); - if (packageLine < 0) { - return null; - } - - let cursor = packageLine; - while (cursor < lines.length && isIgnorableTopLevelLine(lines[cursor] ?? "")) { - cursor += 1; - } - - if (cursor >= lines.length || !(lines[cursor]?.trimStart().startsWith("import") ?? false)) { - return null; - } - - const newline = detectNewline(content); - const entries: GoImportEntry[] = []; - const startLine = cursor + 1; - let endLine = cursor + 1; - let hasBlock = false; - let sawBlankGroupSeparator = false; - let previousEntryLine = -1; - - while (cursor < lines.length) { - const line = lines[cursor] ?? ""; - if (isIgnorableTopLevelLine(line)) { - cursor += 1; - continue; - } - - if (BLOCK_IMPORT_START_RE.test(line)) { - hasBlock = true; - cursor += 1; - while (cursor < lines.length) { - const innerLine = lines[cursor] ?? ""; - if (BLOCK_IMPORT_END_RE.test(innerLine)) { - endLine = cursor + 1; - cursor += 1; - break; - } - - if (isBlank(innerLine)) { - if (previousEntryLine > 0) { - sawBlankGroupSeparator = true; - } - cursor += 1; - continue; - } - - if (isCommentLine(innerLine)) { - cursor += 1; - continue; - } - - const parsed = parseImportSpec(innerLine, BLOCK_IMPORT_LINE_RE); - if (parsed) { - entries.push(parsed); - previousEntryLine = cursor + 1; - } - cursor += 1; - } - continue; - } - - const parsed = parseImportSpec(line, SINGLE_IMPORT_RE); - if (!parsed) { - break; - } - - entries.push(parsed); - endLine = cursor + 1; - cursor += 1; - - let lookahead = cursor; - while (lookahead < lines.length && isIgnorableTopLevelLine(lines[lookahead] ?? "")) { - if (isBlank(lines[lookahead] ?? "")) { - sawBlankGroupSeparator = true; - } - lookahead += 1; - } - if (lookahead >= lines.length || !(lines[lookahead]?.trimStart().startsWith("import") ?? false)) { - break; - } - cursor = lookahead; - } - - const style: ImportStyle = { - ...defaultImportStyle, - groupSeparator: sawBlankGroupSeparator, - groupOrder: detectGroupOrder(entries), - }; - - return { - startLine, - endLine, - imports: entries, - style, - entries, - hasBlock, - packageLine, - newline, - }; -} - -function parseExisting(content: string): ImportRegion | null { - return parseExistingRegion(content); -} - -function specToLine(spec: ImportSpec, _style: ImportStyle): string { - const aliasPrefix = spec.alias ? `${spec.alias} ` : ""; - return `${aliasPrefix}"${spec.from}"`; -} - -function normalizeRequestedImports(specs: ImportSpec[]): { imports: GoImportEntry[]; warnings: string[] } { - const warnings: string[] = []; - const bySource = new Map(); - - for (const spec of specs) { - if (spec.imports?.length) { - warnings.push(`Go imports do not support named imports from ${spec.from}; ignoring named bindings.`); - } - if (spec.default) { - warnings.push(`Go imports do not support default imports from ${spec.from}; ignoring default binding.`); - } - if (spec.namespace) { - warnings.push(`Go imports do not support namespace imports from ${spec.from}; ignoring namespace binding.`); - } - if (spec.system) { - warnings.push(`Go imports do not use system import mode for ${spec.from}; ignoring system flag.`); - } - - const normalized: GoImportEntry = { - raw: specToLine(spec, defaultImportStyle), - source: spec.from, - names: [], - alias: spec.alias, - group: classifyGroup(spec.from), - }; - - const existing = bySource.get(normalized.source); - if (!existing) { - bySource.set(normalized.source, normalized); - continue; - } - - if (existing.alias === normalized.alias) { - continue; - } - - warnings.push( - `Conflicting Go import aliases requested for ${normalized.source}; keeping ${formatAlias(existing.alias)}.`, - ); - } - - return { imports: [...bySource.values()], warnings }; -} - -function formatAlias(alias: string | undefined): string { - return alias ? `alias ${alias}` : "the existing unaliased import"; -} - -function trimLeadingBlankLines(lines: string[]): string[] { - let start = 0; - while (start < lines.length && isBlank(lines[start] ?? "")) { - start += 1; - } - return lines.slice(start); -} - -function pushGroupLines(lines: string[], imports: GoImportEntry[], style: ImportStyle): void { - const orderedGroups = style.groupOrder.filter( - (group): group is GoImportGroup => group === "stdlib" || group === "thirdParty", - ); - const groupOrder = orderedGroups.length > 0 ? orderedGroups : GROUP_ORDER; - let emittedGroups = 0; - - for (const group of groupOrder) { - const groupImports = imports.filter(entry => entry.group === group); - if (groupImports.length === 0) { - continue; - } - if (style.groupSeparator && emittedGroups > 0) { - lines.push(""); - } - for (const entry of groupImports) { - lines.push(`\t${specToLine({ from: entry.source, alias: entry.alias }, style)}`); - } - emittedGroups += 1; - } -} - -function buildImportSection(imports: GoImportEntry[], preferBlock: boolean, style: ImportStyle): string[] { - if (imports.length === 1 && !preferBlock) { - const [entry] = imports; - return [`import ${specToLine({ from: entry.source, alias: entry.alias }, style)}`]; - } - - const lines = ["import ("]; - pushGroupLines(lines, imports, style); - lines.push(")"); - return lines; -} - -function mergeImports( - existing: GoImportEntry[], - requested: GoImportEntry[], -): { merged: GoImportEntry[]; added: GoImportEntry[]; warnings: string[] } { - const warnings: string[] = []; - const added: GoImportEntry[] = []; - const mergedBySource = new Map(); - - for (const entry of existing) { - if (mergedBySource.has(entry.source)) { - const prior = mergedBySource.get(entry.source); - if (prior?.alias !== entry.alias) { - warnings.push( - `Conflicting existing Go import aliases for ${entry.source}; keeping ${formatAlias(prior?.alias)}.`, - ); - } - continue; - } - mergedBySource.set(entry.source, entry); - } - - for (const entry of requested) { - const prior = mergedBySource.get(entry.source); - if (!prior) { - mergedBySource.set(entry.source, entry); - added.push(entry); - continue; - } - if (prior.alias === entry.alias) { - continue; - } - warnings.push( - `Go import ${entry.source} already exists with ${formatAlias(prior.alias)}; skipping conflicting alias.`, - ); - } - - const groupBuckets = new Map(); - for (const group of GROUP_ORDER) { - groupBuckets.set(group, []); - } - for (const entry of mergedBySource.values()) { - groupBuckets.get(entry.group)?.push(entry); - } - for (const bucket of groupBuckets.values()) { - bucket.sort((left, right) => { - if (left.source === right.source) { - return (left.alias ?? "").localeCompare(right.alias ?? ""); - } - return left.source.localeCompare(right.source); - }); - } - - return { - merged: [...(groupBuckets.get("stdlib") ?? []), ...(groupBuckets.get("thirdParty") ?? [])], - added, - warnings, - }; -} - -function apply(content: string, specs: ImportSpec[]): { content: string; added: string[]; warnings: string[] } { - const requested = normalizeRequestedImports(specs); - const parsedRegion = parseExistingRegion(content); - const newline = parsedRegion?.newline ?? detectNewline(content); - const trailingNewline = hasTrailingNewline(content); - const warnings = [...requested.warnings]; - const lines = splitLines(content); - const packageLine = parsedRegion?.packageLine ?? findPackageLine(lines); - if (packageLine < 0) { - warnings.push("Go import management requires a package declaration."); - return { content, added: [], warnings }; - } - - const mergeResult = mergeImports(parsedRegion?.entries ?? [], requested.imports); - warnings.push(...mergeResult.warnings); - if (mergeResult.added.length === 0) { - return { content, added: [], warnings }; - } - - const style: ImportStyle = parsedRegion?.style ?? { - ...defaultImportStyle, - groupSeparator: true, - groupOrder: [...GROUP_ORDER], - }; - const preferBlock = Boolean(parsedRegion) || mergeResult.merged.length > 1; - const importSection = buildImportSection(mergeResult.merged, preferBlock, style); - - let nextLines: string[]; - if (parsedRegion) { - const before = lines.slice(0, parsedRegion.startLine - 1); - const after = trimLeadingBlankLines(lines.slice(parsedRegion.endLine)); - nextLines = [...before, ...importSection]; - if (after.length > 0 && !isBlank(nextLines[nextLines.length - 1] ?? "")) { - nextLines.push(""); - } - nextLines.push(...after); - } else { - const before = lines.slice(0, packageLine); - const after = trimLeadingBlankLines(lines.slice(packageLine)); - nextLines = [...before]; - if (!isBlank(nextLines[nextLines.length - 1] ?? "")) { - nextLines.push(""); - } - nextLines.push(...importSection); - if (after.length > 0) { - nextLines.push(""); - nextLines.push(...after); - } - } - - return { - content: joinLines(nextLines, newline, trailingNewline), - added: mergeResult.added.map(entry => specToLine({ from: entry.source, alias: entry.alias }, style)), - warnings, - }; -} - -export const goImportHandler: ImportHandler = { - parseExisting, - specToLine, - apply, -}; - -export { defaultImportStyle }; diff --git a/packages/coding-agent/src/patch/imports/index.ts b/packages/coding-agent/src/patch/imports/index.ts deleted file mode 100644 index 6b9e14986..000000000 --- a/packages/coding-agent/src/patch/imports/index.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { detectLanguageId } from "../../lsp/utils"; -import type { ImportSpec } from "../types"; -import { cppImportHandler } from "./cpp"; -import { goImportHandler } from "./go"; -import { pythonImportHandler } from "./python"; -import { rustImportHandler } from "./rust"; -import type { ImportApplyResult, ImportHandler } from "./types"; -import { typescriptImportHandler } from "./typescript"; - -const handlers: Partial> = { - typescript: typescriptImportHandler, - typescriptreact: typescriptImportHandler, - javascript: typescriptImportHandler, - javascriptreact: typescriptImportHandler, - rust: rustImportHandler, - python: pythonImportHandler, - go: goImportHandler, - c: cppImportHandler, - cpp: cppImportHandler, -}; - -export interface ApplyImportsResult { - content: string; - warnings: string[]; - added: string[]; -} - -export function applyImports(filePath: string, content: string, specs: ImportSpec[]): ApplyImportsResult { - if (specs.length === 0) { - return { content, warnings: [], added: [] }; - } - - const languageId = detectLanguageId(filePath); - const handler = handlers[languageId]; - if (!handler) { - return { - content, - added: [], - warnings: [`Import management not supported for language: ${languageId}`], - }; - } - - try { - const result: ImportApplyResult = handler.apply(content, specs); - return { - content: result.content, - warnings: result.warnings, - added: result.added, - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return { - content, - added: [], - warnings: [`Failed to manage imports for ${filePath}: ${message}`], - }; - } -} - -export * from "./types"; diff --git a/packages/coding-agent/src/patch/imports/python.ts b/packages/coding-agent/src/patch/imports/python.ts deleted file mode 100644 index c2480669d..000000000 --- a/packages/coding-agent/src/patch/imports/python.ts +++ /dev/null @@ -1,653 +0,0 @@ -import type { ImportSpec } from "../types"; -import { - defaultImportStyle, - type ImportHandler, - type ImportRegion, - type ImportStyle, - type ParsedImport, -} from "./types"; - -type ImportGroup = "stdlib" | "thirdparty" | "local"; - -interface PythonImportStyle extends ImportStyle { - parenthesized: boolean; -} - -interface ImportedName { - name: string; - alias?: string; - order: number; -} - -interface ParsedPythonImport extends ParsedImport { - kind: "module" | "from"; - group: ImportGroup; - order: number; - multiline: boolean; - startLine: number; - endLine: number; - names: string[]; - parsedNames: ImportedName[]; -} - -interface ParsedPythonRegion extends ImportRegion { - imports: ParsedPythonImport[]; - style: PythonImportStyle; -} - -const DEFAULT_GROUP_ORDER: ImportGroup[] = ["stdlib", "thirdparty", "local"]; - -const STDLIB_MODULES = new Set([ - "__future__", - "abc", - "argparse", - "array", - "ast", - "asyncio", - "base64", - "collections", - "contextlib", - "copy", - "csv", - "dataclasses", - "datetime", - "decimal", - "enum", - "functools", - "glob", - "gzip", - "hashlib", - "heapq", - "hmac", - "html", - "http", - "importlib", - "inspect", - "io", - "itertools", - "json", - "logging", - "math", - "mimetypes", - "os", - "pathlib", - "pickle", - "platform", - "queue", - "random", - "re", - "secrets", - "shlex", - "shutil", - "socket", - "sqlite3", - "statistics", - "string", - "subprocess", - "sys", - "tempfile", - "threading", - "time", - "traceback", - "types", - "typing", - "unittest", - "urllib", - "uuid", - "warnings", - "weakref", - "xml", - "zipfile", - "zoneinfo", -]); - -function getPythonStyle(style: ImportStyle): PythonImportStyle { - return { - ...style, - parenthesized: (style as Partial).parenthesized ?? false, - }; -} - -function detectEol(content: string): string { - return content.includes("\r\n") ? "\r\n" : "\n"; -} - -function isBlankLine(line: string): boolean { - return line.trim().length === 0; -} - -function isCommentLine(line: string): boolean { - return line.trimStart().startsWith("#"); -} - -function isEncodingComment(line: string): boolean { - return /^[ \t]*#.*coding[:=][ \t]*[-\w.]+/.test(line); -} - -function isImportStart(line: string): boolean { - const trimmed = line.trimStart(); - return trimmed.startsWith("import ") || trimmed.startsWith("from "); -} - -function stripInlineComment(line: string): string { - const hashIndex = line.indexOf("#"); - return hashIndex === -1 ? line : line.slice(0, hashIndex); -} - -function parseModuleDocstring(lines: string[], startIndex: number): number | null { - const firstLine = lines[startIndex]; - if (firstLine === undefined) return null; - const trimmed = firstLine.trimStart(); - const match = /^[rRuUbBfF]*(["']{3})/.exec(trimmed); - if (!match) return null; - const delimiter = match[1]; - const remainder = trimmed.slice(match[0].length); - if (remainder.includes(delimiter)) return startIndex + 1; - for (let index = startIndex + 1; index < lines.length; index += 1) { - if (lines[index]?.includes(delimiter)) return index + 1; - } - return lines.length; -} - -function findPrefixEnd(lines: string[]): number { - let index = 0; - if (lines[0]?.startsWith("#!")) index += 1; - if (isEncodingComment(lines[index] ?? "")) index += 1; - while (index < lines.length && isBlankLine(lines[index] ?? "")) index += 1; - const docstringEnd = parseModuleDocstring(lines, index); - if (docstringEnd !== null) { - index = docstringEnd; - while (index < lines.length && isBlankLine(lines[index] ?? "")) index += 1; - } - return index; -} - -function classifyGroup(source: string): ImportGroup { - if (source.startsWith(".")) return "local"; - const root = source.split(".")[0] ?? source; - return STDLIB_MODULES.has(root) ? "stdlib" : "thirdparty"; -} - -function formatImportedName(name: ImportedName): string { - return name.alias ? `${name.name} as ${name.alias}` : name.name; -} - -function parseImportedName(token: string, order: number): ImportedName | null { - const cleaned = stripInlineComment(token).replace(/,$/, "").trim(); - if (cleaned.length === 0) return null; - const match = /^(\*|[A-Za-z_][\w]*)(?:\s+as\s+([A-Za-z_][\w]*))?$/.exec(cleaned); - if (!match) return null; - return { name: match[1], alias: match[2], order }; -} - -function parseModuleImport(token: string, order: number): ParsedPythonImport | null { - const cleaned = stripInlineComment(token).replace(/,$/, "").trim(); - if (cleaned.length === 0) return null; - const match = /^([A-Za-z_][\w.]*)(?:\s+as\s+([A-Za-z_][\w]*))?$/.exec(cleaned); - if (!match) return null; - return { - raw: `import ${cleaned}`, - source: match[1], - names: [], - parsedNames: [], - alias: match[2], - kind: "module", - group: classifyGroup(match[1]), - order, - multiline: false, - startLine: 0, - endLine: 0, - }; -} - -function parseImportStatement( - line: string, - lineNumber: number, - startOrder: number, -): { records: ParsedPythonImport[]; nextOrder: number } | null { - const match = /^import\s+(.+)$/.exec(stripInlineComment(line.trim())); - if (!match) return null; - let nextOrder = startOrder; - const records: ParsedPythonImport[] = []; - for (const token of match[1].split(",")) { - const parsed = parseModuleImport(token, nextOrder); - if (!parsed) continue; - parsed.startLine = lineNumber; - parsed.endLine = lineNumber; - parsed.raw = line; - records.push(parsed); - nextOrder += 1; - } - return records.length === 0 ? null : { records, nextOrder }; -} - -function countParentheses(text: string): number { - let balance = 0; - for (const char of text) { - if (char === "(") balance += 1; - if (char === ")") balance -= 1; - } - return balance; -} - -function parseFromStatement( - lines: string[], - startIndex: number, - eol: string, - startOrder: number, -): { record: ParsedPythonImport; nextIndex: number; nextOrder: number } | null { - const collected: string[] = []; - let nextIndex = startIndex; - let balance = 0; - let sawParenthesis = false; - while (nextIndex < lines.length) { - const line = lines[nextIndex] ?? ""; - collected.push(line); - const withoutComment = stripInlineComment(line); - balance += countParentheses(withoutComment); - sawParenthesis ||= withoutComment.includes("("); - nextIndex += 1; - if (sawParenthesis) { - if (balance <= 0) break; - continue; - } - break; - } - const normalized = collected - .map(line => stripInlineComment(line).trim()) - .join(" ") - .replace(/\s+/g, " "); - const match = /^from\s+([.A-Za-z_][\w.]*)\s+import\s+(.+)$/.exec(normalized); - if (!match) return null; - let namesPart = match[2].trim(); - if (namesPart.startsWith("(") && namesPart.endsWith(")")) { - namesPart = namesPart.slice(1, -1).trim(); - } - const parsedNames: ImportedName[] = []; - let nextOrder = startOrder; - for (const token of namesPart.split(",")) { - const parsedName = parseImportedName(token, nextOrder); - if (!parsedName) continue; - parsedNames.push(parsedName); - nextOrder += 1; - } - if (parsedNames.length === 0) return null; - const record: ParsedPythonImport = { - raw: collected.join(eol), - source: match[1], - names: parsedNames.map(formatImportedName), - parsedNames, - kind: "from", - group: classifyGroup(match[1]), - order: startOrder, - multiline: sawParenthesis || collected.length > 1, - startLine: startIndex + 1, - endLine: nextIndex, - }; - return { record, nextIndex, nextOrder }; -} - -function compareImportedNames(left: ImportedName, right: ImportedName): number { - const leftKey = `${left.name}|${left.alias ?? ""}`; - const rightKey = `${right.name}|${right.alias ?? ""}`; - return leftKey.localeCompare(rightKey); -} - -function compareRecords(left: ParsedPythonImport, right: ParsedPythonImport): number { - const leftKey = left.kind === "module" ? `import ${left.source} ${left.alias ?? ""}` : `from ${left.source}`; - const rightKey = right.kind === "module" ? `import ${right.source} ${right.alias ?? ""}` : `from ${right.source}`; - return leftKey.localeCompare(rightKey); -} - -function hasBlankLineBetween(lines: string[], previousEndLine: number, nextStartLine: number): boolean { - for (let index = previousEndLine; index < nextStartLine - 1; index += 1) { - if (isBlankLine(lines[index] ?? "")) return true; - } - return false; -} - -function inferSorted(records: ParsedPythonImport[]): boolean { - const byGroup = new Map(); - for (const record of records) { - const groupRecords = byGroup.get(record.group); - if (groupRecords) groupRecords.push(record); - else byGroup.set(record.group, [record]); - if (record.kind === "from") { - for (let index = 1; index < record.parsedNames.length; index += 1) { - if (compareImportedNames(record.parsedNames[index - 1]!, record.parsedNames[index]!) > 0) return false; - } - } - } - for (const group of DEFAULT_GROUP_ORDER) { - const groupRecords = byGroup.get(group) ?? []; - for (let index = 1; index < groupRecords.length; index += 1) { - if (compareRecords(groupRecords[index - 1]!, groupRecords[index]!) > 0) return false; - } - } - return true; -} - -function inferStyle(records: ParsedPythonImport[], lines: string[]): PythonImportStyle { - const groupOrder: ImportGroup[] = []; - let groupSeparator = false; - for (let index = 0; index < records.length; index += 1) { - const record = records[index]!; - if (!groupOrder.includes(record.group)) groupOrder.push(record.group); - if (index > 0) { - const previous = records[index - 1]!; - if (hasBlankLineBetween(lines, previous.endLine, record.startLine)) groupSeparator = true; - } - } - return { - ...defaultImportStyle, - groupSeparator, - groupOrder: groupOrder.length > 0 ? groupOrder : [...DEFAULT_GROUP_ORDER], - sorted: inferSorted(records), - parenthesized: records.some(record => record.kind === "from" && record.multiline), - }; -} - -function normalizeRecords(records: ParsedPythonImport[]): ParsedPythonImport[] { - const moduleRecords = new Map(); - const fromRecords = new Map(); - for (const record of [...records].sort((left, right) => left.order - right.order)) { - if (record.kind === "module") { - const key = `${record.source}|${record.alias ?? ""}`; - if (!moduleRecords.has(key)) moduleRecords.set(key, { ...record, parsedNames: [], names: [] }); - continue; - } - const existing = fromRecords.get(record.source); - if (!existing) { - fromRecords.set(record.source, { - ...record, - parsedNames: [...record.parsedNames], - names: [...record.names], - }); - continue; - } - for (const name of record.parsedNames) { - const alreadyPresent = existing.parsedNames.some( - existingName => existingName.name === name.name && (existingName.alias ?? "") === (name.alias ?? ""), - ); - if (alreadyPresent) continue; - existing.parsedNames.push({ ...name, order: existing.parsedNames.length }); - } - existing.names = existing.parsedNames.map(formatImportedName); - existing.multiline ||= record.multiline; - if (record.order < existing.order) existing.order = record.order; - } - const combined = [...moduleRecords.values(), ...fromRecords.values()]; - return combined.sort((left, right) => left.order - right.order); -} - -function renderRecord(record: ParsedPythonImport, style: PythonImportStyle): string { - if (record.kind === "module") { - return record.alias ? `import ${record.source} as ${record.alias}` : `import ${record.source}`; - } - const names = [...record.parsedNames]; - if (style.sorted) names.sort(compareImportedNames); - const renderedNames = names.map(formatImportedName); - const useParenthesized = record.multiline || (style.parenthesized && renderedNames.length > 1); - if (!useParenthesized) { - return `from ${record.source} import ${renderedNames.join(", ")}`; - } - return [`from ${record.source} import (`, ...renderedNames.map(name => ` ${name},`), `)`].join("\n"); -} - -function groupComparator(left: ImportGroup, right: ImportGroup, style: PythonImportStyle): number { - const orderedGroups = [ - ...style.groupOrder, - ...DEFAULT_GROUP_ORDER.filter(group => !style.groupOrder.includes(group)), - ]; - return orderedGroups.indexOf(left) - orderedGroups.indexOf(right); -} - -function renderImportBlock(records: ParsedPythonImport[], style: PythonImportStyle): string[] { - const normalized = normalizeRecords(records).map(record => ({ - ...record, - parsedNames: [...record.parsedNames], - names: [...record.names], - })); - const grouped = new Map(); - for (const record of normalized) { - const groupRecords = grouped.get(record.group); - if (groupRecords) groupRecords.push(record); - else grouped.set(record.group, [record]); - } - const orderedGroups = [...grouped.keys()].sort((left, right) => groupComparator(left, right, style)); - const renderedLines: string[] = []; - for (const group of orderedGroups) { - const groupRecords = grouped.get(group) ?? []; - const orderedRecords = style.sorted - ? [...groupRecords].sort(compareRecords) - : [...groupRecords].sort((left, right) => left.order - right.order); - if (renderedLines.length > 0 && style.groupSeparator) renderedLines.push(""); - for (const record of orderedRecords) { - renderedLines.push(...renderRecord(record, style).split("\n")); - } - } - return renderedLines; -} - -function specToRecords(spec: ImportSpec, order: number): { records: ParsedPythonImport[]; warnings: string[] } { - const warnings: string[] = []; - if (spec.default) warnings.push(`Python imports do not support default import syntax for ${spec.from}`); - if (spec.namespace) warnings.push(`Python imports do not support namespace import syntax for ${spec.from}`); - if (spec.imports && spec.imports.length > 0) { - const parsedNames = spec.imports.flatMap((item, index) => { - const parsed = parseImportedName(item, order + index); - if (parsed) return [parsed]; - return []; - }); - if (parsedNames.length === 1 && spec.alias) parsedNames[0] = { ...parsedNames[0]!, alias: spec.alias }; - if (parsedNames.length === 0) return { records: [], warnings }; - return { - records: [ - { - raw: "", - source: spec.from, - names: parsedNames.map(formatImportedName), - parsedNames, - kind: "from", - group: classifyGroup(spec.from), - order, - multiline: false, - startLine: 0, - endLine: 0, - }, - ], - warnings, - }; - } - return { - records: [ - { - raw: "", - source: spec.from, - names: [], - parsedNames: [], - alias: spec.alias, - kind: "module", - group: classifyGroup(spec.from), - order, - multiline: false, - startLine: 0, - endLine: 0, - }, - ], - warnings, - }; -} - -function parsePythonRegion(content: string): ParsedPythonRegion | null { - const lines = content.split(/\r?\n/); - const eol = detectEol(content); - const prefixEnd = findPrefixEnd(lines); - let index = prefixEnd; - while (index < lines.length && (isBlankLine(lines[index] ?? "") || isCommentLine(lines[index] ?? ""))) index += 1; - if (!isImportStart(lines[index] ?? "")) return null; - const records: ParsedPythonImport[] = []; - let nextOrder = 0; - while (index < lines.length) { - const currentLine = lines[index] ?? ""; - if (isBlankLine(currentLine) || isCommentLine(currentLine)) { - index += 1; - continue; - } - if (currentLine.trimStart().startsWith("import ")) { - const parsed = parseImportStatement(currentLine, index + 1, nextOrder); - if (!parsed) break; - records.push(...parsed.records); - nextOrder = parsed.nextOrder; - index += 1; - continue; - } - if (currentLine.trimStart().startsWith("from ")) { - const parsed = parseFromStatement(lines, index, eol, nextOrder); - if (!parsed) break; - records.push(parsed.record); - nextOrder = parsed.nextOrder; - index = parsed.nextIndex; - continue; - } - break; - } - if (records.length === 0) return null; - const normalizedRecords = normalizeRecords(records); - return { - startLine: normalizedRecords[0]!.startLine, - endLine: Math.max(...normalizedRecords.map(record => record.endLine)), - imports: normalizedRecords, - style: inferStyle(normalizedRecords, lines), - }; -} - -function parseExisting(content: string): ImportRegion | null { - return parsePythonRegion(content); -} - -function specToLine(spec: ImportSpec, style: ImportStyle): string { - const pythonStyle = getPythonStyle(style); - const { records } = specToRecords(spec, 0); - return records.length === 0 ? "" : renderRecord(records[0]!, pythonStyle); -} - -function mergeRecords( - existing: ParsedPythonImport[], - requested: ParsedPythonImport[], - style: PythonImportStyle, -): { records: ParsedPythonImport[]; added: string[] } { - const records = normalizeRecords(existing).map(record => ({ - ...record, - parsedNames: [...record.parsedNames], - names: [...record.names], - })); - let nextOrder = records.reduce((max, record) => Math.max(max, record.order), -1) + 1; - const added: string[] = []; - for (const record of requested) { - if (record.kind === "module") { - const exists = records.some( - existingRecord => - existingRecord.kind === "module" && - existingRecord.source === record.source && - (existingRecord.alias ?? "") === (record.alias ?? ""), - ); - if (exists) continue; - records.push({ ...record, order: nextOrder, startLine: 0, endLine: 0 }); - added.push(renderRecord(record, style)); - nextOrder += 1; - continue; - } - const existingRecord = records.find(candidate => candidate.kind === "from" && candidate.source === record.source); - if (!existingRecord || existingRecord.kind !== "from") { - records.push({ ...record, order: nextOrder, startLine: 0, endLine: 0 }); - added.push(renderRecord(record, style)); - nextOrder += 1; - continue; - } - const missingNames = record.parsedNames.filter( - name => - !existingRecord.parsedNames.some( - existingName => existingName.name === name.name && (existingName.alias ?? "") === (name.alias ?? ""), - ), - ); - if (missingNames.length === 0) continue; - for (const name of missingNames) { - existingRecord.parsedNames.push({ ...name, order: existingRecord.parsedNames.length }); - } - if (style.sorted) existingRecord.parsedNames.sort(compareImportedNames); - existingRecord.names = existingRecord.parsedNames.map(formatImportedName); - existingRecord.multiline ||= record.multiline; - added.push( - renderRecord( - { ...existingRecord, parsedNames: missingNames, names: missingNames.map(formatImportedName) }, - style, - ), - ); - } - return { records, added }; -} - -function insertWithoutRegion(content: string, lines: string[], blockLines: string[], eol: string): string { - if (content.length === 0) return blockLines.join(eol); - const insertAt = findPrefixEnd(lines); - const before = lines.slice(0, insertAt); - const after = lines.slice(insertAt); - const needsTrailingBlank = - after.length > 0 && after.some(line => line.trim().length > 0) && !isBlankLine(after[0] ?? ""); - const inserted = [...before, ...blockLines, ...(needsTrailingBlank ? [""] : []), ...after]; - const nextContent = inserted.join(eol); - if (content.endsWith(eol) && !nextContent.endsWith(eol)) return `${nextContent}${eol}`; - return nextContent; -} - -function replaceRegion( - content: string, - lines: string[], - region: ParsedPythonRegion, - blockLines: string[], - eol: string, -): string { - const nextLines = [...lines.slice(0, region.startLine - 1), ...blockLines, ...lines.slice(region.endLine)]; - const nextContent = nextLines.join(eol); - if (content.endsWith(eol) && !nextContent.endsWith(eol)) return `${nextContent}${eol}`; - return nextContent; -} - -export const pythonImportHandler: ImportHandler = { - parseExisting, - specToLine, - apply(content, specs) { - const region = parsePythonRegion(content); - const style = getPythonStyle( - region?.style ?? { - ...defaultImportStyle, - groupSeparator: true, - groupOrder: [...DEFAULT_GROUP_ORDER], - sorted: true, - parenthesized: false, - }, - ); - const warnings: string[] = []; - const requested: ParsedPythonImport[] = []; - let order = 0; - for (const spec of specs) { - const result = specToRecords(spec, order); - warnings.push(...result.warnings); - requested.push(...result.records); - order += Math.max(result.records.length, 1); - } - if (requested.length === 0) return { content, added: [], warnings }; - const { records, added } = mergeRecords(region?.imports ?? [], requested, style); - if (added.length === 0) return { content, added: [], warnings }; - const eol = detectEol(content); - const lines = content.split(/\r?\n/); - const blockLines = renderImportBlock(records, style); - const nextContent = region - ? replaceRegion(content, lines, region, blockLines, eol) - : insertWithoutRegion(content, lines, blockLines, eol); - return { content: nextContent, added, warnings }; - }, -}; - -export { defaultImportStyle }; diff --git a/packages/coding-agent/src/patch/imports/rust.ts b/packages/coding-agent/src/patch/imports/rust.ts deleted file mode 100644 index 7f299ffd8..000000000 --- a/packages/coding-agent/src/patch/imports/rust.ts +++ /dev/null @@ -1,594 +0,0 @@ -import type { ImportSpec } from "../types"; -import { - defaultImportStyle, - type ImportHandler, - type ImportRegion, - type ImportStyle, - type ParsedImport, -} from "./types"; - -type RustImportGroup = "std" | "external" | "local"; -type RustImportKind = "simple" | "group"; - -interface RustImportEntry extends ParsedImport { - group: RustImportGroup; - kind: RustImportKind; - path: string; - prefix?: string; - members: string[]; - order: number; -} - -interface ParsedRustRegion extends ImportRegion { - imports: RustImportEntry[]; - entries: RustImportEntry[]; - newline: string; -} - -const DEFAULT_GROUP_ORDER: RustImportGroup[] = ["std", "external", "local"]; - -function detectNewline(content: string): string { - return content.includes("\r\n") ? "\r\n" : "\n"; -} - -function hasTrailingNewline(content: string): boolean { - return content.endsWith("\n"); -} - -function splitLines(content: string): string[] { - return content.split(/\r?\n/); -} - -function joinLines(lines: string[], newline: string, trailingNewline: boolean): string { - let result = lines.join(newline); - if (trailingNewline && (lines.length === 0 || lines[lines.length - 1] !== "")) { - result += newline; - } - return result; -} - -function isBlank(line: string): boolean { - return line.trim().length === 0; -} - -function isLineComment(line: string): boolean { - const trimmed = line.trimStart(); - return trimmed.startsWith("//"); -} - -function isBlockCommentStart(line: string): boolean { - return line.trimStart().startsWith("/*"); -} - -function isOuterAttribute(line: string): boolean { - const trimmed = line.trimStart(); - return trimmed.startsWith("#![") || trimmed.startsWith("#["); -} - -function consumeBlockComment(lines: string[], startIndex: number): number { - let index = startIndex; - while (index < lines.length) { - if ((lines[index] ?? "").includes("*/")) { - return index + 1; - } - index += 1; - } - return lines.length; -} - -function stripLineComment(line: string): string { - const markerIndex = line.indexOf("//"); - return markerIndex === -1 ? line : line.slice(0, markerIndex); -} - -function normalizeWhitespace(text: string): string { - return text.trim().replace(/\s+/g, " "); -} - -function normalizeMemberName(member: string): string { - return normalizeWhitespace(member).replace(/\s+as\s+/g, " as "); -} - -function classifyGroup(path: string): RustImportGroup { - const root = path.split("::")[0] ?? path; - if (root === "std" || root === "core") { - return "std"; - } - if (root === "crate" || root === "self" || root === "super") { - return "local"; - } - return "external"; -} - -function detectGroupOrder(entries: RustImportEntry[]): string[] { - const seen = new Set(); - const order: string[] = []; - for (const entry of entries) { - if (seen.has(entry.group)) { - continue; - } - seen.add(entry.group); - order.push(entry.group); - } - return order.length > 0 ? order : [...DEFAULT_GROUP_ORDER]; -} - -function findPrefixEnd(lines: string[]): number { - let index = 0; - while (index < lines.length && isBlank(lines[index] ?? "")) { - index += 1; - } - - if ((lines[index] ?? "").startsWith("#!") && !(lines[index] ?? "").trimStart().startsWith("#![")) { - index += 1; - } - - while (index < lines.length) { - const line = lines[index] ?? ""; - if (isBlank(line) || isLineComment(line) || isOuterAttribute(line)) { - index += 1; - continue; - } - if (isBlockCommentStart(line)) { - index = consumeBlockComment(lines, index); - continue; - } - break; - } - - return index; -} - -function parseSimpleExpression(expression: string): { path: string; alias?: string } | null { - const match = /^(?.+?)(?:\s+as\s+(?[A-Za-z_][A-Za-z0-9_]*))?$/.exec(expression.trim()); - if (!match?.groups?.path) { - return null; - } - - const path = normalizeWhitespace(match.groups.path); - if (path.length === 0 || path.includes("{") || path.includes("}")) { - return null; - } - - return { - path, - alias: match.groups.alias, - }; -} - -function parseMembers(rawMembers: string): string[] | null { - const members: string[] = []; - for (const token of rawMembers.split(",")) { - const normalized = normalizeMemberName(token); - if (normalized.length === 0) { - continue; - } - if (normalized.includes("{") || normalized.includes("}")) { - return null; - } - members.push(normalized); - } - return members.length > 0 ? members : null; -} - -function parseUseStatement(line: string, order: number): RustImportEntry | null { - const withoutComment = stripLineComment(line).trim(); - const match = /^use\s+(.+);$/.exec(withoutComment); - if (!match) { - return null; - } - - const expression = normalizeWhitespace(match[1]); - const groupMatch = /^(?.+?)::\{(?[^{}]+)\}$/.exec(expression); - if (groupMatch?.groups?.prefix && groupMatch.groups.members) { - const prefix = normalizeWhitespace(groupMatch.groups.prefix); - const members = parseMembers(groupMatch.groups.members); - if (!members) { - return null; - } - return { - raw: line, - source: prefix, - names: [...members], - path: prefix, - prefix, - members, - kind: "group", - group: classifyGroup(prefix), - order, - }; - } - - const simple = parseSimpleExpression(expression); - if (!simple) { - return null; - } - - return { - raw: line, - source: simple.path, - names: [], - alias: simple.alias, - path: simple.path, - members: [], - kind: "simple", - group: classifyGroup(simple.path), - order, - }; -} - -function parseExistingRegion(content: string): ParsedRustRegion | null { - const lines = splitLines(content); - const prefixEnd = findPrefixEnd(lines); - let cursor = prefixEnd; - while (cursor < lines.length && isBlank(lines[cursor] ?? "")) { - cursor += 1; - } - const startLine = cursor + 1; - if (!(lines[cursor] ?? "").trimStart().startsWith("use ")) { - return null; - } - - const entries: RustImportEntry[] = []; - let endLine = cursor + 1; - let order = 0; - let sawBlankGroupSeparator = false; - let seenImport = false; - - while (cursor < lines.length) { - const line = lines[cursor] ?? ""; - if (isBlank(line)) { - if (seenImport) { - sawBlankGroupSeparator = true; - endLine = cursor + 1; - } - cursor += 1; - continue; - } - if (isLineComment(line)) { - if (seenImport) { - endLine = cursor + 1; - } - cursor += 1; - continue; - } - if (isBlockCommentStart(line)) { - if (!seenImport) { - break; - } - const nextCursor = consumeBlockComment(lines, cursor); - endLine = nextCursor; - cursor = nextCursor; - continue; - } - const parsed = parseUseStatement(line, order); - if (!parsed) { - break; - } - entries.push(parsed); - seenImport = true; - endLine = cursor + 1; - cursor += 1; - order += 1; - } - - if (entries.length === 0) { - return null; - } - - return { - startLine, - endLine, - imports: entries, - entries, - style: { - ...defaultImportStyle, - groupSeparator: sawBlankGroupSeparator, - groupOrder: detectGroupOrder(entries), - sorted: true, - }, - newline: detectNewline(content), - }; -} - -function parseExisting(content: string): ImportRegion | null { - return parseExistingRegion(content); -} - -function renderSimpleExpression(path: string, alias?: string): string { - return alias ? `${path} as ${alias}` : path; -} - -function renderUseLine(entry: RustImportEntry): string { - if (entry.kind === "group") { - const prefix = entry.prefix ?? entry.path; - return `use ${prefix}::{${entry.members.join(", ")}};`; - } - return `use ${renderSimpleExpression(entry.path, entry.alias)};`; -} - -function specToLine(spec: ImportSpec, _style: ImportStyle): string { - if (spec.imports && spec.imports.length > 0) { - const members = spec.imports.map(item => normalizeMemberName(item)).filter(item => item.length > 0); - return `use ${spec.from}::{${members.join(", ")}};`; - } - return `use ${renderSimpleExpression(spec.from, spec.alias)};`; -} - -function trimLeadingBlankLines(lines: string[]): string[] { - let index = 0; - while (index < lines.length && isBlank(lines[index] ?? "")) { - index += 1; - } - return lines.slice(index); -} - -function createSimpleEntry(path: string, alias: string | undefined, order: number): RustImportEntry { - return { - raw: `use ${renderSimpleExpression(path, alias)};`, - source: path, - names: [], - alias, - path, - members: [], - kind: "simple", - group: classifyGroup(path), - order, - }; -} - -function createGroupEntry(prefix: string, members: string[], order: number): RustImportEntry { - const normalizedMembers = [ - ...new Set(members.map(member => normalizeMemberName(member)).filter(member => member.length > 0)), - ]; - return { - raw: `use ${prefix}::{${normalizedMembers.join(", ")}};`, - source: prefix, - names: [...normalizedMembers], - path: prefix, - prefix, - members: normalizedMembers, - kind: "group", - group: classifyGroup(prefix), - order, - }; -} - -function normalizeRequestedImports(specs: ImportSpec[]): { entries: RustImportEntry[]; warnings: string[] } { - const warnings: string[] = []; - const entries: RustImportEntry[] = []; - let order = 0; - - for (const spec of specs) { - if (spec.default) { - warnings.push(`Rust imports do not support default imports from ${spec.from}; ignoring default binding.`); - } - if (spec.namespace) { - warnings.push(`Rust imports do not support namespace imports from ${spec.from}; ignoring namespace binding.`); - } - if (spec.system) { - warnings.push(`Rust imports do not use system import mode for ${spec.from}; ignoring system flag.`); - } - - const normalizedFrom = normalizeWhitespace(spec.from); - if (normalizedFrom.length === 0) { - continue; - } - - if (spec.imports && spec.imports.length > 0) { - const members = spec.imports.map(item => normalizeMemberName(item)).filter(item => item.length > 0); - if (members.length === 0) { - continue; - } - entries.push(createGroupEntry(normalizedFrom, members, order)); - order += 1; - continue; - } - - entries.push(createSimpleEntry(normalizedFrom, spec.alias, order)); - order += 1; - } - - return { entries, warnings }; -} - -function simpleKey(path: string, alias: string | undefined): string { - return alias ? `${path} as ${alias}` : path; -} - -function splitSimplePath(path: string): { prefix: string; member: string } | null { - const separatorIndex = path.lastIndexOf("::"); - if (separatorIndex <= 0 || separatorIndex === path.length - 2) { - return null; - } - return { - prefix: path.slice(0, separatorIndex), - member: path.slice(separatorIndex + 2), - }; -} - -function sortMembers(members: Iterable): string[] { - return [...new Set(members)].sort((left, right) => left.localeCompare(right)); -} - -function compareEntries(left: RustImportEntry, right: RustImportEntry): number { - const leftKey = left.kind === "group" ? `${left.path}::{` : renderSimpleExpression(left.path, left.alias); - const rightKey = right.kind === "group" ? `${right.path}::{` : renderSimpleExpression(right.path, right.alias); - return leftKey.localeCompare(rightKey); -} - -function mergeImports( - existing: RustImportEntry[], - requested: RustImportEntry[], -): { merged: RustImportEntry[]; added: RustImportEntry[] } { - const simpleEntries = new Map(); - const aliaslessSimplePaths = new Set(); - const groupEntries = new Map(); - let nextOrder = existing.length; - - for (const entry of existing) { - if (entry.kind === "simple") { - simpleEntries.set(simpleKey(entry.path, entry.alias), createSimpleEntry(entry.path, entry.alias, entry.order)); - if (!entry.alias) { - aliaslessSimplePaths.add(entry.path); - } - continue; - } - - const prior = groupEntries.get(entry.path); - if (!prior) { - groupEntries.set(entry.path, createGroupEntry(entry.path, entry.members, entry.order)); - continue; - } - prior.members = sortMembers([...prior.members, ...entry.members]); - prior.names = [...prior.members]; - } - - const addedSimple = new Map(); - const addedGroupMembers = new Map>(); - - for (const entry of requested) { - if (entry.kind === "simple") { - const key = simpleKey(entry.path, entry.alias); - if (simpleEntries.has(key)) { - continue; - } - if (!entry.alias) { - const pathParts = splitSimplePath(entry.path); - if (pathParts) { - const grouped = groupEntries.get(pathParts.prefix); - if (grouped?.members.includes(pathParts.member)) { - continue; - } - } - } - const normalizedEntry = createSimpleEntry(entry.path, entry.alias, nextOrder); - nextOrder += 1; - simpleEntries.set(key, normalizedEntry); - addedSimple.set(key, normalizedEntry); - if (!entry.alias) { - aliaslessSimplePaths.add(entry.path); - } - continue; - } - - let mergedGroup = groupEntries.get(entry.path); - if (!mergedGroup) { - mergedGroup = createGroupEntry(entry.path, [], nextOrder); - nextOrder += 1; - groupEntries.set(entry.path, mergedGroup); - } - - const groupAdditions = addedGroupMembers.get(entry.path) ?? new Set(); - for (const member of entry.members) { - const simplePath = `${entry.path}::${member}`; - if (aliaslessSimplePaths.has(simplePath) || mergedGroup.members.includes(member)) { - continue; - } - mergedGroup.members = sortMembers([...mergedGroup.members, member]); - mergedGroup.names = [...mergedGroup.members]; - groupAdditions.add(member); - } - if (groupAdditions.size > 0) { - addedGroupMembers.set(entry.path, groupAdditions); - } - } - - const merged = [...simpleEntries.values(), ...groupEntries.values()].sort(compareEntries); - const added = [ - ...addedSimple.values(), - ...Array.from(addedGroupMembers.entries(), ([prefix, members]) => { - const entry = createGroupEntry(prefix, sortMembers(members), nextOrder); - nextOrder += 1; - return entry; - }), - ].sort(compareEntries); - return { merged, added }; -} - -function buildImportSection(entries: RustImportEntry[], style: ImportStyle): string[] { - const requestedOrder = style.groupOrder.filter( - (group): group is RustImportGroup => group === "std" || group === "external" || group === "local", - ); - const groupOrder = [...requestedOrder]; - for (const fallbackGroup of DEFAULT_GROUP_ORDER) { - if (!groupOrder.includes(fallbackGroup)) { - groupOrder.push(fallbackGroup); - } - } - - const lines: string[] = []; - let emittedGroups = 0; - for (const group of groupOrder) { - const groupEntries = entries.filter(entry => entry.group === group).sort(compareEntries); - if (groupEntries.length === 0) { - continue; - } - if (style.groupSeparator && emittedGroups > 0) { - lines.push(""); - } - for (const entry of groupEntries) { - lines.push(renderUseLine(entry)); - } - emittedGroups += 1; - } - return lines; -} - -function apply(content: string, specs: ImportSpec[]): { content: string; added: string[]; warnings: string[] } { - const requested = normalizeRequestedImports(specs); - const parsedRegion = parseExistingRegion(content); - const style: ImportStyle = parsedRegion?.style ?? { - ...defaultImportStyle, - groupSeparator: true, - groupOrder: [...DEFAULT_GROUP_ORDER], - sorted: true, - }; - const mergeResult = mergeImports(parsedRegion?.entries ?? [], requested.entries); - if (mergeResult.added.length === 0) { - return { content, added: [], warnings: requested.warnings }; - } - - const lines = splitLines(content); - const newline = parsedRegion?.newline ?? detectNewline(content); - const trailingNewline = hasTrailingNewline(content); - const importSection = buildImportSection(mergeResult.merged, style); - let nextLines: string[]; - - if (parsedRegion) { - const before = lines.slice(0, parsedRegion.startLine - 1); - const after = trimLeadingBlankLines(lines.slice(parsedRegion.endLine)); - nextLines = [...before, ...importSection]; - if (after.length > 0 && !isBlank(nextLines[nextLines.length - 1] ?? "")) { - nextLines.push(""); - } - nextLines.push(...after); - } else { - const insertionLine = findPrefixEnd(lines); - const before = lines.slice(0, insertionLine); - const after = trimLeadingBlankLines(lines.slice(insertionLine)); - nextLines = [...before]; - if (nextLines.length > 0 && !isBlank(nextLines[nextLines.length - 1] ?? "")) { - nextLines.push(""); - } - nextLines.push(...importSection); - if (after.length > 0) { - nextLines.push(""); - nextLines.push(...after); - } - } - - return { - content: joinLines(nextLines, newline, trailingNewline), - added: mergeResult.added.map(entry => renderUseLine(entry)), - warnings: requested.warnings, - }; -} - -export const rustImportHandler: ImportHandler = { - parseExisting, - specToLine, - apply, -}; - -export { defaultImportStyle }; diff --git a/packages/coding-agent/src/patch/imports/types.ts b/packages/coding-agent/src/patch/imports/types.ts deleted file mode 100644 index 6bf67bc3a..000000000 --- a/packages/coding-agent/src/patch/imports/types.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { ImportSpec } from "../types"; - -export interface ImportStyle { - groupSeparator: boolean; - groupOrder: string[]; - sorted: boolean; - typeImportStyle?: "separate" | "inline"; - quoteStyle?: "single" | "double"; - semicolons?: boolean; -} - -export interface ParsedImport { - raw: string; - source: string; - names: string[]; - defaultImport?: string; - namespaceImport?: string; - typeOnly?: boolean; - assertion?: string; - system?: boolean; - alias?: string; -} - -export interface ImportRegion { - startLine: number; - endLine: number; - imports: ParsedImport[]; - style: ImportStyle; -} - -export interface ImportApplyResult { - content: string; - added: string[]; - warnings: string[]; -} - -export interface ImportHandler { - parseExisting(content: string): ImportRegion | null; - specToLine(spec: ImportSpec, style: ImportStyle): string; - apply(content: string, specs: ImportSpec[]): ImportApplyResult; -} - -export const defaultImportStyle: ImportStyle = { - groupSeparator: true, - groupOrder: [], - sorted: true, -}; diff --git a/packages/coding-agent/src/patch/imports/typescript.ts b/packages/coding-agent/src/patch/imports/typescript.ts deleted file mode 100644 index fccbb9ac3..000000000 --- a/packages/coding-agent/src/patch/imports/typescript.ts +++ /dev/null @@ -1,1150 +0,0 @@ -import type { ImportSpec } from "../types"; -import { - defaultImportStyle, - type ImportHandler, - type ImportRegion, - type ImportStyle, - type ParsedImport, -} from "./types"; - -type ImportGroup = "side-effect" | "external" | "local"; -type QuoteStyle = "single" | "double"; -type TypeImportStyle = "separate" | "inline"; - -interface ImportedBinding { - imported: string; - local?: string; - typeOnly: boolean; - order: number; -} - -interface TypeScriptImportStyle extends ImportStyle { - typeImportStyle: TypeImportStyle; - quoteStyle: QuoteStyle; - semicolons: boolean; -} - -interface ParsedTypeScriptImport extends ParsedImport { - kind: "side-effect" | "binding"; - typeOnly: boolean; - order: number; - startLine: number; - endLine: number; - group: ImportGroup; - quoteStyle: QuoteStyle; - hasSemicolon: boolean; - namedBindings: ImportedBinding[]; - assertion?: string; -} - -interface ParsedTypeScriptRegion extends ImportRegion { - imports: ParsedTypeScriptImport[]; - style: TypeScriptImportStyle; -} - -interface ParsedImportStatement { - kind: "side-effect" | "binding"; - typeOnly: boolean; - source: string; - quoteStyle: QuoteStyle; - assertion?: string; - defaultImport?: string; - namespaceImport?: string; - namedBindings: ImportedBinding[]; -} - -interface ParsedSpecifierName { - imported: string; - local?: string; -} - -interface ParsedSpecRequest { - source: string; - defaultImport?: string; - namespaceImport?: string; - valueBindings: ImportedBinding[]; - typeBindings: ImportedBinding[]; - assertion?: string; -} - -const DEFAULT_GROUP_ORDER: ImportGroup[] = ["side-effect", "external", "local"]; -const ASSET_IMPORT_TYPES: Record = { - ".adoc": "text", - ".css": "css", - ".json": "json", - ".md": "text", - ".txt": "text", -}; - -function getTypeScriptStyle(style: ImportStyle): TypeScriptImportStyle { - const maybeStyle = style as Partial; - return { - ...style, - typeImportStyle: maybeStyle.typeImportStyle ?? "inline", - quoteStyle: maybeStyle.quoteStyle ?? "double", - semicolons: maybeStyle.semicolons ?? true, - }; -} - -function detectEol(content: string): string { - return content.includes("\r\n") ? "\r\n" : "\n"; -} - -function isBlankLine(line: string): boolean { - return line.trim().length === 0; -} - -function isLineComment(line: string): boolean { - return line.trimStart().startsWith("//"); -} - -function startsBlockComment(line: string): boolean { - return line.trimStart().startsWith("/*"); -} - -function lineStartsImport(line: string): boolean { - return line.trimStart().startsWith("import "); -} - -function isImportGroup(value: string): value is ImportGroup { - return value === "side-effect" || value === "external" || value === "local"; -} - -function classifyGroup(record: Pick): ImportGroup { - if (record.kind === "side-effect") return "side-effect"; - if (record.source.startsWith(".") || record.source.startsWith("/")) return "local"; - return "external"; -} - -function findPrefixEnd(lines: string[]): number { - let index = 0; - if (lines[0]?.startsWith("#!")) index += 1; - while (index < lines.length) { - const line = lines[index] ?? ""; - if (isBlankLine(line) || isLineComment(line)) { - index += 1; - continue; - } - if (startsBlockComment(line)) { - index += 1; - while (index < lines.length && !(lines[index - 1] ?? "").includes("*/")) index += 1; - continue; - } - break; - } - return index; -} - -function stripComments(text: string): string { - let result = ""; - let index = 0; - let inSingle = false; - let inDouble = false; - let inTemplate = false; - let inBlockComment = false; - let escaped = false; - while (index < text.length) { - const char = text[index] ?? ""; - const next = text[index + 1] ?? ""; - if (inBlockComment) { - if (char === "*" && next === "/") { - inBlockComment = false; - index += 2; - continue; - } - if (char === "\n" || char === "\r") result += char; - index += 1; - continue; - } - if (inSingle) { - result += char; - if (escaped) { - escaped = false; - } else if (char === "\\") { - escaped = true; - } else if (char === "'") { - inSingle = false; - } - index += 1; - continue; - } - if (inDouble) { - result += char; - if (escaped) { - escaped = false; - } else if (char === "\\") { - escaped = true; - } else if (char === '"') { - inDouble = false; - } - index += 1; - continue; - } - if (inTemplate) { - result += char; - if (escaped) { - escaped = false; - } else if (char === "\\") { - escaped = true; - } else if (char === "`") { - inTemplate = false; - } - index += 1; - continue; - } - if (char === "/" && next === "/") { - index += 2; - while (index < text.length) { - const commentChar = text[index] ?? ""; - if (commentChar === "\n" || commentChar === "\r") break; - index += 1; - } - continue; - } - if (char === "/" && next === "*") { - inBlockComment = true; - index += 2; - continue; - } - result += char; - if (char === "'") inSingle = true; - else if (char === '"') inDouble = true; - else if (char === "`") inTemplate = true; - index += 1; - } - return result; -} - -function parseStringLiteral(text: string): { value: string; quoteStyle: QuoteStyle; nextIndex: number } | null { - const quote = text[0]; - if (quote !== '"' && quote !== "'") return null; - let value = ""; - let index = 1; - let escaped = false; - while (index < text.length) { - const char = text[index] ?? ""; - if (escaped) { - value += char; - escaped = false; - index += 1; - continue; - } - if (char === "\\") { - value += char; - escaped = true; - index += 1; - continue; - } - if (char === quote) { - return { - value, - quoteStyle: quote === "'" ? "single" : "double", - nextIndex: index + 1, - }; - } - value += char; - index += 1; - } - return null; -} - -function scanBalancedState(text: string): { balanced: boolean } { - let braceDepth = 0; - let bracketDepth = 0; - let parenDepth = 0; - let inSingle = false; - let inDouble = false; - let inTemplate = false; - let inBlockComment = false; - let escaped = false; - for (let index = 0; index < text.length; index += 1) { - const char = text[index] ?? ""; - const next = text[index + 1] ?? ""; - if (inBlockComment) { - if (char === "*" && next === "/") { - inBlockComment = false; - index += 1; - } - continue; - } - if (inSingle) { - if (escaped) escaped = false; - else if (char === "\\") escaped = true; - else if (char === "'") inSingle = false; - continue; - } - if (inDouble) { - if (escaped) escaped = false; - else if (char === "\\") escaped = true; - else if (char === '"') inDouble = false; - continue; - } - if (inTemplate) { - if (escaped) escaped = false; - else if (char === "\\") escaped = true; - else if (char === "`") inTemplate = false; - continue; - } - if (char === "/" && next === "*") { - inBlockComment = true; - index += 1; - continue; - } - if (char === "/" && next === "/") break; - if (char === "'") { - inSingle = true; - continue; - } - if (char === '"') { - inDouble = true; - continue; - } - if (char === "`") { - inTemplate = true; - continue; - } - if (char === "{") braceDepth += 1; - else if (char === "}") braceDepth -= 1; - else if (char === "[") bracketDepth += 1; - else if (char === "]") bracketDepth -= 1; - else if (char === "(") parenDepth += 1; - else if (char === ")") parenDepth -= 1; - } - return { - balanced: - !inSingle && - !inDouble && - !inTemplate && - !inBlockComment && - braceDepth === 0 && - bracketDepth === 0 && - parenDepth === 0, - }; -} - -function findKeywordOutside(text: string, keyword: string): number { - let braceDepth = 0; - let bracketDepth = 0; - let parenDepth = 0; - let inSingle = false; - let inDouble = false; - let inTemplate = false; - let escaped = false; - for (let index = 0; index <= text.length - keyword.length; index += 1) { - const char = text[index] ?? ""; - if (inSingle) { - if (escaped) escaped = false; - else if (char === "\\") escaped = true; - else if (char === "'") inSingle = false; - continue; - } - if (inDouble) { - if (escaped) escaped = false; - else if (char === "\\") escaped = true; - else if (char === '"') inDouble = false; - continue; - } - if (inTemplate) { - if (escaped) escaped = false; - else if (char === "\\") escaped = true; - else if (char === "`") inTemplate = false; - continue; - } - if (char === "'") { - inSingle = true; - continue; - } - if (char === '"') { - inDouble = true; - continue; - } - if (char === "`") { - inTemplate = true; - continue; - } - if (char === "{") { - braceDepth += 1; - continue; - } - if (char === "}") { - braceDepth -= 1; - continue; - } - if (char === "[") { - bracketDepth += 1; - continue; - } - if (char === "]") { - bracketDepth -= 1; - continue; - } - if (char === "(") { - parenDepth += 1; - continue; - } - if (char === ")") { - parenDepth -= 1; - continue; - } - if (braceDepth !== 0 || bracketDepth !== 0 || parenDepth !== 0) continue; - if (text.slice(index, index + keyword.length) !== keyword) continue; - const before = index === 0 ? "" : (text[index - 1] ?? ""); - const after = text[index + keyword.length] ?? ""; - const beforeOk = before.length === 0 || /\s|,|\{|\}/.test(before); - const afterOk = after.length === 0 || /\s|['"]/.test(after); - if (beforeOk && afterOk) return index; - } - return -1; -} - -function splitTopLevel(text: string, delimiter: string): string[] { - const parts: string[] = []; - let current = ""; - let braceDepth = 0; - let bracketDepth = 0; - let parenDepth = 0; - let inSingle = false; - let inDouble = false; - let inTemplate = false; - let escaped = false; - for (let index = 0; index < text.length; index += 1) { - const char = text[index] ?? ""; - if (inSingle) { - current += char; - if (escaped) escaped = false; - else if (char === "\\") escaped = true; - else if (char === "'") inSingle = false; - continue; - } - if (inDouble) { - current += char; - if (escaped) escaped = false; - else if (char === "\\") escaped = true; - else if (char === '"') inDouble = false; - continue; - } - if (inTemplate) { - current += char; - if (escaped) escaped = false; - else if (char === "\\") escaped = true; - else if (char === "`") inTemplate = false; - continue; - } - if (char === "'") { - inSingle = true; - current += char; - continue; - } - if (char === '"') { - inDouble = true; - current += char; - continue; - } - if (char === "`") { - inTemplate = true; - current += char; - continue; - } - if (char === "{") braceDepth += 1; - else if (char === "}") braceDepth -= 1; - else if (char === "[") bracketDepth += 1; - else if (char === "]") bracketDepth -= 1; - else if (char === "(") parenDepth += 1; - else if (char === ")") parenDepth -= 1; - if (char === delimiter && braceDepth === 0 && bracketDepth === 0 && parenDepth === 0) { - parts.push(current); - current = ""; - continue; - } - current += char; - } - parts.push(current); - return parts; -} - -function parseNamedBinding(token: string, order: number, impliedTypeOnly: boolean): ImportedBinding | null { - const trimmed = token.trim(); - if (trimmed.length === 0) return null; - const hasInlineType = trimmed.startsWith("type "); - const typeOnly = impliedTypeOnly || hasInlineType; - const body = hasInlineType ? trimmed.slice(5).trim() : trimmed; - const match = /^(?[A-Za-z_$][\w$]*)(?:\s+as\s+(?[A-Za-z_$][\w$]*))?$/.exec(body); - if (!match?.groups?.imported) return null; - return { - imported: match.groups.imported, - local: match.groups.local, - typeOnly, - order, - }; -} - -function parseSpecifierName(token: string): ParsedSpecifierName | null { - const trimmed = token.trim(); - if (trimmed.length === 0) return null; - const match = /^(?[A-Za-z_$][\w$]*)(?:\s+as\s+(?[A-Za-z_$][\w$]*))?$/.exec(trimmed); - if (!match?.groups?.imported) return null; - return { - imported: match.groups.imported, - local: match.groups.local, - }; -} - -function parseClause(clauseText: string): Omit { - let clause = clauseText.trim(); - let typeOnly = false; - if (clause.startsWith("type ")) { - typeOnly = true; - clause = clause.slice(5).trim(); - } - let defaultImport: string | undefined; - let namespaceImport: string | undefined; - const namedBindings: ImportedBinding[] = []; - const parts = splitTopLevel(clause, ",") - .map(part => part.trim()) - .filter(part => part.length > 0); - let bindingOrder = 0; - if (parts.length === 1) { - const only = parts[0] ?? ""; - if (only.startsWith("{")) { - const inner = only.slice(1, only.endsWith("}") ? -1 : undefined); - for (const token of splitTopLevel(inner, ",")) { - const parsed = parseNamedBinding(token, bindingOrder, typeOnly); - if (!parsed) continue; - namedBindings.push(parsed); - bindingOrder += 1; - } - } else if (only.startsWith("* as ")) { - namespaceImport = only.slice(5).trim(); - } else { - defaultImport = only; - } - } else if (parts.length >= 2) { - defaultImport = parts[0] ?? undefined; - const second = parts[1] ?? ""; - if (second.startsWith("* as ")) { - namespaceImport = second.slice(5).trim(); - } else if (second.startsWith("{")) { - const inner = second.slice(1, second.endsWith("}") ? -1 : undefined); - for (const token of splitTopLevel(inner, ",")) { - const parsed = parseNamedBinding(token, bindingOrder, typeOnly); - if (!parsed) continue; - namedBindings.push(parsed); - bindingOrder += 1; - } - } - } - return { - typeOnly, - defaultImport, - namespaceImport, - namedBindings, - }; -} - -function parseImportStatementText(statement: string): ParsedImportStatement | null { - const withoutComments = stripComments(statement).trim(); - if (!withoutComments.startsWith("import ")) return null; - const normalized = withoutComments.endsWith(";") ? withoutComments.slice(0, -1).trimEnd() : withoutComments; - const remainder = normalized.slice(6).trimStart(); - const sideEffectLiteral = parseStringLiteral(remainder); - if (sideEffectLiteral) { - const assertionText = remainder.slice(sideEffectLiteral.nextIndex).trim(); - return { - kind: "side-effect", - typeOnly: false, - source: sideEffectLiteral.value, - quoteStyle: sideEffectLiteral.quoteStyle, - assertion: assertionText.startsWith("with") ? assertionText : undefined, - namedBindings: [], - }; - } - const fromIndex = findKeywordOutside(remainder, "from"); - if (fromIndex === -1) return null; - const clause = remainder.slice(0, fromIndex).trim(); - const sourceSection = remainder.slice(fromIndex + 4).trimStart(); - const sourceLiteral = parseStringLiteral(sourceSection); - if (!sourceLiteral) return null; - const assertionText = sourceSection.slice(sourceLiteral.nextIndex).trim(); - const parsedClause = parseClause(clause); - return { - kind: "binding", - typeOnly: parsedClause.typeOnly, - source: sourceLiteral.value, - quoteStyle: sourceLiteral.quoteStyle, - assertion: assertionText.startsWith("with") ? assertionText : undefined, - defaultImport: parsedClause.defaultImport, - namespaceImport: parsedClause.namespaceImport, - namedBindings: parsedClause.namedBindings, - }; -} - -function collectImportStatement( - lines: string[], - startIndex: number, -): { statement: string; nextIndex: number; endLine: number; hasSemicolon: boolean } | null { - const collected: string[] = []; - for (let index = startIndex; index < lines.length; index += 1) { - collected.push(lines[index] ?? ""); - const joined = collected.join("\n"); - const { balanced } = scanBalancedState(joined); - if (!balanced) continue; - const parsed = parseImportStatementText(joined); - if (!parsed) continue; - const trimmed = stripComments(joined).trimEnd(); - return { - statement: joined, - nextIndex: index + 1, - endLine: index + 1, - hasSemicolon: trimmed.endsWith(";"), - }; - } - return null; -} - -function hasBlankLineBetween(lines: string[], startIndex: number, endIndex: number): boolean { - for (let index = startIndex; index < endIndex; index += 1) { - if (isBlankLine(lines[index] ?? "")) return true; - } - return false; -} - -function inferStyle(records: ParsedTypeScriptImport[], lines: string[]): TypeScriptImportStyle { - const singleQuotes = records.filter(record => record.quoteStyle === "single").length; - const semicolons = records.filter(record => record.hasSemicolon).length; - const separateTypeStatements = records.filter(record => record.kind === "binding" && record.typeOnly).length; - const inlineTypeBindings = records.reduce((count, record) => { - if (record.kind !== "binding" || record.typeOnly) return count; - return count + record.namedBindings.filter(binding => binding.typeOnly).length; - }, 0); - const groupOrder = records.reduce((order, record) => { - if (!order.includes(record.group)) order.push(record.group); - return order; - }, []); - let groupSeparator = false; - for (let index = 1; index < records.length; index += 1) { - if (hasBlankLineBetween(lines, records[index - 1]!.endLine, records[index]!.startLine - 1)) { - groupSeparator = true; - break; - } - } - return { - ...defaultImportStyle, - groupSeparator, - groupOrder: groupOrder.length > 0 ? groupOrder : [...DEFAULT_GROUP_ORDER], - sorted: true, - typeImportStyle: separateTypeStatements > inlineTypeBindings ? "separate" : "inline", - quoteStyle: singleQuotes > records.length - singleQuotes ? "single" : "double", - semicolons: semicolons >= Math.ceil(records.length / 2), - }; -} - -function normalizeAssertion(assertion: string | undefined): string | undefined { - return assertion?.trim() || undefined; -} - -function cloneBinding(binding: ImportedBinding, order: number): ImportedBinding { - return { - imported: binding.imported, - local: binding.local, - typeOnly: binding.typeOnly, - order, - }; -} - -function cloneRecord(record: ParsedTypeScriptImport, order: number): ParsedTypeScriptImport { - return { - ...record, - order, - startLine: 0, - endLine: 0, - namedBindings: record.namedBindings.map((binding, index) => cloneBinding(binding, index)), - }; -} - -function compareBindings(left: ImportedBinding, right: ImportedBinding): number { - const leftName = `${left.typeOnly ? "1" : "0"}:${left.imported}:${left.local ?? left.imported}`; - const rightName = `${right.typeOnly ? "1" : "0"}:${right.imported}:${right.local ?? right.imported}`; - return leftName.localeCompare(rightName); -} - -function compareRecords(left: ParsedTypeScriptImport, right: ParsedTypeScriptImport): number { - if (left.group !== right.group) return left.group.localeCompare(right.group); - if (left.source !== right.source) return left.source.localeCompare(right.source); - if (left.kind !== right.kind) return left.kind === "side-effect" ? -1 : 1; - if (left.kind === "binding" && right.kind === "binding" && left.typeOnly !== right.typeOnly) - return left.typeOnly ? 1 : -1; - return left.order - right.order; -} - -function formatBinding(binding: ImportedBinding, statementTypeOnly = false): string { - const prefix = !statementTypeOnly && binding.typeOnly ? "type " : ""; - const localPart = binding.local ? ` as ${binding.local}` : ""; - return `${prefix}${binding.imported}${localPart}`; -} - -function formatSource(source: string, quoteStyle: QuoteStyle): string { - const quote = quoteStyle === "single" ? "'" : '"'; - return `${quote}${source}${quote}`; -} - -function renderAssertion(assertion: string | undefined): string { - return assertion ? ` ${assertion}` : ""; -} - -function renderRecord(record: ParsedTypeScriptImport, style: TypeScriptImportStyle): string { - const semicolon = style.semicolons ? ";" : ""; - if (record.kind === "side-effect") { - return `import ${formatSource(record.source, style.quoteStyle)}${renderAssertion(record.assertion)}${semicolon}`; - } - const namedBindings = [...record.namedBindings]; - if (style.sorted) namedBindings.sort(compareBindings); - const namedPart = - namedBindings.length > 0 - ? `{ ${namedBindings.map(binding => formatBinding(binding, record.typeOnly)).join(", ")} }` - : ""; - const defaultPart = record.defaultImport ?? ""; - const namespacePart = record.namespaceImport ? `* as ${record.namespaceImport}` : ""; - const bindingParts = [defaultPart, namespacePart, namedPart].filter(part => part.length > 0); - const typePrefix = record.typeOnly ? "type " : ""; - return `import ${typePrefix}${bindingParts.join(", ")} from ${formatSource(record.source, style.quoteStyle)}${renderAssertion(record.assertion)}${semicolon}`; -} - -function renderImportBlock(records: ParsedTypeScriptImport[], style: TypeScriptImportStyle): string[] { - const groups = new Map(); - for (const record of records) { - const list = groups.get(record.group) ?? []; - list.push(record); - groups.set(record.group, list); - } - const orderedGroups = [...style.groupOrder.filter(isImportGroup), ...DEFAULT_GROUP_ORDER].filter( - (group, index, values) => values.indexOf(group) === index, - ); - const lines: string[] = []; - for (const group of orderedGroups) { - const recordsInGroup = groups.get(group); - if (!recordsInGroup || recordsInGroup.length === 0) continue; - const ordered = [...recordsInGroup]; - if (style.sorted) ordered.sort(compareRecords); - else ordered.sort((left, right) => left.order - right.order); - for (const record of ordered) lines.push(renderRecord(record, style)); - if (style.groupSeparator) lines.push(""); - } - if (lines[lines.length - 1] === "") lines.pop(); - return lines; -} - -function deriveAssertion(source: string): string | undefined { - for (const [extension, type] of Object.entries(ASSET_IMPORT_TYPES)) { - if (!source.toLowerCase().endsWith(extension)) continue; - return `with { type: ${JSON.stringify(type)} }`; - } - return undefined; -} - -function parseImportSpecName(token: string, order: number, typeOnly: boolean): ImportedBinding | null { - const parsed = parseSpecifierName(token); - if (!parsed) return null; - return { - imported: parsed.imported, - local: parsed.local, - typeOnly, - order, - }; -} - -function specToRequest(spec: ImportSpec): ParsedSpecRequest { - const valueBindings: ImportedBinding[] = []; - const typeBindings: ImportedBinding[] = []; - let order = 0; - for (const imported of spec.imports ?? []) { - const trimmed = imported.trim(); - if (trimmed.length === 0) continue; - const isTypeOnly = trimmed.startsWith("type "); - const parsed = parseImportSpecName(isTypeOnly ? trimmed.slice(5).trim() : trimmed, order, isTypeOnly); - if (!parsed) continue; - if (parsed.typeOnly) typeBindings.push(parsed); - else valueBindings.push(parsed); - order += 1; - } - return { - source: spec.from, - defaultImport: spec.default, - namespaceImport: spec.namespace, - valueBindings, - typeBindings, - assertion: deriveAssertion(spec.from), - }; -} - -function requestToRecords( - request: ParsedSpecRequest, - style: TypeScriptImportStyle, - startOrder: number, -): ParsedTypeScriptImport[] { - const records: ParsedTypeScriptImport[] = []; - let order = startOrder; - const group = classifyGroup({ - kind: - request.defaultImport || - request.namespaceImport || - request.valueBindings.length > 0 || - request.typeBindings.length > 0 - ? "binding" - : "side-effect", - source: request.source, - }); - if ( - !request.defaultImport && - !request.namespaceImport && - request.valueBindings.length === 0 && - request.typeBindings.length === 0 - ) { - records.push({ - raw: "", - source: request.source, - names: [], - kind: "side-effect", - typeOnly: false, - order, - startLine: 0, - endLine: 0, - group, - quoteStyle: style.quoteStyle, - hasSemicolon: style.semicolons, - namedBindings: [], - assertion: request.assertion, - }); - return records; - } - if (request.defaultImport || request.namespaceImport || request.valueBindings.length > 0) { - const bindings = request.valueBindings.map((binding, index) => cloneBinding(binding, index)); - if (style.typeImportStyle === "inline") { - for (const typeBinding of request.typeBindings) bindings.push(cloneBinding(typeBinding, bindings.length)); - } - records.push({ - raw: "", - source: request.source, - names: bindings.map(binding => formatBinding(binding, false)), - defaultImport: request.defaultImport, - namespaceImport: request.namespaceImport, - kind: "binding", - typeOnly: false, - order, - startLine: 0, - endLine: 0, - group, - quoteStyle: style.quoteStyle, - hasSemicolon: style.semicolons, - namedBindings: bindings, - assertion: request.assertion, - }); - order += 1; - } - if ( - request.typeBindings.length > 0 && - (style.typeImportStyle === "separate" || - (!request.defaultImport && !request.namespaceImport && request.valueBindings.length === 0)) - ) { - const bindings = request.typeBindings.map((binding, index) => - cloneBinding({ ...binding, typeOnly: false }, index), - ); - records.push({ - raw: "", - source: request.source, - names: bindings.map(binding => formatBinding(binding, true)), - kind: "binding", - typeOnly: true, - order, - startLine: 0, - endLine: 0, - group, - quoteStyle: style.quoteStyle, - hasSemicolon: style.semicolons, - namedBindings: bindings, - assertion: request.assertion, - }); - } - return records; -} - -function bindingKey(binding: ImportedBinding, statementTypeOnly = false): string { - const typeKey = statementTypeOnly || binding.typeOnly ? "type" : "value"; - return `${typeKey}:${binding.imported}:${binding.local ?? binding.imported}`; -} - -function hasBinding( - records: ParsedTypeScriptImport[], - source: string, - binding: ImportedBinding, - statementTypeOnly = false, -): boolean { - return records.some( - record => - record.kind === "binding" && - record.source === source && - record.namedBindings.some( - existing => bindingKey(existing, record.typeOnly) === bindingKey(binding, statementTypeOnly), - ), - ); -} - -function getSourceRecords(records: ParsedTypeScriptImport[], source: string): ParsedTypeScriptImport[] { - return records.filter(record => record.source === source); -} - -function getBindingRecord( - records: ParsedTypeScriptImport[], - source: string, - typeOnly: boolean, -): ParsedTypeScriptImport | undefined { - return records.find(record => record.kind === "binding" && record.source === source && record.typeOnly === typeOnly); -} - -function syncNames(record: ParsedTypeScriptImport): void { - record.names = record.namedBindings.map(binding => formatBinding(binding, record.typeOnly)); -} - -function mergeNamedBindings( - target: ParsedTypeScriptImport, - bindings: ImportedBinding[], - typeOnly: boolean, - style: TypeScriptImportStyle, -): ImportedBinding[] { - const added: ImportedBinding[] = []; - for (const binding of bindings) { - const candidate = typeOnly - ? { ...binding, typeOnly: style.typeImportStyle === "inline" && !target.typeOnly } - : binding; - const existing = target.namedBindings.some( - current => bindingKey(current, target.typeOnly) === bindingKey(candidate, target.typeOnly), - ); - if (existing) continue; - target.namedBindings.push(cloneBinding(candidate, target.namedBindings.length)); - added.push(candidate); - } - if (style.sorted) target.namedBindings.sort(compareBindings); - syncNames(target); - return added; -} - -function buildPreviewRecord( - base: ParsedTypeScriptImport, - parts: { defaultImport?: string; namespaceImport?: string; namedBindings?: ImportedBinding[]; typeOnly?: boolean }, -): ParsedTypeScriptImport { - const namedBindings = parts.namedBindings?.map((binding, index) => cloneBinding(binding, index)) ?? []; - return { - ...base, - defaultImport: parts.defaultImport, - namespaceImport: parts.namespaceImport, - namedBindings, - names: namedBindings.map(binding => formatBinding(binding, parts.typeOnly ?? base.typeOnly)), - typeOnly: parts.typeOnly ?? base.typeOnly, - }; -} - -function mergeRecords( - existing: ParsedTypeScriptImport[], - requested: ParsedTypeScriptImport[], - style: TypeScriptImportStyle, -): { records: ParsedTypeScriptImport[]; added: string[] } { - const records = existing.map((record, index) => cloneRecord(record, index)); - const added: string[] = []; - let nextOrder = records.reduce((max, record) => Math.max(max, record.order), -1) + 1; - for (const record of requested) { - if (record.kind === "side-effect") { - const exists = records.some( - existingRecord => existingRecord.kind === "side-effect" && existingRecord.source === record.source, - ); - if (exists) continue; - const inserted = cloneRecord(record, nextOrder); - records.push(inserted); - added.push(renderRecord(inserted, style)); - nextOrder += 1; - continue; - } - if (record.typeOnly && style.typeImportStyle !== "separate") { - const inlineTarget = getBindingRecord(records, record.source, false); - if (inlineTarget) { - const normalizedBindings = record.namedBindings.map(binding => ({ ...binding, typeOnly: true })); - const missingBindings = normalizedBindings.filter(binding => !hasBinding(records, record.source, binding)); - if (missingBindings.length === 0) continue; - mergeNamedBindings(inlineTarget, normalizedBindings, true, style); - added.push( - renderRecord( - buildPreviewRecord(inlineTarget, { namedBindings: missingBindings, typeOnly: false }), - style, - ), - ); - continue; - } - } - const target = getBindingRecord(records, record.source, record.typeOnly); - if (!target) { - const inserted = cloneRecord(record, nextOrder); - records.push(inserted); - added.push(renderRecord(inserted, style)); - nextOrder += 1; - continue; - } - const addedDefault = - record.defaultImport && - !getSourceRecords(records, record.source).some( - sourceRecord => sourceRecord.defaultImport === record.defaultImport, - ) - ? record.defaultImport - : undefined; - if (addedDefault) target.defaultImport = addedDefault; - const addedNamespace = - record.namespaceImport && - !getSourceRecords(records, record.source).some( - sourceRecord => sourceRecord.namespaceImport === record.namespaceImport, - ) - ? record.namespaceImport - : undefined; - if (addedNamespace) target.namespaceImport = addedNamespace; - const missingBindings = record.namedBindings.filter( - binding => !hasBinding(records, record.source, binding, record.typeOnly), - ); - if (missingBindings.length > 0) mergeNamedBindings(target, missingBindings, record.typeOnly, style); - if (!addedDefault && !addedNamespace && missingBindings.length === 0) continue; - added.push( - renderRecord( - buildPreviewRecord(target, { - defaultImport: addedDefault, - namespaceImport: addedNamespace, - namedBindings: missingBindings, - typeOnly: record.typeOnly, - }), - style, - ), - ); - } - return { records, added }; -} - -function parseTypeScriptRegion(content: string): ParsedTypeScriptRegion | null { - const lines = content.split(/\r?\n/); - const prefixEnd = findPrefixEnd(lines); - let index = prefixEnd; - while (index < lines.length && isBlankLine(lines[index] ?? "")) index += 1; - if (!lineStartsImport(lines[index] ?? "")) return null; - const records: ParsedTypeScriptImport[] = []; - while (index < lines.length) { - const line = lines[index] ?? ""; - if (isBlankLine(line) || isLineComment(line)) { - index += 1; - continue; - } - if (startsBlockComment(line)) { - index += 1; - while (index < lines.length && !(lines[index - 1] ?? "").includes("*/")) index += 1; - continue; - } - if (!lineStartsImport(line)) break; - const collected = collectImportStatement(lines, index); - if (!collected) break; - const parsed = parseImportStatementText(collected.statement); - if (!parsed) break; - records.push({ - raw: collected.statement, - source: parsed.source, - names: parsed.namedBindings.map(binding => formatBinding(binding, parsed.typeOnly)), - defaultImport: parsed.defaultImport, - namespaceImport: parsed.namespaceImport, - typeOnly: parsed.typeOnly, - assertion: normalizeAssertion(parsed.assertion), - kind: parsed.kind, - order: records.length, - startLine: index + 1, - endLine: collected.endLine, - group: classifyGroup({ kind: parsed.kind, source: parsed.source }), - quoteStyle: parsed.quoteStyle, - hasSemicolon: collected.hasSemicolon, - namedBindings: parsed.namedBindings.map((binding, bindingIndex) => cloneBinding(binding, bindingIndex)), - }); - index = collected.nextIndex; - } - if (records.length === 0) return null; - return { - startLine: records[0]!.startLine, - endLine: Math.max(...records.map(record => record.endLine)), - imports: records, - style: inferStyle(records, lines), - }; -} - -function parseExisting(content: string): ImportRegion | null { - return parseTypeScriptRegion(content); -} - -function specToLine(spec: ImportSpec, style: ImportStyle): string { - const tsStyle = getTypeScriptStyle(style); - const request = specToRequest(spec); - const records = requestToRecords(request, tsStyle, 0); - return records.map(record => renderRecord(record, tsStyle)).join("\n"); -} - -function insertWithoutRegion(content: string, lines: string[], blockLines: string[], eol: string): string { - if (content.length === 0) return blockLines.join(eol); - const insertAt = findPrefixEnd(lines); - const before = lines.slice(0, insertAt); - const after = lines.slice(insertAt); - const needsTrailingBlank = - after.length > 0 && after.some(line => line.trim().length > 0) && !isBlankLine(after[0] ?? ""); - const nextLines = [...before, ...blockLines, ...(needsTrailingBlank ? [""] : []), ...after]; - const nextContent = nextLines.join(eol); - if (content.endsWith(eol) && !nextContent.endsWith(eol)) return `${nextContent}${eol}`; - return nextContent; -} - -function replaceRegion( - content: string, - lines: string[], - region: ParsedTypeScriptRegion, - blockLines: string[], - eol: string, -): string { - const nextLines = [...lines.slice(0, region.startLine - 1), ...blockLines, ...lines.slice(region.endLine)]; - const nextContent = nextLines.join(eol); - if (content.endsWith(eol) && !nextContent.endsWith(eol)) return `${nextContent}${eol}`; - return nextContent; -} - -export const typescriptImportHandler: ImportHandler = { - parseExisting, - specToLine, - apply(content, specs) { - const region = parseTypeScriptRegion(content); - const style = getTypeScriptStyle( - region?.style ?? { - ...defaultImportStyle, - groupSeparator: true, - groupOrder: [...DEFAULT_GROUP_ORDER], - sorted: true, - typeImportStyle: "inline", - quoteStyle: "double", - semicolons: true, - }, - ); - const requested: ParsedTypeScriptImport[] = []; - let order = 0; - for (const spec of specs) { - const request = specToRequest(spec); - const records = requestToRecords(request, style, order); - requested.push(...records); - order += Math.max(records.length, 1); - } - if (requested.length === 0) return { content, added: [], warnings: [] }; - const { records, added } = mergeRecords(region?.imports ?? [], requested, style); - if (added.length === 0) return { content, added: [], warnings: [] }; - const eol = detectEol(content); - const lines = content.split(/\r?\n/); - const blockLines = renderImportBlock(records, style); - const nextContent = region - ? replaceRegion(content, lines, region, blockLines, eol) - : insertWithoutRegion(content, lines, blockLines, eol); - return { content: nextContent, added, warnings: [] }; - }, -}; - -export { defaultImportStyle }; diff --git a/packages/coding-agent/src/patch/index.ts b/packages/coding-agent/src/patch/index.ts index a80089173..0b47ee05f 100644 --- a/packages/coding-agent/src/patch/index.ts +++ b/packages/coding-agent/src/patch/index.ts @@ -44,11 +44,10 @@ import { type HashlineEdit, parseTag, } from "./hashline"; -// Internal imports -import { applyImports } from "./imports"; import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "./normalize"; import { type EditToolDetails, getLspBatchRequest } from "./shared"; -import type { FileSystem, ImportSpec, Operation, PatchInput } from "./types"; +// Internal imports +import type { FileSystem, Operation, PatchInput } from "./types"; import { EditMatchError } from "./types"; // ═══════════════════════════════════════════════════════════════════════════ @@ -64,7 +63,6 @@ export * from "./diff"; export * from "./fuzzy"; // Hashline export * from "./hashline"; -export * from "./imports"; // Normalization export * from "./normalize"; // Parsing @@ -78,44 +76,14 @@ export * from "./types"; // Schemas // ═══════════════════════════════════════════════════════════════════════════ -const importSpecSchema = Type.Object( - { - from: Type.String({ description: "Module/crate/package path" }), - imports: Type.Optional( - Type.Array(Type.String(), { - description: "Named imports. Prefix TS type imports with `type ` (for example `type FC`)", - }), - ), - default: Type.Optional(Type.String({ description: "Default import name (TS/JS)" })), - namespace: Type.Optional(Type.String({ description: "Namespace import name (TS/JS)" })), - alias: Type.Optional(Type.String({ description: "Import alias (Go/Python)" })), - system: Type.Optional(Type.Boolean({ description: "System include for C/C++ headers" })), - }, - { additionalProperties: false }, -); - -const replaceEditSchemaBase = Type.Object({ +const replaceEditSchema = Type.Object({ path: Type.String({ description: "File path (relative or absolute)" }), old_text: Type.String({ description: "Text to find (fuzzy whitespace matching enabled)" }), new_text: Type.String({ description: "Replacement text" }), all: Type.Optional(Type.Boolean({ description: "Replace all occurrences (default: unique match required)" })), }); -const replaceEditSchemaWithImports = Type.Object( - { - ...replaceEditSchemaBase.properties, - imports: Type.Optional( - Type.Array(importSpecSchema, { description: "Imports to add or merge into the file after editing" }), - ), - }, - { additionalProperties: false }, -); - -function buildReplaceEditSchema(importsEnabled: boolean) { - return importsEnabled ? replaceEditSchemaWithImports : replaceEditSchemaBase; -} - -const patchEditSchemaBase = Type.Object({ +const patchEditSchema = Type.Object({ path: Type.String({ description: "File path" }), op: Type.Optional( StringEnum(["create", "delete", "update"], { @@ -126,22 +94,8 @@ const patchEditSchemaBase = Type.Object({ diff: Type.Optional(Type.String({ description: "Diff hunks (update) or full content (create)" })), }); -const patchEditSchemaWithImports = Type.Object( - { - ...patchEditSchemaBase.properties, - imports: Type.Optional( - Type.Array(importSpecSchema, { description: "Imports to add or merge into the file after editing" }), - ), - }, - { additionalProperties: false }, -); - -function buildPatchEditSchema(importsEnabled: boolean) { - return importsEnabled ? patchEditSchemaWithImports : patchEditSchemaBase; -} - -export type ReplaceParams = Static; -export type PatchParams = Static; +export type ReplaceParams = Static; +export type PatchParams = Static; /** * Pattern matching hashline display format prefixes: `LINE#ID:CONTENT`, `#ID:CONTENT`, and `+ID:CONTENT`. @@ -250,7 +204,7 @@ const hashlineEditSchema = Type.Object( { additionalProperties: false }, ); -const hashlineEditParamsSchemaBase = Type.Object( +const hashlineEditParamsSchema = Type.Object( { path: Type.String({ description: "path" }), edits: Type.Array(hashlineEditSchema, { description: "edits over $path" }), @@ -260,22 +214,8 @@ const hashlineEditParamsSchemaBase = Type.Object( { additionalProperties: false }, ); -const hashlineEditParamsSchemaWithImports = Type.Object( - { - ...hashlineEditParamsSchemaBase.properties, - imports: Type.Optional( - Type.Array(importSpecSchema, { description: "Imports to add or merge into the file after editing" }), - ), - }, - { additionalProperties: false }, -); - -function buildHashlineEditParamsSchema(importsEnabled: boolean) { - return importsEnabled ? hashlineEditParamsSchemaWithImports : hashlineEditParamsSchemaBase; -} - export type HashlineToolEdit = Static; -export type HashlineParams = Static; +export type HashlineParams = Static; // ═══════════════════════════════════════════════════════════════════════════ // Resilient anchor resolution @@ -417,31 +357,11 @@ function mergeDiagnosticsWithWarnings( }; } -function applyRequestedImports( - filePath: string, - content: string, - imports: ImportSpec[] | undefined, - importsEnabled: boolean, -): { content: string; warnings: string[] } { - if (!importsEnabled || !imports || imports.length === 0) { - return { content, warnings: [] }; - } - - const result = applyImports(filePath, content, imports); - return { content: result.content, warnings: result.warnings }; -} - // ═══════════════════════════════════════════════════════════════════════════ // Tool Class // ═══════════════════════════════════════════════════════════════════════════ -type TInput = - | typeof replaceEditSchemaBase - | typeof replaceEditSchemaWithImports - | typeof patchEditSchemaBase - | typeof patchEditSchemaWithImports - | typeof hashlineEditParamsSchemaBase - | typeof hashlineEditParamsSchemaWithImports; +type TInput = typeof replaceEditSchema | typeof patchEditSchema | typeof hashlineEditParamsSchema; export type EditMode = "replace" | "patch" | "hashline"; @@ -459,15 +379,6 @@ function isReplaceParams(params: ReplaceParams | PatchParams | HashlineParams): return "old_text" in params && "new_text" in params; } -function getRequestedImports(params: ReplaceParams | PatchParams | HashlineParams): ImportSpec[] | undefined { - if (!("imports" in params)) { - return undefined; - } - - const { imports } = params as { imports?: unknown }; - return Array.isArray(imports) ? (imports as ImportSpec[]) : undefined; -} - /** * Edit tool implementation. * @@ -482,12 +393,8 @@ export class EditTool implements AgentTool { readonly #allowFuzzy: boolean; readonly #fuzzyThreshold: number; - readonly #manageImports: boolean; readonly #writethrough: WritethroughCallback; readonly #editMode?: EditMode | null; - readonly #replaceSchema: TInput; - readonly #patchSchema: TInput; - readonly #hashlineSchema: TInput; constructor(private readonly session: ToolSession) { const { @@ -530,10 +437,6 @@ export class EditTool implements AgentTool { } break; } - this.#manageImports = session.settings.get("edit.manageImports"); - this.#replaceSchema = buildReplaceEditSchema(this.#manageImports); - this.#patchSchema = buildPatchEditSchema(this.#manageImports); - this.#hashlineSchema = buildHashlineEditParamsSchema(this.#manageImports); const enableLsp = session.enableLsp ?? true; const enableDiagnostics = enableLsp && session.settings.get("lsp.diagnosticsOnEdit"); @@ -566,14 +469,13 @@ export class EditTool implements AgentTool { * Dynamic description based on current edit mode (which depends on current model). */ get description(): string { - const promptContext = { editManageImportsEnabled: this.#manageImports }; switch (this.mode) { case "patch": - return renderPromptTemplate(patchDescription, promptContext); + return renderPromptTemplate(patchDescription); case "hashline": - return renderPromptTemplate(hashlineDescription, promptContext); + return renderPromptTemplate(hashlineDescription); default: - return renderPromptTemplate(replaceDescription, promptContext); + return renderPromptTemplate(replaceDescription); } } @@ -583,11 +485,11 @@ export class EditTool implements AgentTool { get parameters(): TInput { switch (this.mode) { case "patch": - return this.#patchSchema; + return patchEditSchema; case "hashline": - return this.#hashlineSchema; + return hashlineEditParamsSchema; default: - return this.#replaceSchema; + return replaceEditSchema; } } @@ -608,7 +510,6 @@ export class EditTool implements AgentTool { throw new Error("Invalid edit parameters for hashline mode."); } const { path, edits, delete: deleteFile, move } = params; - const imports = getRequestedImports(params); enforcePlanModeWrite(this.session, path, { op: deleteFile ? "delete" : "update", move }); @@ -673,13 +574,9 @@ export class EditTool implements AgentTool { throw new Error(`File not found: ${path}`); } } - const createdResult = applyRequestedImports(move ?? path, lines.join("\n"), imports, this.#manageImports); - await fs.writeFile(absolutePath, createdResult.content); - const warningsBlock = createdResult.warnings.length - ? `\n\nWarnings:\n${createdResult.warnings.join("\n")}` - : ""; + await fs.writeFile(absolutePath, lines.join("\n")); return { - content: [{ type: "text", text: `Created ${path}${warningsBlock}` }], + content: [{ type: "text", text: `Created ${path}` }], details: { diff: "", op: "create", @@ -701,11 +598,10 @@ export class EditTool implements AgentTool { const anchorResult = applyHashlineEdits(normalizedText, anchorEdits); normalizedText = anchorResult.lines; - const importResult = applyRequestedImports(move ?? path, normalizedText, imports, this.#manageImports); const result = { - text: importResult.content, + text: normalizedText, firstChangedLine: anchorResult.firstChangedLine, - warnings: [...(anchorResult.warnings ?? []), ...importResult.warnings], + warnings: anchorResult.warnings, noopEdits: anchorResult.noopEdits, }; if (originalNormalized === result.text && !move) { @@ -815,7 +711,6 @@ export class EditTool implements AgentTool { throw new Error("Invalid edit parameters for patch mode."); } const { path, op: rawOp, rename, diff } = params; - const imports = getRequestedImports(params); // Normalize unrecognized operations to "update" const op: Operation = rawOp === "create" || rawOp === "delete" ? rawOp : "update"; @@ -841,36 +736,6 @@ export class EditTool implements AgentTool { fuzzyThreshold: this.#fuzzyThreshold, allowFuzzy: this.#allowFuzzy, }); - const writePath = result.change.newPath ?? resolvedPath; - let importDiagnostics: FileDiagnosticsResult | undefined; - const importWarnings: string[] = []; - if ( - imports && - imports.length > 0 && - result.change.type !== "delete" && - result.change.newContent !== undefined - ) { - const importResult = applyRequestedImports( - result.change.newPath ?? path, - result.change.newContent, - imports, - this.#manageImports, - ); - importWarnings.push(...importResult.warnings); - if (importResult.content !== result.change.newContent) { - result.change.newContent = importResult.content; - importDiagnostics = await this.#writethrough( - writePath, - result.change.newContent, - signal, - Bun.file(writePath), - batchRequest, - ); - } else { - result.change.newContent = importResult.content; - } - } - if (resolvedRename) { invalidateFsScanAfterRename(resolvedPath, resolvedRename); } else if (result.change.type === "delete") { @@ -904,12 +769,12 @@ export class EditTool implements AgentTool { break; } - let diagnostics = importDiagnostics ?? fs.getDiagnostics(); + let diagnostics = fs.getDiagnostics(); if (op === "delete" && batchRequest?.flush) { const flushedDiagnostics = await flushLspWritethroughBatch(batchRequest.id, this.session.cwd, signal); diagnostics ??= flushedDiagnostics; } - const patchWarnings = [...(result.warnings ?? []), ...importWarnings]; + const patchWarnings = result.warnings ?? []; const mergedDiagnostics = mergeDiagnosticsWithWarnings(diagnostics, patchWarnings); const meta = outputMeta() @@ -936,7 +801,6 @@ export class EditTool implements AgentTool { throw new Error("Invalid edit parameters for replace mode."); } const { path, old_text, new_text, all } = params; - const imports = getRequestedImports(params); enforcePlanModeWrite(this.session, path); @@ -996,8 +860,7 @@ export class EditTool implements AgentTool { ); } - const importResult = applyRequestedImports(path, result.content, imports, this.#manageImports); - const finalContent = bom + restoreLineEndings(importResult.content, originalEnding); + const finalContent = bom + restoreLineEndings(result.content, originalEnding); const diagnostics = await this.#writethrough( absolutePath, finalContent, @@ -1006,8 +869,7 @@ export class EditTool implements AgentTool { batchRequest, ); invalidateFsScanAfterWrite(absolutePath); - const diffResult = generateDiffString(normalizedContent, importResult.content); - const mergedDiagnostics = mergeDiagnosticsWithWarnings(diagnostics, importResult.warnings); + const diffResult = generateDiffString(normalizedContent, result.content); const resultText = result.count > 1 @@ -1015,17 +877,12 @@ export class EditTool implements AgentTool { : `Successfully replaced text in ${path}.`; const meta = outputMeta() - .diagnostics(mergedDiagnostics?.summary ?? "", mergedDiagnostics?.messages ?? []) + .diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? []) .get(); return { content: [{ type: "text", text: resultText }], - details: { - diff: diffResult.diff, - firstChangedLine: diffResult.firstChangedLine, - diagnostics: mergedDiagnostics, - meta, - }, + details: { diff: diffResult.diff, firstChangedLine: diffResult.firstChangedLine, diagnostics, meta }, }; } } diff --git a/packages/coding-agent/src/patch/types.ts b/packages/coding-agent/src/patch/types.ts index e848e8a2a..d612c3ff7 100644 --- a/packages/coding-agent/src/patch/types.ts +++ b/packages/coding-agent/src/patch/types.ts @@ -174,26 +174,6 @@ export interface ApplyPatchOptions { fs?: FileSystem; } -// ═══════════════════════════════════════════════════════════════════════════ -// Import Management Types -// ═══════════════════════════════════════════════════════════════════════════ - -/** Structured import request supplied alongside an edit operation. */ -export interface ImportSpec { - /** Module/crate/package path or header name */ - from: string; - /** Named imports; TS type imports use a `type ` prefix */ - imports?: string[]; - /** Default import name (TS/JS) */ - default?: string; - /** Namespace import name (TS/JS) */ - namespace?: string; - /** Import alias (Go/Python) */ - alias?: string; - /** Whether a C/C++ include is a system include */ - system?: boolean; -} - // ═══════════════════════════════════════════════════════════════════════════ // Diff Generation Types // ═══════════════════════════════════════════════════════════════════════════ diff --git a/packages/coding-agent/src/prompts/tools/hashline.md b/packages/coding-agent/src/prompts/tools/hashline.md index cba7cb2f5..1845dff40 100644 --- a/packages/coding-agent/src/prompts/tools/hashline.md +++ b/packages/coding-agent/src/prompts/tools/hashline.md @@ -8,16 +8,6 @@ Read the file first. Copy anchors exactly from the latest `read` output. In one - `move` — optional rename target - `delete` — optional whole-file delete - `edits` — array of `{ loc, content }` entries -{{#if editManageImportsEnabled}} -- `imports` — **OPTIONAL** import/include declarations to merge after the main edit -**Import entry** -- `from` — **REQUIRED** module/crate/package/header source -- `imports` — **OPTIONAL** named imports -- `default` — **OPTIONAL** default import name -- `namespace` — **OPTIONAL** namespace import name -- `alias` — **OPTIONAL** import alias where supported -- `system` — **OPTIONAL** system-include flag for C/C++ -{{/if}} **Edit entry**: `{ loc, content }` - `loc` — where to apply the edit (see below) @@ -27,11 +17,7 @@ Read the file first. Copy anchors exactly from the latest `read` output. In one - `"append"` / `"prepend"` — insert at end/start of file - `{ append: "N#ID" }` / `{ prepend: "N#ID" }` — insert after/before anchored line - `{ range: { pos: "N#ID", end: "N#ID" } }` — replace inclusive range of lines `pos..end` with new content - -{{#if editManageImportsEnabled}} -`imports` are merged after the main edit is applied. Use them when inserted or replaced code now depends on new imports/includes. -{{/if}} - + All examples below reference the same file: @@ -56,28 +42,6 @@ All examples below reference the same file: {{hline 18 "}"}} ``` -{{#if editManageImportsEnabled}} - -Replace only the catch body. Do not target the shared boundary line `} catch (err) {`. -``` -{ - path: "a.ts", - edits: [{ - loc: { range: { pos: {{href 15 "\t\tconsole.error(err);"}}, end: {{href 16 "\t\treturn null;"}} } }, - content: [ - "\t\tif (isEnoent(err)) return null;", - "\t\tthrow err;" - ] - }], - imports: [{ - from: "./errors", - imports: ["isEnoent"] - }] -} -``` -`imports` are merged after the anchored edit, so existing declarations are reused before new ones are inserted. - -{{else}} Replace only the catch body. Do not target the shared boundary line `} catch (err) {`. ``` @@ -93,7 +57,6 @@ Replace only the catch body. Do not target the shared boundary line `} catch (er } ``` -{{/if}} Replace the entire body of `alpha`, including its closing `}`. `end` **MUST** be {{href 7 "}"}} because `content` includes `}`. @@ -165,4 +128,4 @@ When adding a sibling declaration, prefer `prepend` on the next declaration. - For a range, either replace only the body or replace the whole range. Do not split range boundaries. - `content` must be literal file content with matching indentation. If the file uses tabs, use real tabs. - You **MUST NOT** use this tool to reformat or clean up unrelated code. **ALWAYS** use project-specific tooling like linters or code formatters which are much more efficient and reliable. - + diff --git a/packages/coding-agent/src/prompts/tools/patch.md b/packages/coding-agent/src/prompts/tools/patch.md index 191c0bef4..6ad8b460e 100644 --- a/packages/coding-agent/src/prompts/tools/patch.md +++ b/packages/coding-agent/src/prompts/tools/patch.md @@ -14,12 +14,6 @@ Patches files given diff hunks. Primary tool for existing-file edits. **Context Lines:** Use enough ` `-prefixed lines to make match unique (usually 2–8) When editing structured blocks (nested braces, tags, indented regions), include opening and closing lines so edit stays inside block -{{#if editManageImportsEnabled}} -**Imports:** -- `imports` is **OPTIONAL**. Use it when the edit adds code that now requires new imports/includes. -- Each entry starts with `from` and **MAY** include named `imports`, `default`, `namespace`, `alias`, or `system`, depending on language. -- Import management runs after the main edit, so the diff should focus on the code change and `imports` should describe declarations to merge or add. -{{/if}} @@ -29,22 +23,13 @@ type T = // - Each hunk begins with "@@" (anchor optional). // - Each hunk body only has lines starting with ' ' | '+' | '-'. // - Each hunk includes at least one change (+ or -). - | { path: string, op: "update", diff: string{{#if editManageImportsEnabled}}, imports?: ImportSpec[]{{/if}} } + | { path: string, op: "update", diff: string } // Diff is full file content, no prefixes. - | { path: string, op: "create", diff: string{{#if editManageImportsEnabled}}, imports?: ImportSpec[]{{/if}} } + | { path: string, op: "create", diff: string } // No diff for delete. | { path: string, op: "delete" } -{{#if editManageImportsEnabled}} - -type ImportSpec = { - from: string; - imports?: string[]; - default?: string; - namespace?: string; - alias?: string; - system?: boolean; -} -{{/if}} + // New path for update+move. + | { path: string, op: "update", rename: string, diff: string } ``` @@ -64,50 +49,24 @@ Returns success/failure; on failure, error message indicates: - **NEVER** use edit to fix indentation, whitespace, or reformat code. Formatting is a single command run once at the end (`bun fmt`, `cargo fmt`, `prettier —write`, etc.)—not N individual edits. If you see inconsistent indentation after an edit, leave it; the formatter will fix all of it in one pass. -{{#if editManageImportsEnabled}} - -```json -{ - "path": "src/app.ts", - "op": "update", - "diff": "@@ function run() {\n function run() {\n-\treturn value;\n+\treturn format(value);\n }\n", - "imports": [ - { - "from": "./format", - "imports": ["format"] - } - ] -} -``` -`imports` are merged after the diff applies, so existing imports are reused when possible and only missing declarations are added. + +edit {"path":"hello.txt","op":"create","diff":"Hello\n"} - -```json -{ - "path": "src/app.ts", - "op": "update", - "diff": "@@\n ...\n", - "imports": [ - { - "from": "react", - "default": "React", - "imports": [ - "useMemo", - "type FC", - "useState" - ] - } - ] -} -``` -Use only supported fields: `from`, optional `imports`, `default`, `namespace`, `alias`, and `system`. + +edit {"path":"src/app.py","op":"update","diff":"@@ def greet():\n def greet():\n-print('Hi')\n+print('Hello')\n"} + + + +edit {"path":"src/app.py","op":"update","rename":"src/main.py","diff":"@@\n …\n"} + + + +edit {"path":"obsolete.txt","op":"delete"} -{{/if}} -{{#if editManageImportsEnabled}} -- Do not duplicate imports already present in the file; describe the desired imports once and let merge logic dedupe. -- Do not rely on `imports` to perform unrelated code edits; it only manages import/include declarations after the main edit. -{{/if}} +- Generic anchors: `import`, `export`, `describe`, `function`, `const` +- Repeating same addition in multiple hunks (duplicate blocks) +- Full-file overwrites for minor changes (acceptable for major restructures or short files) diff --git a/packages/coding-agent/src/prompts/tools/replace.md b/packages/coding-agent/src/prompts/tools/replace.md index ff7d0e2bf..eb6b13879 100644 --- a/packages/coding-agent/src/prompts/tools/replace.md +++ b/packages/coding-agent/src/prompts/tools/replace.md @@ -5,34 +5,8 @@ Performs string replacements in files with fuzzy whitespace matching. - If `old_text` not unique, you **MUST** expand to include more context or use `all: true` to replace all occurrences - Fuzzy matching handles minor whitespace/indentation differences automatically - You **SHOULD** prefer editing existing files over creating new ones -{{#if editManageImportsEnabled}} -- `imports` is **OPTIONAL**. Use it when the replacement introduces code that needs imports/includes. -- Each `imports` entry starts with `from` and **MAY** include named `imports`, `default`, `namespace`, `alias`, or `system`. -- Import management runs after the main replacement, so use `new_text` for the code change and `imports` for declarations to merge or add. -{{/if}} - -```ts -type T = { - path: string; - old_text: string; - new_text: string; - all?: boolean; -{{#if editManageImportsEnabled}} - imports?: Array<{ - from: string; - imports?: string[]; - default?: string; - namespace?: string; - alias?: string; - system?: boolean; - }>; -{{/if}} -}; -``` - - Returns success/failure status. On success, file modified in place with replacement applied. On failure (e.g., `old_text` not found or matches multiple locations without `all: true`), returns error describing issue. @@ -41,44 +15,8 @@ Returns success/failure status. On success, file modified in place with replacem - You **MUST** read the file at least once in the conversation before editing. Tool errors if you attempt edit without reading file first. -{{#if editManageImportsEnabled}} - -```json -{ - "path": "src/widget.ts", - "old_text": "return value;", - "new_text": "return formatValue(value);", - "imports": [ - { - "from": "./format", - "imports": ["formatValue"] - } - ] -} -``` -`imports` are merged after `new_text` is written, so existing imports stay canonical and only missing ones are added. - - - -```json -{ - "path": "src/widget.ts", - "old_text": "uuid()", - "new_text": "crypto.randomUUID()", - "imports": [ - { - "from": "node:crypto", - "namespace": "crypto" - } - ] -} -``` -Use only supported fields: `from`, optional `imports`, `default`, `namespace`, `alias`, and `system`. - -{{/if}} - -Replace for content-addressed changes—you identify _what_ to change by its text. +Replace for content-addressed changes—you identify \_what* to change by its text. For position-addressed or pattern-addressed changes, bash more efficient: diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index 4c35a1a5d..69371bfc0 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -229,6 +229,8 @@ export interface CreateAgentSessionResult { modelFallbackMessage?: string; /** LSP servers that were warmed up at startup */ lspServers?: Array<{ name: string; status: "ready" | "error"; fileTypes: string[]; error?: string }>; + /** Shared event bus for tool/extension communication */ + eventBus: EventBus; } // Re-exports @@ -1639,5 +1641,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} mcpManager, modelFallbackMessage, lspServers, + eventBus, }; } diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index 24a299893..2fb6f562d 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -38,6 +38,7 @@ import { type ReviewFinding, type SingleResult, TASK_SUBAGENT_EVENT_CHANNEL, + TASK_SUBAGENT_LIFECYCLE_CHANNEL, TASK_SUBAGENT_PROGRESS_CHANNEL, } from "./types"; @@ -630,6 +631,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise !parentOwnedToolNames.has(name)); @@ -1238,6 +1253,19 @@ export async function runSubprocess(options: ExecutorOptions): Promise { contextFile: contextFilePath, enableLsp: false, signal, - eventBus: undefined, + eventBus: this.session.eventBus, onProgress: progress => { progressMap.set(index, { ...structuredClone(progress), @@ -820,7 +833,7 @@ export class TaskTool implements AgentTool { contextFile: contextFilePath, enableLsp: false, signal, - eventBus: undefined, + eventBus: this.session.eventBus, onProgress: progress => { progressMap.set(index, { ...structuredClone(progress), diff --git a/packages/coding-agent/src/task/types.ts b/packages/coding-agent/src/task/types.ts index 2ed4458f9..df3aa2333 100644 --- a/packages/coding-agent/src/task/types.ts +++ b/packages/coding-agent/src/task/types.ts @@ -31,6 +31,31 @@ export const TASK_SUBAGENT_EVENT_CHANNEL = "task:subagent:event"; /** EventBus channel for aggregated subagent progress */ export const TASK_SUBAGENT_PROGRESS_CHANNEL = "task:subagent:progress"; +/** EventBus channel for subagent lifecycle (start/end) */ +export const TASK_SUBAGENT_LIFECYCLE_CHANNEL = "task:subagent:lifecycle"; + +/** Payload emitted on TASK_SUBAGENT_PROGRESS_CHANNEL */ +export interface SubagentProgressPayload { + index: number; + agent: string; + agentSource: AgentSource; + task: string; + assignment?: string; + progress: AgentProgress; + sessionFile?: string; +} + +/** Payload emitted on TASK_SUBAGENT_LIFECYCLE_CHANNEL */ +export interface SubagentLifecyclePayload { + id: string; + agent: string; + agentSource: AgentSource; + description?: string; + status: "started" | "completed" | "failed" | "aborted"; + sessionFile?: string; + index: number; +} + /** Single task item for parallel execution */ export const taskItemSchema = Type.Object({ id: Type.String({ diff --git a/packages/coding-agent/src/tools/gh.ts b/packages/coding-agent/src/tools/gh.ts index 2eacb701d..16bd45ca2 100644 --- a/packages/coding-agent/src/tools/gh.ts +++ b/packages/coding-agent/src/tools/gh.ts @@ -2177,12 +2177,13 @@ export class GhPrCheckoutTool implements AgentTool 0 ? ` ${metaParts.join(metaSep)}` : ""; - const srcLines: string[] = [`${theme.fg("accent", title)}${metaSuffix}`]; + const srcLines: string[] = [ + truncateToWidth(`${theme.fg("accent", title)}${metaSuffix}`, MAX_SNIPPET_LINE_LEN), + ]; const snippetText = typeof src.snippet === "string" ? src.snippet : ""; if (snippetText.trim()) { const snippetLines = getPreviewLines(snippetText, MAX_SNIPPET_LINES, MAX_SNIPPET_LINE_LEN); @@ -208,7 +210,7 @@ export function renderSearchResult( srcLines.push(theme.fg("muted", `${theme.format.dash} ${snippetLine}`)); } } - if (url) srcLines.push(theme.fg("mdLinkUrl", url)); + if (url) srcLines.push(theme.fg("mdLinkUrl", truncateToWidth(url, MAX_SNIPPET_LINE_LEN))); return srcLines; }, }, diff --git a/packages/coding-agent/test/autoresearch-state.test.ts b/packages/coding-agent/test/autoresearch-state.test.ts index db59e877a..bde9542a8 100644 --- a/packages/coding-agent/test/autoresearch-state.test.ts +++ b/packages/coding-agent/test/autoresearch-state.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from "bun:test"; +import { afterEach, describe, expect, it, vi } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; @@ -17,7 +17,11 @@ import type { SessionSwitchEvent, ToolCallEvent, } from "../src/extensibility/extensions"; +import * as git from "../src/utils/git"; +afterEach(() => { + vi.restoreAllMocks(); +}); function makeTempDir(): string { const dir = path.join(os.tmpdir(), `pi-autoresearch-test-${Snowflake.next()}`); fs.mkdirSync(dir, { recursive: true }); @@ -326,6 +330,55 @@ function createAutoresearchCommandHarness( let command: RegisteredCommand | undefined; const inputQueue = typeof inputResult === "string" || inputResult === undefined ? [inputResult] : [...inputResult]; + const runGitMock = async (args: string[]) => { + execCalls.push({ args: [...args], command: "git" }); + if (execImpl) { + return execImpl("git", args); + } + return { code: 0, stderr: "", stdout: "" }; + }; + + vi.spyOn(git.repo, "root").mockImplementation(async () => { + const result = await runGitMock(["rev-parse", "--show-toplevel"]); + if (result.code !== 0) return null; + const repoRoot = result.stdout.trim(); + return repoRoot.length > 0 ? repoRoot : null; + }); + vi.spyOn(git.show, "prefix").mockImplementation(async () => { + const result = await runGitMock(["rev-parse", "--show-prefix"]); + return result.code === 0 ? result.stdout.trim() : ""; + }); + vi.spyOn(git.branch, "current").mockImplementation(async () => { + const result = await runGitMock(["branch", "--show-current"]); + if (result.code !== 0) return null; + const branch = result.stdout.trim(); + return branch.length > 0 ? branch : null; + }); + const mockStatus = Object.assign( + async (_cwd: string, options?: Parameters[1]) => { + const args = ["status", "--porcelain=v1", "--untracked-files=all", "-z"]; + if (options?.pathspecs?.length) { + args.push("--", ...options.pathspecs); + } + const result = await runGitMock(args); + if (result.code !== 0) + throw new Error(result.stderr || result.stdout || `git status exited with code ${result.code}`); + return result.stdout; + }, + { parse: git.status.parse, summary: git.status.summary }, + ); + vi.spyOn(git, "status").mockImplementation(mockStatus); + vi.spyOn(git.ref, "exists").mockImplementation(async (_workDir, refName) => { + const result = await runGitMock(["show-ref", "--verify", "--quiet", refName]); + return result.code === 0; + }); + vi.spyOn(git.branch, "checkoutNew").mockImplementation(async (_workDir, branchName) => { + const result = await runGitMock(["checkout", "-b", branchName]); + if (result.code !== 0) { + throw new Error(result.stderr || result.stdout || `git checkout exited with code ${result.code}`); + } + }); + const api = { appendEntry(_customType: string, _data?: unknown): void {}, exec: async (commandName: string, args: string[]) => { diff --git a/packages/coding-agent/test/autoresearch-tools.test.ts b/packages/coding-agent/test/autoresearch-tools.test.ts index 19ae7f6f1..88832a3bd 100644 --- a/packages/coding-agent/test/autoresearch-tools.test.ts +++ b/packages/coding-agent/test/autoresearch-tools.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from "bun:test"; +import { afterEach, describe, expect, it, vi } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; @@ -16,7 +16,11 @@ import { createLogExperimentTool } from "../src/autoresearch/tools/log-experimen import { createRunExperimentTool } from "../src/autoresearch/tools/run-experiment"; import type { RunDetails } from "../src/autoresearch/types"; import type { ExtensionAPI, ExtensionContext } from "../src/extensibility/extensions"; +import * as git from "../src/utils/git"; +afterEach(() => { + vi.restoreAllMocks(); +}); function makeTempDir(): string { const dir = path.join(os.tmpdir(), `pi-autoresearch-tools-${Snowflake.next()}`); fs.mkdirSync(dir, { recursive: true }); @@ -1469,15 +1473,10 @@ describe("autoresearch tools", () => { runNumber: 1, }; - const api = { - exec: async (command: string, args: string[]) => { - if (command !== "git") return { code: 1, stderr: "unexpected", stdout: "" }; - if (args[0] === "status") { - return { code: 0, stderr: "", stdout: "R src/generated/index.ts\0src/index.ts\0" }; - } - return { code: 1, stderr: `unexpected git args: ${args.join(" ")}`, stdout: "" }; - }, - } as unknown as ExtensionAPI; + vi.spyOn(git, "status").mockResolvedValue("R src/generated/index.ts\0src/index.ts\0"); + vi.spyOn(git.show, "prefix").mockResolvedValue(""); + + const api = {} as ExtensionAPI; const tool = createLogExperimentTool({ dashboard: createDashboardStub(), diff --git a/packages/coding-agent/test/extensibility/custom-commands/ci-green.test.ts b/packages/coding-agent/test/extensibility/custom-commands/ci-green.test.ts index cc7cf916e..bc5d835af 100644 --- a/packages/coding-agent/test/extensibility/custom-commands/ci-green.test.ts +++ b/packages/coding-agent/test/extensibility/custom-commands/ci-green.test.ts @@ -1,15 +1,20 @@ -import { describe, expect, it } from "bun:test"; +import { afterEach, describe, expect, it, vi } from "bun:test"; import * as typebox from "@sinclair/typebox"; import { GreenCommand } from "../../../src/extensibility/custom-commands/bundled/ci-green"; import type { CustomCommandAPI } from "../../../src/extensibility/custom-commands/types"; import type { HookCommandContext } from "../../../src/extensibility/hooks/types"; import * as piCodingAgent from "../../../src/index"; +import * as git from "../../../src/utils/git"; -function createApi(stdout: string): CustomCommandAPI { +afterEach(() => { + vi.restoreAllMocks(); +}); + +function createApi(): CustomCommandAPI { return { cwd: "/tmp/test", exec: async () => ({ - stdout, + stdout: "", stderr: "", code: 0, killed: false, @@ -21,13 +26,14 @@ function createApi(stdout: string): CustomCommandAPI { describe("GreenCommand", () => { it("exposes the /green command name", () => { - const command = new GreenCommand(createApi("")); + const command = new GreenCommand(createApi()); expect(command.name).toBe("green"); }); it("includes tag instructions when HEAD has a tag", async () => { - const command = new GreenCommand(createApi("v0.1.0-alpha2\n")); + vi.spyOn(git.ref, "tags").mockResolvedValue(["v0.1.0-alpha2"]); + const command = new GreenCommand(createApi()); const result = await command.execute([], {} as HookCommandContext); @@ -41,7 +47,8 @@ describe("GreenCommand", () => { }); it("omits tag instructions when HEAD is not tagged", async () => { - const command = new GreenCommand(createApi("")); + vi.spyOn(git.ref, "tags").mockResolvedValue([]); + const command = new GreenCommand(createApi()); const result = await command.execute([], {} as HookCommandContext); diff --git a/packages/coding-agent/test/patch/imports/cpp.test.ts b/packages/coding-agent/test/patch/imports/cpp.test.ts deleted file mode 100644 index 34717c6e2..000000000 --- a/packages/coding-agent/test/patch/imports/cpp.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { cppImportHandler } from "../../../src/patch/imports/cpp"; -import type { ImportSpec } from "../../../src/patch/types"; - -function apply(content: string, imports: ImportSpec[]) { - return cppImportHandler.apply(content, imports); -} - -describe("c and c++ import handler", () => { - it("inserts includes after #pragma once and keeps system includes before local includes", () => { - const source = ["#pragma once", "", "class Widget {};", ""].join("\n"); - - const result = apply(source, [ - { from: "vector", system: true }, - { from: "widget/detail.h", system: false }, - ]); - - expect(result.content).toBe( - ["#pragma once", "", "#include ", "", '#include "widget/detail.h"', "", "class Widget {};", ""].join( - "\n", - ), - ); - expect(result.added).toEqual(["#include ", '#include "widget/detail.h"']); - expect(result.warnings).toEqual([]); - }); - - it("inserts includes after a header guard", () => { - const source = ["#ifndef WIDGET_H", "#define WIDGET_H", "", "struct Widget {};", ""].join("\n"); - - const result = apply(source, [{ from: "widget/detail.h", system: false }]); - - expect(result.content).toBe( - ["#ifndef WIDGET_H", "#define WIDGET_H", "", '#include "widget/detail.h"', "", "struct Widget {};", ""].join( - "\n", - ), - ); - expect(result.added).toEqual(['#include "widget/detail.h"']); - expect(result.warnings).toEqual([]); - }); - - it("deduplicates requested includes while preserving grouped ordering", () => { - const source = ["#include ", "", '#include "app/foo.h"', "", "int main();", ""].join("\n"); - - const result = apply(source, [ - { from: "string", system: true }, - { from: "vector", system: true }, - { from: "app/foo.h", system: false }, - { from: "app/foo.h", system: false }, - { from: "app/bar.h", system: false }, - ]); - - expect(result.content).toBe( - [ - "#include ", - "#include ", - "", - '#include "app/bar.h"', - '#include "app/foo.h"', - "", - "int main();", - "", - ].join("\n"), - ); - expect(result.added).toEqual(["#include ", '#include "app/bar.h"']); - expect(result.warnings).toEqual([]); - }); -}); diff --git a/packages/coding-agent/test/patch/imports/go.test.ts b/packages/coding-agent/test/patch/imports/go.test.ts deleted file mode 100644 index 8ee8727a7..000000000 --- a/packages/coding-agent/test/patch/imports/go.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { goImportHandler } from "../../../src/patch/imports/go"; -import type { ImportSpec } from "../../../src/patch/types"; - -function apply(content: string, imports: ImportSpec[]) { - return goImportHandler.apply(content, imports); -} - -describe("go import handler", () => { - it("converts a single import into a block when adding another import", () => { - const source = ["package main", "", 'import "fmt"', "", "func main() {}", ""].join("\n"); - - const result = apply(source, [{ from: "os" }]); - - expect(result.content).toBe( - ["package main", "", "import (", '\t"fmt"', '\t"os"', ")", "", "func main() {}", ""].join("\n"), - ); - expect(result.added).toEqual(['"os"']); - expect(result.warnings).toEqual([]); - }); - - it("groups stdlib imports before third-party imports in a new block", () => { - const source = ["package main", "", "func main() {}", ""].join("\n"); - - const result = apply(source, [{ from: "github.com/acme/project" }, { from: "fmt" }]); - - expect(result.content).toBe( - [ - "package main", - "", - "import (", - '\t"fmt"', - "", - '\t"github.com/acme/project"', - ")", - "", - "func main() {}", - "", - ].join("\n"), - ); - expect(result.warnings).toEqual([]); - }); - - it("deduplicates repeated requested imports", () => { - const source = ["package main", "", "func main() {}", ""].join("\n"); - - const result = apply(source, [ - { from: "fmt" }, - { from: "fmt" }, - { from: "github.com/acme/project" }, - { from: "github.com/acme/project" }, - ]); - - expect(result.content).toBe( - [ - "package main", - "", - "import (", - '\t"fmt"', - "", - '\t"github.com/acme/project"', - ")", - "", - "func main() {}", - "", - ].join("\n"), - ); - expect(result.added).toEqual(['"fmt"', '"github.com/acme/project"']); - expect(result.warnings).toEqual([]); - }); -}); diff --git a/packages/coding-agent/test/patch/imports/integration.test.ts b/packages/coding-agent/test/patch/imports/integration.test.ts deleted file mode 100644 index 479ce0d8c..000000000 --- a/packages/coding-agent/test/patch/imports/integration.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; -import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; -import { EditTool } from "@oh-my-pi/pi-coding-agent/patch"; -import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; -import { Snowflake } from "@oh-my-pi/pi-utils"; - -function createTestToolSession(cwd: string, settings: Settings = Settings.isolated()): ToolSession { - const sessionFile = path.join(cwd, "session.jsonl"); - const sessionDir = path.join(cwd, "session"); - return { - cwd, - hasUI: false, - getSessionFile: () => sessionFile, - getSessionSpawns: () => "*", - getArtifactsDir: () => sessionDir, - allocateOutputArtifact: async (toolType: string) => { - fs.mkdirSync(sessionDir, { recursive: true }); - return { - id: `${toolType}-${Snowflake.next()}`, - path: path.join(sessionDir, `${toolType}-${Snowflake.next()}.log`), - }; - }, - settings, - }; -} - -function getTextOutput(result: { content?: Array<{ type: string; text?: string }> }): string { - return ( - result.content - ?.filter(block => block.type === "text") - .map(block => block.text ?? "") - .join("\n") ?? "" - ); -} - -function getSchemaProperties(tool: EditTool): Record { - const schema = tool.parameters as unknown as { properties?: Record }; - return schema.properties ?? {}; -} - -describe("EditTool import management integration", () => { - let tempDir: string; - let originalEditVariant: string | undefined; - - beforeEach(() => { - originalEditVariant = Bun.env.PI_EDIT_VARIANT; - Bun.env.PI_EDIT_VARIANT = "replace"; - tempDir = path.join(os.tmpdir(), `coding-agent-imports-${Snowflake.next()}`); - fs.mkdirSync(tempDir, { recursive: true }); - }); - - afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - if (originalEditVariant === undefined) { - delete Bun.env.PI_EDIT_VARIANT; - } else { - Bun.env.PI_EDIT_VARIANT = originalEditVariant; - } - }); - - it("applies a textual edit and then manages imports in replace mode", async () => { - const filePath = path.join(tempDir, "main.go"); - fs.writeFileSync(filePath, ["package main", "", "func main() {", '\tprintln("old")', "}", ""].join("\n")); - - const tool = new EditTool(createTestToolSession(tempDir)); - const result = await tool.execute("replace-go-imports", { - path: filePath, - old_text: 'println("old")', - new_text: 'fmt.Println("new")', - imports: [{ from: "fmt" }], - }); - - expect(getTextOutput(result)).toContain("Successfully replaced"); - expect(await Bun.file(filePath).text()).toBe( - ["package main", "", 'import "fmt"', "", "func main() {", '\tfmt.Println("new")', "}", ""].join("\n"), - ); - }); - it("omits imports from prompts and schemas when the setting is disabled", () => { - const tool = new EditTool(createTestToolSession(tempDir, Settings.isolated({ "edit.manageImports": false }))); - - expect(tool.description).not.toContain("`imports`"); - expect(getSchemaProperties(tool)).not.toHaveProperty("imports"); - }); - - it("ignores import requests when the setting is disabled", async () => { - const filePath = path.join(tempDir, "main.go"); - fs.writeFileSync(filePath, ["package main", "", "func main() {", '\tprintln("old")', "}", ""].join("\n")); - - const tool = new EditTool(createTestToolSession(tempDir, Settings.isolated({ "edit.manageImports": false }))); - await tool.execute("replace-go-imports-disabled", { - path: filePath, - old_text: 'println("old")', - new_text: 'fmt.Println("new")', - imports: [{ from: "fmt" }], - }); - - expect(await Bun.file(filePath).text()).toBe( - ["package main", "", "func main() {", '\tfmt.Println("new")', "}", ""].join("\n"), - ); - }); - - it("includes imports in prompts and schemas when the setting is enabled", () => { - const tool = new EditTool(createTestToolSession(tempDir, Settings.isolated({ "edit.manageImports": true }))); - - expect(tool.description).toContain("`imports`"); - expect(getSchemaProperties(tool)).toHaveProperty("imports"); - }); -}); diff --git a/packages/coding-agent/test/patch/imports/python.test.ts b/packages/coding-agent/test/patch/imports/python.test.ts deleted file mode 100644 index a8d11a3b2..000000000 --- a/packages/coding-agent/test/patch/imports/python.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { pythonImportHandler } from "../../../src/patch/imports/python"; - -describe("pythonImportHandler.apply", () => { - test("inserts after shebang, encoding, and module docstring", () => { - const content = '#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\n\n"""module docs"""\n\nvalue = 1\n'; - - const result = pythonImportHandler.apply(content, [{ from: "os", imports: ["path"] }]); - - expect(result).toEqual({ - content: - '#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\n\n"""module docs"""\n\nfrom os import path\n\nvalue = 1\n', - added: ["from os import path"], - warnings: [], - }); - }); - - test("merges names into an existing from-import", () => { - const content = "from pathlib import Path\n\nvalue = 1\n"; - - const result = pythonImportHandler.apply(content, [{ from: "pathlib", imports: ["PurePath"] }]); - - expect(result.content).toBe("from pathlib import Path, PurePath\n\nvalue = 1\n"); - expect(result.added).toEqual(["from pathlib import PurePath"]); - expect(result.warnings).toEqual([]); - }); - - test("preserves existing import grouping when adding to an existing group", () => { - const content = "import os\n\nimport requests\n\nfrom .local import thing\n\nvalue = 1\n"; - - const result = pythonImportHandler.apply(content, [{ from: "sys" }]); - - expect(result.content).toBe( - "import os\nimport sys\n\nimport requests\n\nfrom .local import thing\n\nvalue = 1\n", - ); - expect(result.added).toEqual(["import sys"]); - expect(result.warnings).toEqual([]); - }); - - test("dedupes an already present import", () => { - const content = "from pathlib import Path\n"; - - const result = pythonImportHandler.apply(content, [{ from: "pathlib", imports: ["Path"] }]); - - expect(result).toEqual({ - content, - added: [], - warnings: [], - }); - }); -}); diff --git a/packages/coding-agent/test/patch/imports/rust.test.ts b/packages/coding-agent/test/patch/imports/rust.test.ts deleted file mode 100644 index 9ca92c914..000000000 --- a/packages/coding-agent/test/patch/imports/rust.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { rustImportHandler } from "../../../src/patch/imports/rust"; - -describe("rustImportHandler.apply", () => { - test("adds a use statement to a file with no imports", () => { - const result = rustImportHandler.apply("fn main() {}\n", [{ from: "std::fmt::Debug" }]); - - expect(result).toEqual({ - content: "use std::fmt::Debug;\n\nfn main() {}\n", - added: ["use std::fmt::Debug;"], - warnings: [], - }); - }); - - test("merges a requested member into an existing brace-group import", () => { - const content = "use std::fmt::{Debug};\n\nfn main() {}\n"; - - const result = rustImportHandler.apply(content, [{ from: "std::fmt", imports: ["Display"] }]); - - expect(result.content).toBe("use std::fmt::{Debug, Display};\n\nfn main() {}\n"); - expect(result.added).toEqual(["use std::fmt::{Display};"]); - expect(result.warnings).toEqual([]); - }); - - test("keeps distinct simple-path imports when they cannot be merged semantically", () => { - const content = "use crate::config;\n\nfn main() {}\n"; - - const result = rustImportHandler.apply(content, [{ from: "crate::config::load" }]); - - expect(result.content).toBe("use crate::config;\nuse crate::config::load;\n\nfn main() {}\n"); - expect(result.added).toEqual(["use crate::config::load;"]); - expect(result.warnings).toEqual([]); - }); - - test("dedupes an already present use statement", () => { - const content = "use std::fmt::Debug;\n"; - - const result = rustImportHandler.apply(content, [{ from: "std::fmt::Debug" }]); - - expect(result).toEqual({ - content, - added: [], - warnings: [], - }); - }); -}); diff --git a/packages/coding-agent/test/patch/imports/typescript.test.ts b/packages/coding-agent/test/patch/imports/typescript.test.ts deleted file mode 100644 index d9bd1d32f..000000000 --- a/packages/coding-agent/test/patch/imports/typescript.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { typescriptImportHandler } from "../../../src/patch/imports/typescript"; - -describe("typescriptImportHandler.apply", () => { - test("adds an import block to a file with no imports", () => { - const result = typescriptImportHandler.apply("const answer = 42;\n", [{ from: "react", imports: ["useMemo"] }]); - - expect(result).toEqual({ - content: 'import { useMemo } from "react";\n\nconst answer = 42;\n', - added: ['import { useMemo } from "react";'], - warnings: [], - }); - }); - - test("merges named imports into an existing module import", () => { - const content = 'import { useState } from "react";\n\nconst answer = 42;\n'; - - const result = typescriptImportHandler.apply(content, [{ from: "react", imports: ["useMemo"] }]); - - expect(result.content).toBe('import { useMemo, useState } from "react";\n\nconst answer = 42;\n'); - expect(result.added).toEqual(['import { useMemo } from "react";']); - expect(result.warnings).toEqual([]); - }); - - test("dedupes an already present import", () => { - const content = 'import { useMemo } from "react";\n'; - - const result = typescriptImportHandler.apply(content, [{ from: "react", imports: ["useMemo"] }]); - - expect(result).toEqual({ - content, - added: [], - warnings: [], - }); - }); - - test("derives an import assertion for known asset types", () => { - const result = typescriptImportHandler.apply("const answer = 42;\n", [ - { from: "./guide.adoc", default: "guide" }, - ]); - - expect(result).toEqual({ - content: 'import guide from "./guide.adoc" with { type: "text" };\n\nconst answer = 42;\n', - added: ['import guide from "./guide.adoc" with { type: "text" };'], - warnings: [], - }); - }); -}); diff --git a/packages/coding-agent/test/task/executor-subagent-reminders.test.ts b/packages/coding-agent/test/task/executor-subagent-reminders.test.ts index 41565290b..90a049801 100644 --- a/packages/coding-agent/test/task/executor-subagent-reminders.test.ts +++ b/packages/coding-agent/test/task/executor-subagent-reminders.test.ts @@ -8,6 +8,7 @@ import type { AgentSession, AgentSessionEvent, PromptOptions } from "../../src/s import type { AuthStorage } from "../../src/session/auth-storage"; import { runSubprocess, SUBAGENT_WARNING_MISSING_SUBMIT_RESULT } from "../../src/task/executor"; import type { AgentDefinition } from "../../src/task/types"; +import { EventBus } from "../../src/utils/event-bus"; function createAssistantStopMessage(text: string): AssistantMessage { return { @@ -81,6 +82,7 @@ function createSessionResult(session: AgentSession): CreateAgentSessionResult { session, extensionsResult: {} as unknown as LoadExtensionsResult, setToolUIContext: () => {}, + eventBus: new EventBus(), }; }