Merge branch 'main' into feat/idle-compaction
This commit is contained in:
@@ -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.
|
||||
@@ -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
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
|
||||
- Added idle auto-compaction settings and scheduling so sessions can compact after inactive turns without auto-continuing.
|
||||
- Added `edit.manageImports` setting to enable/disable post-edit import and include management
|
||||
- Added import/include management for TypeScript, JavaScript, Python, Rust, Go, and C/C++ in edit operations
|
||||
- Added `imports` parameter to patch, hashline, and replace edit operations to declare imports/includes needed after code changes
|
||||
- Added `ImportSpec` type to specify structured import requests with support for named imports, default imports, namespace imports, aliases, and system includes
|
||||
- Added comprehensive import handler modules for each supported language with deduplication and grouping logic
|
||||
- Added `edit.manageImports` setting to enable/disable post-edit import and include management
|
||||
- Added import/include management for TypeScript, JavaScript, Python, Rust, Go, and C/C++ in edit operations
|
||||
- Added `imports` parameter to patch, hashline, and replace edit operations to declare imports/includes needed after code changes
|
||||
- Added `ImportSpec` type to specify structured import requests with support for named imports, default imports, namespace imports, aliases, and system includes
|
||||
- Added comprehensive import handler modules for each supported language with deduplication and grouping logic
|
||||
- Added `onExternalEditor` callback to extension UI dialog options for handling external editor shortcut in select dialogs
|
||||
- Added external editor shortcut support in plan review selector, allowing users to open and edit the plan in their configured editor
|
||||
- Added `matchesAppExternalEditor` keybinding matcher to detect external editor shortcut (Ctrl+G or configured binding)
|
||||
@@ -24,9 +16,6 @@
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated edit tool prompts to document import management feature when enabled
|
||||
- Modified edit tool schemas to conditionally include `imports` parameter based on `edit.manageImports` setting
|
||||
- Enhanced patch, hashline, and replace tool documentation with import management examples and guidance
|
||||
- Updated hook editor and hook selector components to use `matchesAppExternalEditor` matcher for consistent external editor keybinding detection
|
||||
- Modified plan review flow to read the latest plan content from disk before approval, allowing changes made in external editor to be reflected
|
||||
- Enhanced plan review help text to dynamically display the configured external editor keybinding
|
||||
@@ -47,6 +36,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 +394,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;
|
||||
}
|
||||
|
||||
@@ -992,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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -229,6 +229,8 @@ export interface CreateAgentSessionResult {
|
||||
modelFallbackMessage?: string;
|
||||
/** LSP servers that were warmed up at startup */
|
||||
lspServers?: Array<{ name: string; status: "ready" | "error"; fileTypes: string[]; error?: string }>;
|
||||
/** Shared event bus for tool/extension communication */
|
||||
eventBus: EventBus;
|
||||
}
|
||||
|
||||
// Re-exports
|
||||
@@ -1639,5 +1641,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
mcpManager,
|
||||
modelFallbackMessage,
|
||||
lspServers,
|
||||
eventBus,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user