Merge branch 'main' into feat/secrets-hash-redaction

This commit is contained in:
Can Bölük
2026-04-05 01:19:34 +02:00
committed by GitHub
53 changed files with 1895 additions and 4347 deletions
+8 -1
View File
@@ -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.
+7
View File
@@ -289,6 +289,13 @@ function applyAnthropicCatalogPolicy(model: ApiModel<Api>, 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;
+677 -35
View File
@@ -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"
}
}
}
}
@@ -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,
),
),
+1
View File
@@ -78,6 +78,7 @@ const serviceProviderMap: Record<string, KeyResolver> = {
"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",
@@ -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 () => {
+9 -13
View File
@@ -1,18 +1,11 @@
# 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 idle auto-compaction settings and scheduling so sessions can compact after inactive turns without auto-continuing.
- 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)
@@ -24,9 +17,6 @@
### Changed
- Changed secret placeholders to deterministic hash-style redaction tokens and deobfuscated assistant output for local display.
- 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 +37,9 @@
### 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 +395,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
@@ -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;
}
@@ -797,6 +797,40 @@ export const SETTINGS_SCHEMA = {
"compaction.remoteEndpoint": { type: "string", default: undefined },
// Idle compaction
"compaction.idleEnabled": {
type: "boolean",
default: false,
ui: {
tab: "context",
label: "Idle Compaction",
description: "Compact context while idle when token count exceeds threshold",
},
},
"compaction.idleThresholdTokens": {
type: "enum",
values: [100000, 200000, 300000, 400000, 500000, 600000, 700000, 800000, 900000] as const,
default: 200000,
ui: {
tab: "context",
label: "Idle Compaction Threshold",
description: "Token count above which idle compaction triggers",
submenu: true,
},
},
"compaction.idleTimeoutSeconds": {
type: "enum",
values: [60, 120, 300, 600, 1800, 3600] as const,
default: 300,
ui: {
tab: "context",
label: "Idle Compaction Delay",
description: "Seconds to wait while idle before compacting",
submenu: true,
},
},
// Branch summaries
"branchSummary.enabled": {
type: "boolean",
@@ -958,16 +992,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,
@@ -1711,6 +1735,9 @@ export interface CompactionSettings {
autoContinue: boolean;
remoteEnabled: boolean;
remoteEndpoint: string | undefined;
idleEnabled: boolean;
idleThresholdTokens: number;
idleTimeoutSeconds: number;
}
export interface ContextPromotionSettings {
@@ -94,7 +94,7 @@ export type CustomToolSessionEvent =
}
| {
reason: "auto_compaction_start";
trigger: "threshold" | "overflow";
trigger: "threshold" | "overflow" | "idle";
action: "context-full" | "handoff";
}
| {
@@ -568,7 +568,7 @@ export interface ToolExecutionEndEvent {
/** Fired when auto-compaction starts */
export interface AutoCompactionStartEvent {
type: "auto_compaction_start";
reason: "threshold" | "overflow";
reason: "threshold" | "overflow" | "idle";
action: "context-full" | "handoff";
}
@@ -394,7 +394,7 @@ export interface TurnEndEvent {
/** Event data for auto_compaction_start event. */
export interface AutoCompactionStartEvent {
type: "auto_compaction_start";
reason: "threshold" | "overflow";
reason: "threshold" | "overflow" | "idle";
action: "context-full" | "handoff";
}
+13 -2
View File
@@ -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<string | undefined> {
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<void> {
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<v
}
}
const { session, setToolUIContext, modelFallbackMessage, lspServers, mcpManager } = await logger.timeAsync(
const { session, setToolUIContext, modelFallbackMessage, lspServers, mcpManager, eventBus } = await logger.timeAsync(
"createAgentSession",
() => createAgentSession(sessionOptions),
);
@@ -806,6 +816,7 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
setToolUIContext,
lspServers,
mcpManager,
eventBus,
initialMessage,
initialImages,
);
@@ -0,0 +1,472 @@
/**
* Session observer overlay component.
*
* Picker mode: lists main + active subagent sessions with live status.
* Viewer mode: renders a read-only transcript of the selected subagent's session
* by reading its JSONL session file — shows thinking, text, tool calls, results.
*
* Lifecycle:
* - shortcut opens picker
* - Enter on a subagent -> 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<string, ToolResultMessage>();
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<string, ToolResultMessage>): 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<string, unknown>; intent?: string },
toolResults: Map<string, ToolResultMessage>,
): 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, unknown>): 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;
}
}
@@ -109,6 +109,25 @@ const OPTION_PROVIDERS: Partial<Record<SettingPath, OptionProvider>> = {
{ value: "300000", label: "300K tokens", description: "Large context window" },
{ value: "500000", label: "500K tokens", description: "Very large context window" },
],
"compaction.idleThresholdTokens": [
{ value: "100000", label: "100K tokens" },
{ value: "200000", label: "200K tokens" },
{ value: "300000", label: "300K tokens" },
{ value: "400000", label: "400K tokens" },
{ value: "500000", label: "500K tokens" },
{ value: "600000", label: "600K tokens" },
{ value: "700000", label: "700K tokens" },
{ value: "800000", label: "800K tokens" },
{ value: "900000", label: "900K tokens" },
],
"compaction.idleTimeoutSeconds": [
{ value: "60", label: "1 minute" },
{ value: "120", label: "2 minutes" },
{ value: "300", label: "5 minutes" },
{ value: "600", label: "10 minutes" },
{ value: "1800", label: "30 minutes" },
{ value: "3600", label: "1 hour" },
],
// Retry max retries
"retry.maxRetries": [
{ value: "1", label: "1 retry" },
@@ -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();
@@ -1,5 +1,5 @@
import { INTENT_FIELD } from "@oh-my-pi/pi-agent-core";
import type { ImageContent } from "@oh-my-pi/pi-ai";
import type { AssistantMessage, ImageContent } from "@oh-my-pi/pi-ai";
import { Loader, TERMINAL, Text } from "@oh-my-pi/pi-tui";
import { settings } from "../../config/settings";
import { AssistantMessageComponent } from "../../modes/components/assistant-message";
@@ -10,6 +10,7 @@ import { TtsrNotificationComponent } from "../../modes/components/ttsr-notificat
import { getSymbolTheme, theme } from "../../modes/theme/theme";
import type { InteractiveModeContext, TodoPhase } from "../../modes/types";
import type { AgentSessionEvent } from "../../session/agent-session";
import { calculatePromptTokens } from "../../session/compaction/compaction";
import type { ExitPlanModeDetails } from "../../tools";
export class EventController {
@@ -21,6 +22,7 @@ export class EventController {
#readToolCallArgs = new Map<string, Record<string, unknown>>();
#readToolCallAssistantComponents = new Map<string, AssistantMessageComponent>();
#lastAssistantComponent: AssistantMessageComponent | undefined = undefined;
#idleCompactionTimer?: NodeJS.Timeout;
constructor(private ctx: InteractiveModeContext) {}
#resetReadGroup(): void {
@@ -107,6 +109,7 @@ export class EventController {
this.ctx.retryLoader = undefined;
this.ctx.statusContainer.clear();
}
this.#cancelIdleCompaction();
this.ctx.ensureLoadingAnimation();
this.ctx.ui.requestRender();
break;
@@ -434,16 +437,19 @@ export class EventController {
this.#readToolCallAssistantComponents.clear();
this.#lastAssistantComponent = undefined;
this.ctx.ui.requestRender();
this.#scheduleIdleCompaction();
this.sendCompletionNotification();
break;
case "auto_compaction_start": {
this.#cancelIdleCompaction();
this.ctx.autoCompactionEscapeHandler = this.ctx.editor.onEscape;
this.ctx.editor.onEscape = () => {
this.ctx.session.abortCompaction();
};
this.ctx.statusContainer.clear();
const reasonText = event.reason === "overflow" ? "Context overflow detected, " : "";
const reasonText =
event.reason === "overflow" ? "Context overflow detected, " : event.reason === "idle" ? "Idle " : "";
const actionLabel = event.action === "handoff" ? "Auto-handoff" : "Auto context-full maintenance";
this.ctx.autoCompactionLoader = new Loader(
this.ctx.ui,
@@ -458,6 +464,7 @@ export class EventController {
}
case "auto_compaction_end": {
this.#cancelIdleCompaction();
if (this.ctx.autoCompactionEscapeHandler) {
this.ctx.editor.onEscape = this.ctx.autoCompactionEscapeHandler;
this.ctx.autoCompactionEscapeHandler = undefined;
@@ -565,6 +572,51 @@ export class EventController {
}
}
#cancelIdleCompaction(): void {
if (this.#idleCompactionTimer) {
clearTimeout(this.#idleCompactionTimer);
this.#idleCompactionTimer = undefined;
}
}
#scheduleIdleCompaction(): void {
this.#cancelIdleCompaction();
// Don't schedule while compaction/handoff is already running — the agent_end from a
// handoff agent turn still has the old session's bloated token counts, and scheduling
// here would fire after the session resets, trying to handoff an empty session.
if (this.ctx.session.isCompacting) return;
const idleSettings = settings.getGroup("compaction");
if (!idleSettings.idleEnabled) return;
// Only if input is empty
if (this.ctx.editor.getText().trim()) return;
const threshold = idleSettings.idleThresholdTokens;
if (threshold <= 0) return;
if (this.#currentContextTokens() < threshold) return;
const timeoutMs = Math.max(60, Math.min(3600, idleSettings.idleTimeoutSeconds)) * 1000;
this.#idleCompactionTimer = setTimeout(() => {
this.#idleCompactionTimer = undefined;
// Re-check conditions before firing. Pruning may have run between arming
// the timer and now, dropping usage back below the idle threshold.
if (this.ctx.session.isStreaming) return;
if (this.ctx.session.isCompacting) return;
if (this.ctx.editor.getText().trim()) return;
if (this.#currentContextTokens() < threshold) return;
void this.ctx.session.runIdleCompaction();
}, timeoutMs);
}
#currentContextTokens(): number {
const lastAssistant = this.ctx.session.agent.state.messages
.slice()
.reverse()
.find((m): m is AssistantMessage => m.role === "assistant" && m.stopReason !== "aborted");
return lastAssistant?.usage ? calculatePromptTokens(lastAssistant.usage) : 0;
}
sendCompletionNotification(): void {
if (this.ctx.isBackgrounded === false) return;
const notify = settings.get("completion.notify");
@@ -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;
@@ -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<OAuthProvider>([
"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 };
});
}
}
@@ -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<void> {
@@ -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<void> {
return this.#commandController.handleBashCommand(command, excludeFromContext);
}
@@ -1359,6 +1395,7 @@ export class InteractiveMode implements InteractiveModeContext {
handleResumeSession(sessionPath: string): Promise<void> {
this.#btwController.dispose();
this.resetObserverRegistry();
return this.#selectorController.handleResumeSession(sessionPath);
}
@@ -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<string, ObservableSession["status"]> = {
started: "active",
completed: "completed",
failed: "failed",
aborted: "aborted",
};
export class SessionObserverRegistry {
#sessions = new Map<string, ObservableSession>();
#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();
}),
);
}
}
+2
View File
@@ -208,6 +208,8 @@ export interface InteractiveModeContext {
showOAuthSelector(mode: "login" | "logout", providerId?: string): Promise<void>;
showHookConfirm(title: string, message: string): Promise<boolean>;
showDebugSelector(): void;
showSessionObserver(): void;
resetObserverRegistry(): void;
// Input handling
handleCtrlC(): void;
@@ -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<T>(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<ImportSpec, "from" | "system">): string {
return `${spec.system ? "system" : "local"}:${spec.from}`;
}
function dedupeSpecs(specs: ImportSpec[], existing: Iterable<Pick<ImportSpec, "from" | "system">>): ImportSpec[] {
const seen = new Set<string>();
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<number, string[]>, 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<number, string[]>,
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<number, string[]>): 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<number, string[]>();
const groupedSpecs: Record<IncludeGroup, ImportSpec[]> = {
system: sortSpecs(specs.filter(spec => spec.system)),
local: sortSpecs(specs.filter(spec => !spec.system)),
};
const groupedEntries: Record<IncludeGroup, ParsedIncludeEntry[]> = {
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<number, string[]>();
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<number, string[]>();
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 };
@@ -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+(?:(?<alias>[._]|[A-Za-z_]\w*)\s+)?"(?<source>[^"]+)"(?:\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*(?:(?<alias>[._]|[A-Za-z_]\w*)\s+)?"(?<source>[^"]+)"(?:\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<GoImportGroup>();
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<string, GoImportEntry>();
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<string, GoImportEntry>();
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<GoImportGroup, GoImportEntry[]>();
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 };
@@ -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<Record<string, ImportHandler>> = {
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";
@@ -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<string>([
"__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<PythonImportStyle>).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<ImportGroup, ParsedPythonImport[]>();
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<string, ParsedPythonImport>();
const fromRecords = new Map<string, ParsedPythonImport>();
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<ImportGroup, ParsedPythonImport[]>();
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 };
@@ -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<RustImportGroup>();
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 = /^(?<path>.+?)(?:\s+as\s+(?<alias>[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 = /^(?<prefix>.+?)::\{(?<members>[^{}]+)\}$/.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>): 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<string, RustImportEntry>();
const aliaslessSimplePaths = new Set<string>();
const groupEntries = new Map<string, RustImportEntry>();
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<string, RustImportEntry>();
const addedGroupMembers = new Map<string, Set<string>>();
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<string>();
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 };
@@ -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,
};
File diff suppressed because it is too large Load Diff
+25 -168
View File
@@ -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<typeof replaceEditSchemaWithImports>;
export type PatchParams = Static<typeof patchEditSchemaWithImports>;
export type ReplaceParams = Static<typeof replaceEditSchema>;
export type PatchParams = Static<typeof patchEditSchema>;
/**
* 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<typeof hashlineEditSchema>;
export type HashlineParams = Static<typeof hashlineEditParamsSchemaWithImports>;
export type HashlineParams = Static<typeof hashlineEditParamsSchema>;
// ═══════════════════════════════════════════════════════════════════════════
// 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<TInput> {
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<TInput> {
}
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<TInput> {
* 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<TInput> {
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<TInput> {
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<TInput> {
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<TInput> {
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<TInput> {
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<TInput> {
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<TInput> {
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<TInput> {
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<TInput> {
);
}
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<TInput> {
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<TInput> {
: `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 },
};
}
}
-20
View File
@@ -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
// ═══════════════════════════════════════════════════════════════════════════
@@ -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}}
</operations>
</operations>
<examples>
All examples below reference the same file:
@@ -56,28 +42,6 @@ All examples below reference the same file:
{{hline 18 "}"}}
```
{{#if editManageImportsEnabled}}
<example name="replace a block body">
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.
</example>
{{else}}
<example name="replace a block body">
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
}
```
</example>
{{/if}}
<example name="replace whole block including closing brace">
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.
</critical>
</critical>
@@ -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}}
</instruction>
<parameters>
@@ -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 }
```
</parameters>
@@ -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.
</critical>
{{#if editManageImportsEnabled}}
<example name="update">
```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.
<example name="create">
edit {"path":"hello.txt","op":"create","diff":"Hello\n"}
</example>
<example name="typescript-mixed-import">
```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`.
<example name="update">
edit {"path":"src/app.py","op":"update","diff":"@@ def greet():\n def greet():\n-print('Hi')\n+print('Hello')\n"}
</example>
<example name="rename">
edit {"path":"src/app.py","op":"update","rename":"src/main.py","diff":"@@\n …\n"}
</example>
<example name="delete">
edit {"path":"obsolete.txt","op":"delete"}
</example>
{{/if}}
<avoid>
{{#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)
</avoid>
@@ -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}}
</instruction>
<parameters>
```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}}
};
```
</parameters>
<output>
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.
</output>
@@ -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.
</critical>
{{#if editManageImportsEnabled}}
<example name="typescript-helper">
```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.
</example>
<example name="namespace-import">
```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`.
</example>
{{/if}}
<bash-alternatives>
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:
+3
View File
@@ -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
@@ -1641,5 +1643,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
mcpManager,
modelFallbackMessage,
lspServers,
eventBus,
};
}
@@ -162,7 +162,7 @@ import { getLatestCompactionEntry } from "./session-manager";
/** Session-specific events that extend the core AgentEvent */
export type AgentSessionEvent =
| AgentEvent
| { type: "auto_compaction_start"; reason: "threshold" | "overflow"; action: "context-full" | "handoff" }
| { type: "auto_compaction_start"; reason: "threshold" | "overflow" | "idle"; action: "context-full" | "handoff" }
| {
type: "auto_compaction_end";
action: "context-full" | "handoff";
@@ -3661,6 +3661,12 @@ export class AgentSession {
this.#handoffAbortController?.abort();
}
/** Trigger idle compaction through the auto-compaction flow (with UI events). */
async runIdleCompaction(): Promise<void> {
if (this.isStreaming || this.isCompacting) return;
await this.#runAutoCompaction("idle", false, true);
}
/**
* Cancel in-progress branch summarization.
*/
@@ -4425,11 +4431,16 @@ export class AgentSession {
/**
* Internal: Run auto-compaction with events.
*/
async #runAutoCompaction(reason: "overflow" | "threshold", willRetry: boolean, deferred = false): Promise<void> {
async #runAutoCompaction(
reason: "overflow" | "threshold" | "idle",
willRetry: boolean,
deferred = false,
): Promise<void> {
const compactionSettings = this.settings.getGroup("compaction");
if (!compactionSettings.enabled || compactionSettings.strategy === "off") return;
if (compactionSettings.strategy === "off") return;
if (reason !== "idle" && !compactionSettings.enabled) return;
const generation = this.#promptGeneration;
if (!deferred && reason !== "overflow" && compactionSettings.strategy === "handoff") {
if (!deferred && reason !== "overflow" && reason !== "idle" && compactionSettings.strategy === "handoff") {
this.#schedulePostPromptTask(
async signal => {
await Promise.resolve();
@@ -4732,7 +4743,7 @@ export class AgentSession {
};
await this.#emitSessionEvent({ type: "auto_compaction_end", action, result, aborted: false, willRetry });
if (!willRetry && compactionSettings.autoContinue !== false) {
if (!willRetry && reason !== "idle" && compactionSettings.autoContinue !== false) {
const continuePrompt = async () => {
await this.#promptWithMessage(
{
@@ -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<SingleRes
task,
assignment,
progress: { ...progress },
sessionFile: subtaskSessionFile,
});
}
lastProgressEmitMs = Date.now();
@@ -983,6 +985,19 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
activeSession = session;
// Emit lifecycle start event
if (options.eventBus) {
options.eventBus.emit(TASK_SUBAGENT_LIFECYCLE_CHANNEL, {
id,
agent: agent.name,
agentSource: agent.source,
description: options.description,
status: "started",
sessionFile: subtaskSessionFile,
index,
});
}
const subagentToolNames = session.getActiveToolNames();
const parentOwnedToolNames = new Set(["todo_write"]);
const filteredSubagentTools = subagentToolNames.filter(name => !parentOwnedToolNames.has(name));
@@ -1238,6 +1253,19 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
progress.status = wasAborted ? "aborted" : exitCode === 0 ? "completed" : "failed";
scheduleProgress(true);
// Emit lifecycle end event after finalization so submit_result status is reflected
if (options.eventBus) {
options.eventBus.emit(TASK_SUBAGENT_LIFECYCLE_CHANNEL, {
id,
agent: agent.name,
agentSource: agent.source,
description: options.description,
status: progress.status as "completed" | "failed" | "aborted",
sessionFile: subtaskSessionFile,
index,
});
}
return {
index,
id,
+89 -68
View File
@@ -109,8 +109,21 @@ export { loadBundledAgents as BUNDLED_AGENTS } from "./agents";
export { discoverCommands, expandCommand, getCommand } from "./commands";
export { discoverAgents, getAgent } from "./discovery";
export { AgentOutputManager } from "./output-manager";
export type { AgentDefinition, AgentProgress, SingleResult, TaskParams, TaskToolDetails } from "./types";
export { taskSchema } from "./types";
export type {
AgentDefinition,
AgentProgress,
SingleResult,
SubagentLifecyclePayload,
SubagentProgressPayload,
TaskParams,
TaskToolDetails,
} from "./types";
export {
TASK_SUBAGENT_EVENT_CHANNEL,
TASK_SUBAGENT_LIFECYCLE_CHANNEL,
TASK_SUBAGENT_PROGRESS_CHANNEL,
taskSchema,
} from "./types";
/**
* Render the tool description from a cached agent list and current settings.
@@ -766,7 +779,7 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
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<TaskSchema, TaskToolDetails, Theme> {
contextFile: contextFilePath,
enableLsp: false,
signal,
eventBus: undefined,
eventBus: this.session.eventBus,
onProgress: progress => {
progressMap.set(index, {
...structuredClone(progress),
@@ -978,82 +991,90 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
let changesApplied: boolean | null = null;
let mergedBranchesForNestedPatches: Set<string> | null = null;
if (isIsolated && repoRoot) {
if (mergeMode === "branch") {
// Branch mode: merge task branches sequentially
const branchEntries = results
.filter(r => r.branchName && r.exitCode === 0 && !r.aborted)
.map(r => ({ branchName: r.branchName!, taskId: r.id, description: r.description }));
try {
if (mergeMode === "branch") {
// Branch mode: merge task branches sequentially
const branchEntries = results
.filter(r => r.branchName && r.exitCode === 0 && !r.aborted)
.map(r => ({ branchName: r.branchName!, taskId: r.id, description: r.description }));
if (branchEntries.length === 0) {
changesApplied = true;
} else {
const mergeResult = await mergeTaskBranches(repoRoot, branchEntries);
mergedBranchesForNestedPatches = new Set(mergeResult.merged);
changesApplied = mergeResult.failed.length === 0;
if (changesApplied) {
mergeSummary = `\n\nMerged ${mergeResult.merged.length} branch${mergeResult.merged.length === 1 ? "" : "es"}: ${mergeResult.merged.join(", ")}`;
} else {
const mergedPart =
mergeResult.merged.length > 0 ? `Merged: ${mergeResult.merged.join(", ")}.\n` : "";
const failedPart = `Failed: ${mergeResult.failed.join(", ")}.`;
const conflictPart = mergeResult.conflict ? `\nConflict: ${mergeResult.conflict}` : "";
mergeSummary = `\n\n<system-notification>Branch merge failed. ${mergedPart}${failedPart}${conflictPart}\nUnmerged branches remain for manual resolution.</system-notification>`;
}
}
// Clean up merged branches (keep failed ones for manual resolution)
const allBranches = branchEntries.map(b => b.branchName);
if (changesApplied) {
await cleanupTaskBranches(repoRoot, allBranches);
}
} else {
// Patch mode: combine and apply patches
const patchesInOrder = results.map(result => result.patchPath).filter(Boolean) as string[];
const missingPatch = results.some(result => !result.patchPath);
if (missingPatch) {
changesApplied = false;
} else {
const patchStats = await Promise.all(
patchesInOrder.map(async patchPath => ({
patchPath,
size: (await fs.stat(patchPath)).size,
})),
);
const nonEmptyPatches = patchStats.filter(patch => patch.size > 0).map(patch => patch.patchPath);
if (nonEmptyPatches.length === 0) {
if (branchEntries.length === 0) {
changesApplied = true;
} else {
const patchTexts = await Promise.all(
nonEmptyPatches.map(async patchPath => Bun.file(patchPath).text()),
const mergeResult = await mergeTaskBranches(repoRoot, branchEntries);
mergedBranchesForNestedPatches = new Set(mergeResult.merged);
changesApplied = mergeResult.failed.length === 0;
if (changesApplied) {
mergeSummary = `\n\nMerged ${mergeResult.merged.length} branch${mergeResult.merged.length === 1 ? "" : "es"}: ${mergeResult.merged.join(", ")}`;
} else {
const mergedPart =
mergeResult.merged.length > 0 ? `Merged: ${mergeResult.merged.join(", ")}.\n` : "";
const failedPart = `Failed: ${mergeResult.failed.join(", ")}.`;
const conflictPart = mergeResult.conflict ? `\nConflict: ${mergeResult.conflict}` : "";
mergeSummary = `\n\n<system-notification>Branch merge failed. ${mergedPart}${failedPart}${conflictPart}\nUnmerged branches remain for manual resolution.</system-notification>`;
}
}
// Clean up merged branches (keep failed ones for manual resolution)
const allBranches = branchEntries.map(b => b.branchName);
if (changesApplied) {
await cleanupTaskBranches(repoRoot, allBranches);
}
} else {
// Patch mode: combine and apply patches
const patchesInOrder = results.map(result => result.patchPath).filter(Boolean) as string[];
const missingPatch = results.some(result => !result.patchPath);
if (missingPatch) {
changesApplied = false;
} else {
const patchStats = await Promise.all(
patchesInOrder.map(async patchPath => ({
patchPath,
size: (await fs.stat(patchPath)).size,
})),
);
const combinedPatch = patchTexts.map(text => (text.endsWith("\n") ? text : `${text}\n`)).join("");
if (!combinedPatch.trim()) {
const nonEmptyPatches = patchStats.filter(patch => patch.size > 0).map(patch => patch.patchPath);
if (nonEmptyPatches.length === 0) {
changesApplied = true;
} else {
changesApplied = await git.patch.canApplyText(repoRoot, combinedPatch);
if (changesApplied) {
try {
await git.patch.applyText(repoRoot, combinedPatch);
} catch {
changesApplied = false;
const patchTexts = await Promise.all(
nonEmptyPatches.map(async patchPath => Bun.file(patchPath).text()),
);
const combinedPatch = patchTexts
.map(text => (text.endsWith("\n") ? text : `${text}\n`))
.join("");
if (!combinedPatch.trim()) {
changesApplied = true;
} else {
changesApplied = await git.patch.canApplyText(repoRoot, combinedPatch);
if (changesApplied) {
try {
await git.patch.applyText(repoRoot, combinedPatch);
} catch {
changesApplied = false;
}
}
}
}
}
}
if (changesApplied) {
mergeSummary = "\n\nApplied patches: yes";
} else {
const notification =
"<system-notification>Patches were not applied and must be handled manually.</system-notification>";
const patchList =
patchPaths.length > 0
? `\n\nPatch artifacts:\n${patchPaths.map(patch => `- ${patch}`).join("\n")}`
: "";
mergeSummary = `\n\n${notification}${patchList}`;
if (changesApplied) {
mergeSummary = "\n\nApplied patches: yes";
} else {
const notification =
"<system-notification>Patches were not applied and must be handled manually.</system-notification>";
const patchList =
patchPaths.length > 0
? `\n\nPatch artifacts:\n${patchPaths.map(patch => `- ${patch}`).join("\n")}`
: "";
mergeSummary = `\n\n${notification}${patchList}`;
}
}
} catch (mergeErr) {
const msg = mergeErr instanceof Error ? mergeErr.message : String(mergeErr);
changesApplied = false;
mergeSummary = `\n\n<system-notification>Merge phase failed: ${msg}\nTask outputs are preserved but changes were not applied.</system-notification>`;
}
}
+25
View File
@@ -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({
+52 -20
View File
@@ -515,29 +515,61 @@ export async function mergeTaskBranches(
const merged: string[] = [];
const failed: string[] = [];
for (const { branchName } of branches) {
try {
await git.cherryPick(repoRoot, branchName);
} catch (err) {
await git.cherryPick.abort(repoRoot);
const stderr =
err instanceof git.GitCommandError
? err.result.stderr.trim()
: err instanceof Error
? err.message
: String(err);
failed.push(branchName);
return {
merged,
failed: [...failed, ...branches.slice(merged.length + failed.length).map(b => b.branchName)],
conflict: `${branchName}: ${stderr}`,
};
}
// Stash dirty working tree so cherry-pick can operate on a clean HEAD.
// Without this, cherry-pick refuses to run when uncommitted changes exist.
const didStash = await git.stash.push(repoRoot, "omp-task-merge");
merged.push(branchName);
let conflictResult: MergeBranchResult | undefined;
try {
for (const { branchName } of branches) {
try {
await git.cherryPick(repoRoot, branchName);
} catch (err) {
try {
await git.cherryPick.abort(repoRoot);
} catch {
/* no state to abort */
}
const stderr =
err instanceof git.GitCommandError
? err.result.stderr.trim()
: err instanceof Error
? err.message
: String(err);
failed.push(branchName);
conflictResult = {
merged,
failed: [...failed, ...branches.slice(merged.length + failed.length).map(b => b.branchName)],
conflict: `${branchName}: ${stderr}`,
};
break;
}
merged.push(branchName);
}
} finally {
if (didStash) {
try {
await git.stash.pop(repoRoot);
} catch {
// Stash-pop conflicts mean the replayed changes clash with the user's
// uncommitted edits. Treat this as a merge failure so the caller preserves
// recovery branches instead of reporting success and deleting them.
logger.warn("Failed to restore stashed changes after task merge; stash entry preserved");
if (!conflictResult) {
conflictResult = {
merged,
failed: merged,
conflict:
"stash pop: cherry-picked changes conflict with uncommitted edits. Run `git stash pop` and resolve manually.",
};
}
}
}
}
return { merged, failed };
return conflictResult ?? { merged, failed };
}
/** Clean up temporary task branches. */
+3 -2
View File
@@ -2177,12 +2177,13 @@ export class GhPrCheckoutTool implements AgentTool<typeof ghPrCheckoutSchema, Gh
await fs.mkdir(path.dirname(finalWorktreePath), { recursive: true });
await git.worktree.add(repoRoot, finalWorktreePath, localBranch, { signal });
}
const resolvedWorktreePath = await fs.realpath(finalWorktreePath);
return buildTextResult(
formatPrCheckoutResult({
data,
localBranch,
worktreePath: finalWorktreePath,
worktreePath: resolvedWorktreePath,
remoteName: remote.name,
remoteUrl: remote.url,
reused: Boolean(existingWorktree),
@@ -2191,7 +2192,7 @@ export class GhPrCheckoutTool implements AgentTool<typeof ghPrCheckoutSchema, Gh
{
repo: repo ?? data.headRepository?.nameWithOwner,
branch: localBranch,
worktreePath: finalWorktreePath,
worktreePath: resolvedWorktreePath,
remote: remote.name,
remoteBranch: headRefName,
},
+20
View File
@@ -1110,6 +1110,26 @@ export const cherryPick = Object.assign(
},
);
// ════════════════════════════════════════════════════════════════════════════
// API: stash
// ════════════════════════════════════════════════════════════════════════════
export const stash = {
/** Stash working tree + index changes. Returns true if something was stashed. */
async push(cwd: string, message?: string): Promise<boolean> {
ensureAvailable();
const args = ["stash", "push", "--include-untracked"];
if (message) args.push("-m", message);
const result = await runCommand(cwd, args);
// git stash push exits 0 whether or not it stashed; check output
return result.exitCode === 0 && !result.stdout.includes("No local changes to save");
},
/** Pop the most recent stash entry. */
async pop(cwd: string): Promise<void> {
await runEffect(cwd, ["stash", "pop"]);
},
};
// ════════════════════════════════════════════════════════════════════════════
// API: clone, restore, clean
// ════════════════════════════════════════════════════════════════════════════
@@ -188,7 +188,7 @@ export function renderSearchResult(
: typeof src.url === "string" && src.url.trim()
? src.url
: "Untitled";
const title = truncateToWidth(titleText, 70);
const title = truncateToWidth(titleText, MAX_SNIPPET_LINE_LEN);
const url = typeof src.url === "string" ? src.url : "";
const domain = url ? getDomain(url) : "";
const age =
@@ -196,11 +196,13 @@ export function renderSearchResult(
const metaParts: string[] = [];
if (domain) metaParts.push(theme.fg("dim", `(${domain})`));
if (typeof src.author === "string" && src.author.trim())
metaParts.push(theme.fg("muted", src.author));
metaParts.push(theme.fg("muted", truncateToWidth(src.author.trim(), 40)));
if (age) metaParts.push(theme.fg("muted", age));
const metaSep = theme.fg("dim", theme.sep.dot);
const metaSuffix = metaParts.length > 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;
},
},
@@ -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<typeof git.status>[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[]) => {
@@ -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(),
@@ -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);
@@ -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 <vector>", "", '#include "widget/detail.h"', "", "class Widget {};", ""].join(
"\n",
),
);
expect(result.added).toEqual(["#include <vector>", '#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 <string>", "", '#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 <string>",
"#include <vector>",
"",
'#include "app/bar.h"',
'#include "app/foo.h"',
"",
"int main();",
"",
].join("\n"),
);
expect(result.added).toEqual(["#include <vector>", '#include "app/bar.h"']);
expect(result.warnings).toEqual([]);
});
});
@@ -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([]);
});
});
@@ -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<string, unknown> {
const schema = tool.parameters as unknown as { properties?: Record<string, unknown> };
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");
});
});
@@ -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: [],
});
});
});
@@ -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: [],
});
});
});
@@ -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: [],
});
});
});
@@ -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(),
};
}