From ab6f2111eed48c8197ea62a87318b425ab933be2 Mon Sep 17 00:00:00 2001
From: maguowei
Date: Sun, 16 Aug 2026 18:37:51 +0800
Subject: [PATCH 1/6] =?UTF-8?q?feat(provider):=20=E6=96=B0=E5=A2=9E=20Open?=
=?UTF-8?q?Code=20Go=20=E5=86=85=E7=BD=AE=E4=BE=9B=E5=BA=94=E5=95=86?=
=?UTF-8?q?=E5=B9=B6=E6=8C=89=20slug=20=E5=88=87=E6=8D=A2=E8=AE=A4?=
=?UTF-8?q?=E8=AF=81=E5=AD=97=E6=AE=B5=E4=B8=BA=20ANTHROPIC=5FAPI=5FKEY?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude
---
.claude/rules/config-system.md | 2 +-
.../0005-provider-auth-frontend-hardcode.md | 24 +++++++
docs/user-manual.md | 2 +-
docs/user-manual.zh-CN.md | 2 +-
src-tauri/resources/builtin-providers.json | 20 ++++++
src-tauri/src/config.rs | 40 +++++++++++
src/components/ProfileEditor.tsx | 37 ++++++++--
.../__tests__/ProfileEditor.test.tsx | 70 +++++++++++++++++++
.../__tests__/config-workspace-utils.test.ts | 44 ++++++++++++
src/components/config-workspace-utils.ts | 8 ++-
src/i18n.ts | 4 ++
11 files changed, 242 insertions(+), 11 deletions(-)
create mode 100644 docs/adr/0005-provider-auth-frontend-hardcode.md
diff --git a/.claude/rules/config-system.md b/.claude/rules/config-system.md
index cb77ada..01cddc3 100644
--- a/.claude/rules/config-system.md
+++ b/.claude/rules/config-system.md
@@ -48,7 +48,7 @@ paths:
## 内置 Provider
-- 内置 Provider 维护在 `src-tauri/resources/builtin-providers.json`,是唯一供应商来源(不支持自定义),当前覆盖 Anthropic、DeepSeek、智谱 GLM、Kimi、MiniMax、小米 MiMo、OpenRouter、火山方舟、万界方舟和 Ollama。
+- 内置 Provider 维护在 `src-tauri/resources/builtin-providers.json`,是唯一供应商来源(不支持自定义),当前覆盖 Anthropic、DeepSeek、智谱 GLM、Kimi、MiniMax、小米 MiMo、OpenRouter、火山方舟、万界方舟、OpenCode Go 和 Ollama。
- 新增 provider 时同步 `localizedName`、`slug`、`baseUrl`、`docUrl` 和模型 `category`。
- 配置编辑器的环境变量自动填充逻辑要覆盖默认 model 字段:`ANTHROPIC_MODEL`、`ANTHROPIC_DEFAULT_OPUS_MODEL`、`ANTHROPIC_DEFAULT_SONNET_MODEL`、`ANTHROPIC_DEFAULT_HAIKU_MODEL`、`CLAUDE_CODE_SUBAGENT_MODEL`。
diff --git a/docs/adr/0005-provider-auth-frontend-hardcode.md b/docs/adr/0005-provider-auth-frontend-hardcode.md
new file mode 100644
index 0000000..382a4c6
--- /dev/null
+++ b/docs/adr/0005-provider-auth-frontend-hardcode.md
@@ -0,0 +1,24 @@
+# opencode-go 认证字段切换:前端按供应商 slug 硬编码,不建模进 provider 数据层
+
+## Context
+
+opencode-go 是 Anthropic 兼容网关(端点 `https://opencode.ai/zen/go`),用 `x-api-key` 认证,对应 Claude Code 的 `ANTHROPIC_API_KEY`;而默认认证区展示的是 `ANTHROPIC_AUTH_TOKEN`(Bearer)。
+
+两条前置约束:
+- Provider 数据层(`builtin-providers.json`)只承载供应商**客观信息**——连接地址 `env.ANTHROPIC_BASE_URL`、模型映射与元数据,**不含认证密钥、不含认证方式**;认证密钥属于 Profile 的 `settings.env`。
+- Claude Code 的认证语义是 `ANTHROPIC_AUTH_TOKEN`(Bearer)**优先**、`ANTHROPIC_API_KEY`(x-api-key)回退;后端 `resolve_model_test_request` 已按此实现,前端认证字段默认也只对应 `ANTHROPIC_AUTH_TOKEN`。
+
+因此 opencode-go 这类"需要 x-api-key 认证"的供应商,其认证字段与默认 UI 冲突:若仍显示 `ANTHROPIC_AUTH_TOKEN`,用户填进去的 key 会被按 Bearer 发送,认证失败。
+
+## Decision
+
+1. **前端按 slug 硬编码切换**:`ProfileEditor.tsx` 用 `providerSlugFromId(providerId) === "opencode-go"` 判定,命中时认证区字段由 `ANTHROPIC_AUTH_TOKEN` 切换为 `ANTHROPIC_API_KEY`(label / placeholder / value / onChange 全部联动),并把 `ANTHROPIC_API_KEY` 追加进 `hiddenEnvKeys` 从通用环境变量分区隐藏。
+2. **切换时清理互斥残留**:`applyProviderAutofill` 切到可解析的 opencode-go 时,在清空地址之外再置空 `ANTHROPIC_AUTH_TOKEN`——否则残留的 Bearer token 会被"Bearer 优先"语义遮蔽用户新填的 API Key,且两者都被隐藏、用户无从察觉。
+3. **坚持"Bearer 优先、API_KEY 回退"不变式**:后端 `resolve_model_test_request` 保持通用回退,不感知具体供应商;前端只做展示层切换,不复制该认证选择逻辑。
+4. **不把认证方式建模进 provider 数据层**:暂不引入 `authScheme` / `credentialEnvKey` 之类的 provider 字段。理由:当前仅 opencode-go 一个特例,数据层建模的收益尚未覆盖其同步成本(前端 schema、后端解析、契约、测试)。
+
+## Consequences
+
+- **硬编码特例会随供应商增加而扩散**:`=== "opencode-go"` 散落在 `ProfileEditor.tsx`、`config-workspace-utils.ts` 与测试。出现第二个 x-api-key(或其它非 Bearer)供应商时,应重新评估把认证方式建模进 provider 数据层,并回看本 ADR。
+- **清理是单向的**:只清"切向 opencode-go"方向的 `ANTHROPIC_AUTH_TOKEN`;切走时保留 `ANTHROPIC_API_KEY`——x-api-key 是 Anthropic 兼容通用认证,切走后 Bearer 优先时它不遮蔽任何东西,属无害保留。
+- **后端不感知供应商**:认证回退语义与真实 Claude Code 保持一致,新增供应商无需改后端认证逻辑;后端"Bearer 优先"不变式成为前端切换与残留清理的共同依据。
diff --git a/docs/user-manual.md b/docs/user-manual.md
index b252c05..304de10 100644
--- a/docs/user-manual.md
+++ b/docs/user-manual.md
@@ -143,7 +143,7 @@ The scheme is registered for packaged installs on macOS / Windows / Linux; Linux
## Providers
-Providers are all built-in and read-only. They carry only objective provider information (the connection endpoint `ANTHROPIC_BASE_URL`, the model mapping, and optional additional environment variables) and contain no authentication keys. They currently cover Anthropic, DeepSeek, Zhipu GLM Coding Plan, Kimi Code Plan, MiniMax Token Plan, Xiaomi MiMo Token Plan, OpenRouter, Volcengine Ark Coding Plan, Alibaba Cloud Bailian Coding Plan, Wanjie Ark, and Ollama.
+Providers are all built-in and read-only. They carry only objective provider information (the connection endpoint `ANTHROPIC_BASE_URL`, the model mapping, and optional additional environment variables) and contain no authentication keys. They currently cover Anthropic, DeepSeek, Zhipu GLM Coding Plan, Kimi Code Plan, MiniMax Token Plan, Xiaomi MiMo Token Plan, OpenRouter, Volcengine Ark Coding Plan, Wanjie Ark, OpenCode Go, and Ollama.
Custom providers are not supported. After you select a built-in provider under the "Provider" option in the configuration editor, its connection endpoint and model mapping are filled in automatically; you only need to add the authentication key and behavior settings. Clicking "View built-in providers" below that option opens a read-only overview where you can see each provider's name, ID, API endpoint, official documentation link, and recommended models.
diff --git a/docs/user-manual.zh-CN.md b/docs/user-manual.zh-CN.md
index da690c3..8a3949b 100644
--- a/docs/user-manual.zh-CN.md
+++ b/docs/user-manual.zh-CN.md
@@ -143,7 +143,7 @@ macOS / Windows / Linux 在安装包场景下均可注册 scheme;开发态下 Li
## 供应商 Provider
-供应商均为内置且只读,只承载供应商客观信息(连接地址 `ANTHROPIC_BASE_URL`、模型映射与可选附加环境变量),不含认证密钥。当前覆盖 Anthropic、DeepSeek、智谱 GLM Coding Plan、Kimi Code Plan、MiniMax Token Plan、小米 MiMo Token Plan、OpenRouter、火山方舟 Coding Plan、万界方舟和 Ollama。
+供应商均为内置且只读,只承载供应商客观信息(连接地址 `ANTHROPIC_BASE_URL`、模型映射与可选附加环境变量),不含认证密钥。当前覆盖 Anthropic、DeepSeek、智谱 GLM Coding Plan、Kimi Code Plan、MiniMax Token Plan、小米 MiMo Token Plan、OpenRouter、火山方舟 Coding Plan、万界方舟、OpenCode Go 和 Ollama。
不支持自定义供应商。在配置编辑器的「供应商」选项处选择一个内置供应商后,其连接地址与模型映射会自动带入;你只需补充认证密钥与行为设置。点击该选项下方的「查看内置供应商」可打开只读一览,查看每个供应商的名称、ID、API 地址、官方文档链接和推荐模型。
diff --git a/src-tauri/resources/builtin-providers.json b/src-tauri/resources/builtin-providers.json
index b53377f..03b0975 100644
--- a/src-tauri/resources/builtin-providers.json
+++ b/src-tauri/resources/builtin-providers.json
@@ -114,6 +114,26 @@
"docUrl": "https://openrouter.ai/docs/guides/coding-agents/claude-code-integration",
"models": []
},
+ {
+ "name": "OpenCode Go",
+ "localizedName": { "zh": "OpenCode Go", "en": "OpenCode Go" },
+ "slug": "opencode-go",
+ "baseUrl": "https://opencode.ai/zen/go",
+ "docUrl": "https://opencode.ai/docs/zh-cn/go",
+ "env": {
+ "ANTHROPIC_MODEL": "deepseek-v4-pro[1m]",
+ "ANTHROPIC_DEFAULT_OPUS_MODEL": "deepseek-v4-pro[1m]",
+ "ANTHROPIC_DEFAULT_SONNET_MODEL": "deepseek-v4-flash[1m]",
+ "ANTHROPIC_DEFAULT_HAIKU_MODEL": "deepseek-v4-flash",
+ "CLAUDE_CODE_SUBAGENT_MODEL": "deepseek-v4-flash"
+ },
+ "models": [
+ { "id": "deepseek-v4-pro[1m]", "name": "DeepSeek V4 Pro" },
+ { "id": "deepseek-v4-flash[1m]", "name": "DeepSeek V4 Flash" },
+ { "id": "minimax-m3", "name": "MiniMax M3" },
+ { "id": "qwen3.8-max", "name": "Qwen3.8 Max" }
+ ]
+ },
{
"name": "Ollama",
"localizedName": { "zh": "Ollama", "en": "Ollama" },
diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs
index 5c805d9..1d2a40f 100644
--- a/src-tauri/src/config.rs
+++ b/src-tauri/src/config.rs
@@ -3189,6 +3189,46 @@ mod tests {
assert!(!env.contains_key("ANTHROPIC_AUTH_TOKEN"));
}
+ #[test]
+ fn builtin_providers_include_opencode_go_claude_code_env() {
+ let opencode_go = builtin_providers()
+ .iter()
+ .find(|provider| provider.id == "builtin:opencode-go")
+ .unwrap();
+ let env = &opencode_go.env;
+
+ assert_eq!(opencode_go.name, "OpenCode Go");
+ assert_eq!(
+ opencode_go.doc_url,
+ Some("https://opencode.ai/docs/zh-cn/go".to_string())
+ );
+ assert_eq!(
+ opencode_go.model_suggestions,
+ vec![
+ "deepseek-v4-pro[1m]".to_string(),
+ "deepseek-v4-flash[1m]".to_string(),
+ "minimax-m3".to_string(),
+ "qwen3.8-max".to_string()
+ ]
+ );
+ assert_eq!(
+ env.get("ANTHROPIC_BASE_URL"),
+ Some(&Value::String("https://opencode.ai/zen/go".to_string()))
+ );
+ assert_eq!(
+ env.get("ANTHROPIC_MODEL"),
+ Some(&Value::String("deepseek-v4-pro[1m]".to_string()))
+ );
+ assert_eq!(
+ env.get("ANTHROPIC_DEFAULT_HAIKU_MODEL"),
+ Some(&Value::String("deepseek-v4-flash".to_string()))
+ );
+ assert_eq!(
+ env.get("CLAUDE_CODE_SUBAGENT_MODEL"),
+ Some(&Value::String("deepseek-v4-flash".to_string()))
+ );
+ }
+
#[test]
fn resolve_profile_settings_merges_provider_env_then_profile_overrides() {
// 供应商只提供 env(地址 + 模型映射),无继承;这里用内置 DeepSeek 供应商
diff --git a/src/components/ProfileEditor.tsx b/src/components/ProfileEditor.tsx
index 394befa..15435d5 100644
--- a/src/components/ProfileEditor.tsx
+++ b/src/components/ProfileEditor.tsx
@@ -228,7 +228,15 @@ const ProfileEditor = forwardRef(functi
allowedKeys: COMMON_JSON_ALLOWED_KEYS,
});
const envObject = useMemo(() => readTopLevelObject(settings, "env"), [settings]);
- const hiddenEnvKeys = useMemo(() => [...AUTH_ENV_KEYS, ...COMMON_ENV_SETTINGS_KEYS], []);
+ // opencode-go 通过 ANTHROPIC_API_KEY(x-api-key)认证,认证区切换为 API Key 字段
+ const usesApiKeyAuth = providerSlugFromId(providerId) === "opencode-go";
+ const hiddenEnvKeys = useMemo(() => {
+ const authKeys: string[] = [...AUTH_ENV_KEYS];
+ if (usesApiKeyAuth) {
+ authKeys.push("ANTHROPIC_API_KEY");
+ }
+ return [...authKeys, ...COMMON_ENV_SETTINGS_KEYS];
+ }, [usesApiKeyAuth]);
const hiddenEnvEntries = useMemo(
() => buildHiddenEnvEntries(envObject, hiddenEnvKeys),
[envObject, hiddenEnvKeys],
@@ -743,6 +751,8 @@ const ProfileEditor = forwardRef(functi
openProviderDocs: t("providers.actions.openDocs"),
authToken: t("profiles.editor.fields.authToken"),
authTokenEnv: t("profiles.editor.fields.authTokenEnv"),
+ authApiKey: t("profiles.editor.fields.authApiKey"),
+ authApiKeyEnv: t("profiles.editor.fields.authApiKeyEnv"),
showAuthToken: t("common.showToken"),
hideAuthToken: t("common.hideToken"),
baseUrl: t("profiles.editor.fields.baseUrl"),
@@ -952,18 +962,31 @@ const ProfileEditor = forwardRef(functi
-
- {messages.authTokenEnv}
+
+
+ {usesApiKeyAuth ? messages.authApiKeyEnv : messages.authTokenEnv}
+
- applySettings(setEnvString(settings, "ANTHROPIC_AUTH_TOKEN", value))
+ applySettings(
+ setEnvString(
+ settings,
+ usesApiKeyAuth ? "ANTHROPIC_API_KEY" : "ANTHROPIC_AUTH_TOKEN",
+ value,
+ ),
+ )
}
/>
diff --git a/src/components/__tests__/ProfileEditor.test.tsx b/src/components/__tests__/ProfileEditor.test.tsx
index 84fc6e8..807b76d 100644
--- a/src/components/__tests__/ProfileEditor.test.tsx
+++ b/src/components/__tests__/ProfileEditor.test.tsx
@@ -4347,4 +4347,74 @@ describe("ProfileEditor", () => {
expect(saved.settings.env).not.toHaveProperty("ENABLE_TOOL_SEARCH");
expect(saved.settings.env).not.toHaveProperty("CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS");
});
+
+ function opencodeGoProvider(): Provider {
+ return {
+ id: "builtin:opencode-go",
+ name: "OpenCode Go",
+ localizedName: { zh: "OpenCode Go", en: "OpenCode Go" },
+ description: "OpenCode Go",
+ modelSuggestions: ["deepseek-v4-pro", "deepseek-v4-flash"],
+ env: {
+ ANTHROPIC_BASE_URL: "https://opencode.ai/zen/go",
+ ANTHROPIC_MODEL: "deepseek-v4-flash",
+ },
+ };
+ }
+
+ function opencodeGoProfile(settings: Record): ConfigProfile {
+ return {
+ id: "user-opencode-go",
+ name: "OpenCode Go User",
+ description: "",
+ providerId: "builtin:opencode-go",
+ settings,
+ createdAt: "2026-08-16T12:00:00Z",
+ updatedAt: "2026-08-16T12:00:00Z",
+ };
+ }
+
+ it("opencode-go 供应商认证区切换到 ANTHROPIC_API_KEY 字段", () => {
+ renderEditor({
+ providers: [opencodeGoProvider()],
+ profile: opencodeGoProfile({ env: { ANTHROPIC_API_KEY: "sk-test" } }),
+ });
+
+ const authSection = getSection("认证");
+ expect(within(authSection).getByLabelText("ANTHROPIC_API_KEY")).toHaveValue("sk-test");
+ expect(within(authSection).queryByLabelText("ANTHROPIC_AUTH_TOKEN")).not.toBeInTheDocument();
+ });
+
+ it("opencode-go 认证密钥变更写入 ANTHROPIC_API_KEY", async () => {
+ const onSave = vi.fn();
+ renderEditor({
+ onSave,
+ providers: [opencodeGoProvider()],
+ profile: opencodeGoProfile({ env: { ANTHROPIC_API_KEY: "sk-old" } }),
+ });
+
+ fireEvent.change(screen.getByLabelText("ANTHROPIC_API_KEY"), {
+ target: { value: "sk-new" },
+ });
+
+ await act(async () => {
+ fireEvent.click(screen.getByRole("button", { name: "保存" }));
+ await Promise.resolve();
+ });
+
+ expect(onSave.mock.calls[0][0].settings.env).toMatchObject({
+ ANTHROPIC_API_KEY: "sk-new",
+ });
+ });
+
+ it("opencode-go 下 ANTHROPIC_API_KEY 从通用环境变量分区隐藏", () => {
+ renderEditor({
+ providers: [opencodeGoProvider()],
+ profile: opencodeGoProfile({ env: { ANTHROPIC_API_KEY: "sk-test" } }),
+ });
+
+ toggleAccordionSection("环境变量");
+ const envSection = getSection("环境变量");
+ expect(within(envSection).queryByText("ANTHROPIC_API_KEY")).not.toBeInTheDocument();
+ });
});
diff --git a/src/components/__tests__/config-workspace-utils.test.ts b/src/components/__tests__/config-workspace-utils.test.ts
index 1cee440..6107b0f 100644
--- a/src/components/__tests__/config-workspace-utils.test.ts
+++ b/src/components/__tests__/config-workspace-utils.test.ts
@@ -237,6 +237,50 @@ describe("config-workspace-utils preset autofill", () => {
// 无可解析 provider 时不动任何字段(含地址)
expect(applyProviderAutofill(seededSettings, PRESETS, undefined)).toEqual(seededSettings);
});
+
+ it("切到 opencode-go 时清掉残留的 ANTHROPIC_AUTH_TOKEN(避免 Bearer 优先遮蔽 API Key)", () => {
+ const opencodeGoProviders: Provider[] = [
+ {
+ id: "builtin:opencode-go",
+ name: "OpenCode Go",
+ description: "OpenCode Go 供应商",
+ localizedName: { zh: "OpenCode Go", en: "OpenCode Go" },
+ models: [],
+ modelSuggestions: [],
+ env: { ANTHROPIC_BASE_URL: "https://opencode.ai/zen/go" },
+ },
+ ];
+ const staleSettings = {
+ env: {
+ ANTHROPIC_AUTH_TOKEN: "stale-token",
+ OTHER_ENV: "keep-me",
+ },
+ };
+
+ // 切到 opencode-go:地址与旧认证 token 一并清空,无关 env 保留
+ expect(
+ applyProviderAutofill(staleSettings, opencodeGoProviders, "builtin:opencode-go"),
+ ).toEqual({ env: { OTHER_ENV: "keep-me" } });
+ });
+
+ it("从 opencode-go 切走时保留 ANTHROPIC_API_KEY(x-api-key 为 Anthropic 兼容通用认证,不反向清理)", () => {
+ const settings = {
+ env: {
+ ANTHROPIC_API_KEY: "sk-keep",
+ ANTHROPIC_MODEL: "manual-model",
+ OTHER_ENV: "keep-me",
+ },
+ };
+
+ // 切到 deepseek(可解析)时只清地址,不清 API_KEY
+ expect(applyProviderAutofill(settings, PRESETS, "builtin:deepseek")).toEqual({
+ env: {
+ ANTHROPIC_API_KEY: "sk-keep",
+ ANTHROPIC_MODEL: "manual-model",
+ OTHER_ENV: "keep-me",
+ },
+ });
+ });
});
describe("config-workspace-utils profile effective summary", () => {
diff --git a/src/components/config-workspace-utils.ts b/src/components/config-workspace-utils.ts
index e8bb435..9b2e7f1 100644
--- a/src/components/config-workspace-utils.ts
+++ b/src/components/config-workspace-utils.ts
@@ -351,7 +351,13 @@ export function applyProviderAutofill(
// 覆盖层只存差异:不再把 provider 的默认模型/effort 复制进 Profile settings
// (编辑器按"有效值"显示这些默认,详见 readBehaviorFieldState)。
// 仅在切到可解析供应商时清掉残留的地址覆盖——地址由 Provider 合并层提供(单一事实源),与后端一致。
- return providerResolved ? setEnvString(settings, "ANTHROPIC_BASE_URL", "") : settings;
+ let next = providerResolved ? setEnvString(settings, "ANTHROPIC_BASE_URL", "") : settings;
+ // opencode-go 通过 ANTHROPIC_API_KEY(x-api-key)认证,而 Claude Code 与后端模型测试均
+ // Bearer 优先;切到该供应商时清掉可能残留的 ANTHROPIC_AUTH_TOKEN,避免其遮蔽用户新填的 API Key。
+ if (providerResolved && providerSlugFromId(providerId) === "opencode-go") {
+ next = setEnvString(next, "ANTHROPIC_AUTH_TOKEN", "");
+ }
+ return next;
}
/**
diff --git a/src/i18n.ts b/src/i18n.ts
index b9a3cdf..d33d4c2 100644
--- a/src/i18n.ts
+++ b/src/i18n.ts
@@ -374,6 +374,8 @@ const translations = {
"profiles.editor.fields.provider": "供应商",
"profiles.editor.fields.authToken": "认证密钥",
"profiles.editor.fields.authTokenEnv": "ANTHROPIC_AUTH_TOKEN",
+ "profiles.editor.fields.authApiKey": "API 密钥",
+ "profiles.editor.fields.authApiKeyEnv": "ANTHROPIC_API_KEY",
"profiles.editor.fields.baseUrl": "API 地址",
"profiles.editor.fields.baseUrlEnv": "ANTHROPIC_BASE_URL",
"profiles.editor.placeholders.name": "例如:OpenRouter-日常开发",
@@ -2086,6 +2088,8 @@ const translations = {
"profiles.editor.fields.provider": "Provider",
"profiles.editor.fields.authToken": "Auth Token",
"profiles.editor.fields.authTokenEnv": "ANTHROPIC_AUTH_TOKEN",
+ "profiles.editor.fields.authApiKey": "API Key",
+ "profiles.editor.fields.authApiKeyEnv": "ANTHROPIC_API_KEY",
"profiles.editor.fields.baseUrl": "API Base URL",
"profiles.editor.fields.baseUrlEnv": "ANTHROPIC_BASE_URL",
"profiles.editor.placeholders.name": "e.g. OpenRouter-Daily dev",
From 82763e597f191384d1b4af9e484a0aa89935460b Mon Sep 17 00:00:00 2001
From: maguowei
Date: Thu, 20 Aug 2026 12:59:34 +0800
Subject: [PATCH 2/6] =?UTF-8?q?fix(claude):=20=E4=BF=AE=E5=A4=8D=20GUI=20?=
=?UTF-8?q?=E7=8E=AF=E5=A2=83=E4=B8=8B=20CLI=20=E5=AE=9A=E4=BD=8D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.claude/rules/tauri-backend.md | 1 +
src-tauri/src/claude_cli.rs | 244 +++++++++++++++++++++++++++++++++
src-tauri/src/lib.rs | 1 +
src-tauri/src/plugins.rs | 14 +-
src-tauri/src/project.rs | 8 +-
5 files changed, 249 insertions(+), 19 deletions(-)
create mode 100644 src-tauri/src/claude_cli.rs
diff --git a/.claude/rules/tauri-backend.md b/.claude/rules/tauri-backend.md
index afa40f3..0928316 100644
--- a/.claude/rules/tauri-backend.md
+++ b/.claude/rules/tauri-backend.md
@@ -25,6 +25,7 @@ paths:
| `project.rs` | 项目 Git 状态、worktree、分支/worktree 清理 preview/apply、本地数据清理 |
| `claude_directory.rs` | `~/.claude` 文件树、文件预览、创建、重命名、删除与外部打开 |
| `claude_directory_watcher.rs` | `~/.claude` 变更监听并广播 `claude-directory-changed` |
+| `claude_cli.rs` | Claude CLI 解析与执行:优先当前 `PATH`,再查官方 native 安装目录与 macOS Homebrew 标准目录 |
| `native_open.rs` | 默认终端 / 编辑器跨平台启动、本机检测受支持工具清单 |
| `terminal_focus.rs` | macOS 上 `pid -> tty -> AppleScript` 聚焦 Terminal.app / iTerm / Ghostty;herdr 会话两跳聚焦编排 |
| `herdr.rs` | herdr 会话聚焦:socket API 客户端(NDJSON)、pane 定位(pid 精确 + cwd 兜底)、附着 client 进程发现 |
diff --git a/src-tauri/src/claude_cli.rs b/src-tauri/src/claude_cli.rs
new file mode 100644
index 0000000..71a6059
--- /dev/null
+++ b/src-tauri/src/claude_cli.rs
@@ -0,0 +1,244 @@
+use std::env;
+use std::fmt;
+use std::path::{Path, PathBuf};
+use std::process::{Command, Output};
+
+#[cfg(windows)]
+const CLAUDE_FILE_NAMES: &[&str] = &["claude.exe", "claude.cmd", "claude.bat", "claude"];
+#[cfg(not(windows))]
+const CLAUDE_FILE_NAMES: &[&str] = &["claude"];
+
+#[derive(Debug)]
+pub(crate) enum ClaudeCliError {
+ NotFound,
+ Spawn(std::io::Error),
+}
+
+impl fmt::Display for ClaudeCliError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::NotFound => write!(
+ formatter,
+ "未找到 claude CLI,请确认 Claude Code 已安装并可在 PATH 或标准安装目录中访问"
+ ),
+ Self::Spawn(error) => write!(formatter, "执行 claude CLI 失败: {error}"),
+ }
+ }
+}
+
+pub(crate) fn run(args: &[String]) -> Result
{/* 表头必须与行同处滚动容器内:否则滚动条宽度只从行网格里扣,两侧 grid 模板宽度不一致导致列错位。
- sticky 自带 bg-card 遮挡下方滚动的行;z-10 沿用 PageHeader / ProfileEditor 的既有层级。 */}
+ sticky 自带 bg-card 遮挡下方滚动的行;z-sticky 使用全局语义层级 token。 */}
{t("profileEditor.common.index")}
diff --git a/src/components/profile-editor/MarketplacePluginRow.tsx b/src/components/profile-editor/MarketplacePluginRow.tsx
index f9f8f79..488bba9 100644
--- a/src/components/profile-editor/MarketplacePluginRow.tsx
+++ b/src/components/profile-editor/MarketplacePluginRow.tsx
@@ -1,47 +1,21 @@
import { openUrl } from "@tauri-apps/plugin-opener";
-import {
- Bot,
- Braces,
- ChevronDown,
- CircleCheck,
- ExternalLink,
- Plug,
- Plus,
- Settings2,
- Sparkles,
- SquareTerminal,
- Webhook,
-} from "lucide-react";
+import { ChevronDown, CircleCheck, ExternalLink, Plus, Settings2 } from "lucide-react";
import { type KeyboardEvent, memo } from "react";
import { cn } from "@/lib/utils";
-import { type TranslationKey, useI18n } from "../../i18n";
+import { useI18n } from "../../i18n";
import { Badge } from "../ui/badge";
import { Button } from "../ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import type { MarketplacePluginEntry } from "./marketplace-catalog";
import { getProviderAffiliation } from "./marketplace-catalog";
+import {
+ COMPONENT_KINDS,
+ DETAILS_COLLAPSE_THRESHOLD,
+ getMarketplacePluginDetails,
+ hasMarketplacePluginComponents,
+} from "./marketplace-plugin-row-utils";
import type { PluginComponents } from "./plugin-install-counts";
-const DETAILS_COLLAPSE_THRESHOLD = 150;
-
-// 组成类别的展示顺序、图标与 i18n 文案 key
-const COMPONENT_KINDS = [
- {
- key: "commands",
- icon: SquareTerminal,
- labelKey: "profileEditor.plugins.browse.componentCommands",
- },
- { key: "agents", icon: Bot, labelKey: "profileEditor.plugins.browse.componentAgents" },
- { key: "skills", icon: Sparkles, labelKey: "profileEditor.plugins.browse.componentSkills" },
- { key: "hooks", icon: Webhook, labelKey: "profileEditor.plugins.browse.componentHooks" },
- { key: "mcpServers", icon: Plug, labelKey: "profileEditor.plugins.browse.componentMcpServers" },
- { key: "lspServers", icon: Braces, labelKey: "profileEditor.plugins.browse.componentLspServers" },
-] as const satisfies ReadonlyArray<{
- key: keyof PluginComponents;
- icon: typeof Bot;
- labelKey: TranslationKey;
-}>;
-
interface MarketplacePluginRowProps {
plugin: MarketplacePluginEntry;
index: number;
@@ -73,8 +47,7 @@ function MarketplacePluginRow({
onManagePlugin,
}: MarketplacePluginRowProps) {
const { t } = useI18n();
- const subTitle = [plugin.authorName, plugin.marketplaceId].filter(Boolean).join(" · ");
- const details = [plugin.description, subTitle].filter(Boolean).join(" · ");
+ const details = getMarketplacePluginDetails(plugin);
const canExpandDetails = details.length > DETAILS_COLLAPSE_THRESHOLD;
const detailsTooltip = expanded
? t("profileEditor.plugins.browse.collapseDetailsTooltip")
@@ -88,7 +61,7 @@ function MarketplacePluginRow({
count: components[kind.key].length,
})).filter((badge) => badge.count > 0)
: [];
- const hasComponents = componentBadges.length > 0;
+ const hasComponents = hasMarketplacePluginComponents(components);
// 提供方归属(仅对官方市场插件做行内徽章区分)
const affiliation = getProviderAffiliation(plugin);
const installCountLabel =
diff --git a/src/components/profile-editor/__tests__/BrowseMarketplaceTab.test.tsx b/src/components/profile-editor/__tests__/BrowseMarketplaceTab.test.tsx
index f3993ff..beedc36 100644
--- a/src/components/profile-editor/__tests__/BrowseMarketplaceTab.test.tsx
+++ b/src/components/profile-editor/__tests__/BrowseMarketplaceTab.test.tsx
@@ -954,6 +954,7 @@ describe("BrowseMarketplaceTab", () => {
// 表头必须在滚动容器内并 sticky,否则滚动条宽度只从行网格里扣,列会错位
const header = container.querySelector("[data-slot='browse-header']");
expect(scroller).toContainElement(header as HTMLElement);
- expect(header).toHaveClass("sticky", "top-0");
+ expect(header).toHaveClass("sticky", "top-0", "z-sticky");
+ expect(header).not.toHaveClass("z-10");
});
});
diff --git a/src/components/profile-editor/__tests__/marketplace-plugin-row-utils.test.ts b/src/components/profile-editor/__tests__/marketplace-plugin-row-utils.test.ts
new file mode 100644
index 0000000..037669e
--- /dev/null
+++ b/src/components/profile-editor/__tests__/marketplace-plugin-row-utils.test.ts
@@ -0,0 +1,96 @@
+import { describe, expect, it } from "vitest";
+import type { MarketplacePluginEntry } from "../marketplace-catalog";
+import {
+ estimatePluginRowSize,
+ hasMarketplacePluginComponents,
+} from "../marketplace-plugin-row-utils";
+import { emptyPluginCatalog, type PluginComponents } from "../plugin-install-counts";
+
+const PLUGIN: MarketplacePluginEntry = {
+ pluginId: "alpha@claude-plugins-official",
+ marketplaceId: "claude-plugins-official",
+ description: "",
+ category: "",
+ authorName: "",
+ sourceType: "github",
+ homepage: "",
+ isOfficial: true,
+};
+
+function emptyComponents(): PluginComponents {
+ return {
+ commands: [],
+ agents: [],
+ skills: [],
+ hooks: [],
+ mcpServers: [],
+ lspServers: [],
+ };
+}
+
+describe("marketplace-plugin-row-utils", () => {
+ it("空 components 不额外估算组成行", () => {
+ const catalog = emptyPluginCatalog();
+ catalog.entries[PLUGIN.pluginId] = {
+ installCount: null,
+ components: emptyComponents(),
+ };
+
+ expect(hasMarketplacePluginComponents(catalog.entries[PLUGIN.pluginId].components)).toBe(false);
+ expect(estimatePluginRowSize(PLUGIN, catalog)).toBe(
+ estimatePluginRowSize(PLUGIN, emptyPluginCatalog()),
+ );
+ });
+
+ it("有组成且展开时把徽章与组件明细纳入估算", () => {
+ const catalog = emptyPluginCatalog();
+ catalog.entries[PLUGIN.pluginId] = {
+ installCount: null,
+ components: {
+ ...emptyComponents(),
+ commands: ["format-code"],
+ skills: ["review-code", "explain-code"],
+ },
+ };
+
+ const collapsed = estimatePluginRowSize(PLUGIN, catalog);
+ const expanded = estimatePluginRowSize(PLUGIN, catalog, true);
+
+ expect(hasMarketplacePluginComponents(catalog.entries[PLUGIN.pluginId].components)).toBe(true);
+ expect(expanded).toBeGreaterThan(collapsed);
+
+ const longPlugin = { ...PLUGIN, description: "a".repeat(300) };
+ expect(estimatePluginRowSize(longPlugin, catalog, true)).toBeGreaterThan(
+ estimatePluginRowSize(longPlugin, catalog, false),
+ );
+ });
+
+ it("没有布局宽度时按默认桌面布局估算徽章行", () => {
+ const catalog = emptyPluginCatalog();
+ catalog.entries[PLUGIN.pluginId] = {
+ installCount: null,
+ components: {
+ commands: ["command"],
+ agents: ["agent"],
+ skills: ["skill"],
+ hooks: ["hook"],
+ mcpServers: ["mcp"],
+ lspServers: ["lsp"],
+ },
+ };
+
+ expect(estimatePluginRowSize(PLUGIN, catalog)).toBe(
+ estimatePluginRowSize(PLUGIN, catalog, false, 800),
+ );
+ });
+
+ it("按列表宽度增加窄列中的详情折行估算", () => {
+ const catalog = emptyPluginCatalog();
+ const plugin = { ...PLUGIN, description: "a".repeat(100) };
+
+ const wide = estimatePluginRowSize(plugin, catalog, false, 800);
+ const narrow = estimatePluginRowSize(plugin, catalog, false, 240);
+
+ expect(narrow).toBeGreaterThan(wide);
+ });
+});
diff --git a/src/components/profile-editor/marketplace-plugin-row-utils.ts b/src/components/profile-editor/marketplace-plugin-row-utils.ts
new file mode 100644
index 0000000..2818cec
--- /dev/null
+++ b/src/components/profile-editor/marketplace-plugin-row-utils.ts
@@ -0,0 +1,145 @@
+import { Bot, Braces, Plug, Sparkles, SquareTerminal, Webhook } from "lucide-react";
+import type { TranslationKey } from "../../i18n";
+import type { MarketplacePluginEntry } from "./marketplace-catalog";
+import type { PluginCatalog, PluginComponents } from "./plugin-install-counts";
+
+export const DETAILS_COLLAPSE_THRESHOLD = 150;
+
+// 组成类别的展示顺序、图标与 i18n 文案 key
+export const COMPONENT_KINDS = [
+ {
+ key: "commands",
+ icon: SquareTerminal,
+ labelKey: "profileEditor.plugins.browse.componentCommands",
+ },
+ { key: "agents", icon: Bot, labelKey: "profileEditor.plugins.browse.componentAgents" },
+ { key: "skills", icon: Sparkles, labelKey: "profileEditor.plugins.browse.componentSkills" },
+ { key: "hooks", icon: Webhook, labelKey: "profileEditor.plugins.browse.componentHooks" },
+ { key: "mcpServers", icon: Plug, labelKey: "profileEditor.plugins.browse.componentMcpServers" },
+ { key: "lspServers", icon: Braces, labelKey: "profileEditor.plugins.browse.componentLspServers" },
+] as const satisfies ReadonlyArray<{
+ key: keyof PluginComponents;
+ icon: typeof Bot;
+ labelKey: TranslationKey;
+}>;
+
+// 统一行组件和行高估算使用的详情文本,避免折行依据与实际文案分叉
+export function getMarketplacePluginDetails(plugin: MarketplacePluginEntry): string {
+ const subTitle = [plugin.authorName, plugin.marketplaceId].filter(Boolean).join(" · ");
+ return [plugin.description, subTitle].filter(Boolean).join(" · ");
+}
+
+// catalog entry 即使没有任何组成,也会带一个空 components 对象;只有存在非空类别时才占行高
+export function hasMarketplacePluginComponents(
+ components: PluginComponents | undefined,
+): components is PluginComponents {
+ return components !== undefined && COMPONENT_KINDS.some(({ key }) => components[key].length > 0);
+}
+
+const ROW_BASE_SIZE = 45;
+const ROW_DETAILS_GAP = 6;
+const ROW_DETAILS_LINE_SIZE = 20;
+const ROW_DETAILS_CHAR_WIDTH = 7;
+const ROW_DETAILS_MAX_LINES = 3;
+const ROW_COMPONENTS_GAP = 6;
+const ROW_COMPONENT_LINE_SIZE = 20;
+const ROW_COMPONENT_LINE_GAP = 4;
+const ROW_COMPONENT_DETAIL_GAP = 6;
+const ROW_COMPONENT_BADGE_WIDTH = 28;
+const ROW_COMPONENT_BADGE_GAP = 12;
+// 首次渲染还没有真实宽度时按默认桌面布局估算;6 个类别加展开按钮最多 7 项
+const ROW_COMPONENTS_PER_LINE_FALLBACK = COMPONENT_KINDS.length + 1;
+const DEFAULT_ROW_CONTENT_WIDTH = 504;
+const MIN_ROW_CONTENT_WIDTH = 160;
+
+function getRowContentWidth(containerWidth: number | undefined): number {
+ if (!containerWidth || containerWidth <= 0) return DEFAULT_ROW_CONTENT_WIDTH;
+
+ const viewportWidth = typeof window === "undefined" ? containerWidth : window.innerWidth;
+ if (viewportWidth <= 640) {
+ return Math.max(MIN_ROW_CONTENT_WIDTH, containerWidth - 72);
+ }
+
+ const actionWidth = Math.min(Math.max(viewportWidth * 0.16, 152), 190);
+ // 对应行 grid 的 px-3.5、索引列、安装数列、操作列和三个 gap
+ return Math.max(MIN_ROW_CONTENT_WIDTH, containerWidth - 28 - 32 - 36 - 104 - actionWidth);
+}
+
+function getCharsPerLine(containerWidth: number | undefined): number {
+ return Math.max(20, Math.floor(getRowContentWidth(containerWidth) / ROW_DETAILS_CHAR_WIDTH));
+}
+
+function estimateLineCount(textLength: number, charsPerLine: number, maxLines?: number): number {
+ const lines = Math.max(1, Math.ceil(textLength / charsPerLine));
+ return maxLines === undefined ? lines : Math.min(maxLines, lines);
+}
+
+function estimateComponentBadgeLines(
+ components: PluginComponents,
+ contentWidth: number | undefined,
+): number {
+ const badgeCount = COMPONENT_KINDS.filter(({ key }) => components[key].length > 0).length + 1;
+ const width = getRowContentWidth(contentWidth);
+ const badgesPerLine = Math.max(
+ 1,
+ Math.floor(
+ (width + ROW_COMPONENT_BADGE_GAP) / (ROW_COMPONENT_BADGE_WIDTH + ROW_COMPONENT_BADGE_GAP),
+ ),
+ );
+ // 没有真实布局宽度时沿用当前桌面布局的默认基线
+ const effectivePerLine =
+ contentWidth === undefined ? ROW_COMPONENTS_PER_LINE_FALLBACK : badgesPerLine;
+ return Math.max(1, Math.ceil(badgeCount / effectivePerLine));
+}
+
+function estimateComponentDetailLines(components: PluginComponents, charsPerLine: number): number {
+ return COMPONENT_KINDS.reduce((total, { key }) => {
+ const values = components[key];
+ if (values.length === 0) return total;
+ const textLength = key.length + 2 + values.join(", ").length;
+ return total + estimateLineCount(textLength, charsPerLine);
+ }, 0);
+}
+
+// 行高先按当前内容估算,再由 virtualizer.measureElement 用真实 DOM 高度校准
+export function estimatePluginRowSize(
+ plugin: MarketplacePluginEntry | undefined,
+ catalog: PluginCatalog,
+ expanded = false,
+ containerWidth?: number,
+): number {
+ if (!plugin) return ROW_BASE_SIZE;
+
+ const charsPerLine = getCharsPerLine(containerWidth);
+ let size = ROW_BASE_SIZE;
+ const details = getMarketplacePluginDetails(plugin);
+ if (details) {
+ const lines = estimateLineCount(
+ details.length,
+ charsPerLine,
+ details.length > DETAILS_COLLAPSE_THRESHOLD && !expanded ? ROW_DETAILS_MAX_LINES : undefined,
+ );
+ size += ROW_DETAILS_GAP + lines * ROW_DETAILS_LINE_SIZE;
+ }
+
+ const components = catalog.entries[plugin.pluginId]?.components;
+ if (hasMarketplacePluginComponents(components)) {
+ const badgeLines = estimateComponentBadgeLines(components, containerWidth);
+ size +=
+ ROW_COMPONENTS_GAP +
+ badgeLines * ROW_COMPONENT_LINE_SIZE +
+ Math.max(0, badgeLines - 1) * ROW_COMPONENT_LINE_GAP;
+
+ if (expanded) {
+ const detailLines = estimateComponentDetailLines(components, charsPerLine);
+ if (detailLines > 0) {
+ size +=
+ ROW_COMPONENT_DETAIL_GAP +
+ detailLines * ROW_DETAILS_LINE_SIZE +
+ Math.max(0, detailLines - 1) * ROW_COMPONENT_LINE_GAP;
+ }
+ }
+ }
+
+ return size;
+}
diff --git a/src/index.css b/src/index.css
index d0e386c..e5315bd 100644
--- a/src/index.css
+++ b/src/index.css
@@ -139,6 +139,7 @@
--shadow-panel: 0 1px 2px oklch(0.2 0.04 255 / 10%), 0 12px 34px oklch(0.44 0.12 245 / 8%);
--shadow-floating: 0 18px 45px oklch(0.18 0.04 255 / 18%), 0 4px 14px oklch(0.44 0.12 245 / 9%);
--shadow-toolbar: 0 1px 0 oklch(0.2 0.04 255 / 10%), 0 10px 22px oklch(0.44 0.12 245 / 6%);
+ --z-index-sticky: 10;
}
@layer base {
From c7f58b890a1f46c215b679033949032057379167 Mon Sep 17 00:00:00 2001
From: maguowei
Date: Sun, 30 Aug 2026 20:53:16 +0800
Subject: [PATCH 5/6] =?UTF-8?q?fix(statusline):=20=E4=BF=AE=E5=A4=8D=20Win?=
=?UTF-8?q?dows=20=E7=8A=B6=E6=80=81=E8=A1=8C=E5=9B=A0=E8=84=9A=E6=9C=AC?=
=?UTF-8?q?=E7=BC=96=E7=A0=81=E8=A7=A3=E6=9E=90=E5=A4=B1=E8=B4=A5=E8=80=8C?=
=?UTF-8?q?=E4=B8=8D=E6=98=BE=E7=A4=BA?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Windows PowerShell 5.1 读取无 BOM 的 .ps1 时按系统代码页(简中 CP936)
解码,UTF-8 中文注释错位后残留的悬空 lead byte 会吞掉行尾换行,使下一行
代码并入注释并触发 ParserError,状态行整行无输出。
- 新增 expected_status_line_script():Windows 落盘前置 UTF-8 BOM,非
Windows 保持裸内容(Bash 带 BOM 会让 shebang 失效);写入与幂等比较
共用该来源,避免带 BOM 的脚本被误判为用户自定义而要求覆盖确认
- statusLine.command 的脚本路径加引号,修复用户名含空格时 -File 参数
在空格处被截断
- default.ps1 显式以 UTF-8 读取 stdin,修复含中文目录名与 session_name
的 JSON 乱码;OutputEncoding 改用无 BOM 实例,避免输出头混入 EF BB BF
Co-Authored-By: Claude Opus 5 (1M context)
---
.claude/rules/config-system.md | 5 +-
src-tauri/resources/statusline/default.ps1 | 16 ++-
src-tauri/src/config.rs | 123 ++++++++++++++++++++-
3 files changed, 136 insertions(+), 8 deletions(-)
diff --git a/.claude/rules/config-system.md b/.claude/rules/config-system.md
index 01cddc3..0c98aa9 100644
--- a/.claude/rules/config-system.md
+++ b/.claude/rules/config-system.md
@@ -75,7 +75,10 @@ paths:
- 权限编辑器只管理 `defaultMode`、`disableBypassPermissionsMode`、`allow`、`deny`、`ask`、`additionalDirectories`;写回时保留其它顶层字段,例如 `disableAutoMode`。
- 修复权限 dirty 问题时优先做局部语义比较,不要扩大到全局 dirty 系统。
-- 状态行默认脚本按平台分发:非 Windows 用 `src-tauri/resources/statusline/default.sh`(Bash,依赖 jq),Windows 用 `src-tauri/resources/statusline/default.ps1`(PowerShell,免 jq)。安装走后端 `install_status_line_preset`:Windows 写入 `~/.claude/statusline.ps1` 并把 `command` 设为绝对正斜杠路径的 `powershell -NoProfile -ExecutionPolicy Bypass -File ...`;两份脚本功能需保持对齐。
+- 状态行默认脚本按平台分发:非 Windows 用 `src-tauri/resources/statusline/default.sh`(Bash,依赖 jq),Windows 用 `src-tauri/resources/statusline/default.ps1`(PowerShell,免 jq)。安装走后端 `install_status_line_preset`:Windows 写入 `~/.claude/statusline.ps1` 并把 `command` 设为绝对正斜杠**且加引号**的 `powershell -NoProfile -ExecutionPolicy Bypass -File "..."`(用户名含空格时不加引号会截断参数);两份脚本功能需保持对齐。
+- 两份脚本源文件都**不带 BOM**;Windows 落盘时由 `config.rs::expected_status_line_script()` 前置 UTF-8 BOM。Windows PowerShell 5.1 读取无 BOM 的 `.ps1` 时按系统代码页(简中 CP936)解码,UTF-8 中文注释错位后残留的悬空 lead byte 会吞掉行尾换行,使下一行代码并入注释并触发 `ParserError`,状态行整行无输出。给源文件加 BOM 会变成双 BOM,Bash 脚本加 BOM 会让 shebang 失效——两者都不要做。
+- `expected_status_line_script()` 同时是落盘内容和幂等比较基准,写入与比较必须共用它。若只改一处,已带 BOM 的脚本会被误判为“用户自定义”,安装预设时要求覆盖确认并把 BOM 覆盖掉,故障复发。
+- `default.ps1` 必须显式以 UTF-8 读取 stdin(`[Console]::OpenStandardInput()` + 无 BOM `UTF8Encoding` 的 `StreamReader`):PS 5.1 的 `[Console]::In` 按系统代码页解码,含中文目录名或 session_name 的 JSON 会乱码;直接设 `[Console]::InputEncoding` 在 stdin 已重定向时可能抛异常。赋给 `[Console]::OutputEncoding` 的实例也必须无 BOM,否则输出头可能混入 `EF BB BF`(MD5 处的 `[System.Text.Encoding]::UTF8.GetBytes()` 不输出 preamble,属正常用法)。
## 新增配置字段同步点
diff --git a/src-tauri/resources/statusline/default.ps1 b/src-tauri/resources/statusline/default.ps1
index 7fdc896..92238bc 100644
--- a/src-tauri/resources/statusline/default.ps1
+++ b/src-tauri/resources/statusline/default.ps1
@@ -6,8 +6,10 @@
# 状态行追求健壮而非严格:单个字段异常不应导致整行无输出
$ErrorActionPreference = 'SilentlyContinue'
-# 强制 UTF-8 输出,避免 -> 等字符被系统代码页破坏
-[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
+# 强制 UTF-8 输入输出,避免 -> 等字符与中文被系统代码页破坏。
+# 必须用无 BOM 实例:[System.Text.Encoding]::UTF8 带 preamble,PS 5.1 下可能把 EF BB BF 混进输出头
+$Utf8NoBom = New-Object System.Text.UTF8Encoding $false
+[Console]::OutputEncoding = $Utf8NoBom
# ── ANSI 颜色常量(用拼接构造,避免字符串插值把 $var[ 当作索引)──
$ESC = [char]27
@@ -100,7 +102,15 @@ function Format-K($n) {
}
# ── 读取并解析 stdin JSON ──────────────────────────────────
-$stdin = [Console]::In.ReadToEnd()
+# 显式以 UTF-8 读取标准输入:PS 5.1 的 [Console]::In 按系统代码页解码,
+# Claude Code 传入的 UTF-8 JSON 一旦含中文(目录名、session_name)就会乱码。
+# 直接设 [Console]::InputEncoding 在 stdin 已重定向时可能抛异常,故改用显式编码的 StreamReader。
+try {
+ $stdinReader = New-Object System.IO.StreamReader([Console]::OpenStandardInput(), $Utf8NoBom)
+ $stdin = $stdinReader.ReadToEnd()
+} catch {
+ $stdin = [Console]::In.ReadToEnd()
+}
if ([string]::IsNullOrWhiteSpace($stdin)) { exit 0 }
try { $data = $stdin | ConvertFrom-Json } catch { exit 0 }
diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs
index 1d2a40f..b7f382d 100644
--- a/src-tauri/src/config.rs
+++ b/src-tauri/src/config.rs
@@ -1982,12 +1982,32 @@ fn status_line_preset_target_path() -> Result {
Ok(crate::utils::get_home_dir()?.join(".claude").join(filename))
}
+// UTF-8 BOM。Windows PowerShell 5.1 读取无 BOM 的 .ps1 时按系统代码页(简中为 CP936)解码,
+// UTF-8 中文注释会被错位解码,残留的悬空 lead byte 会吞掉行尾换行,
+// 使下一行代码被并入注释行,最终触发 ParserError 让状态行整行无输出。
+#[cfg(windows)]
+const STATUS_LINE_SCRIPT_UTF8_BOM: &str = "\u{feff}";
+
+// 期望写入磁盘的脚本内容,同时作为幂等比较基准;
+// 两处必须共用同一来源,否则带 BOM 的已安装脚本会被误判成用户自定义脚本。
+#[cfg(windows)]
+fn expected_status_line_script() -> String {
+ format!("{STATUS_LINE_SCRIPT_UTF8_BOM}{DEFAULT_STATUS_LINE_SCRIPT}")
+}
+
+// Bash 脚本不能带 BOM:shebang 之前出现 BOM 会让 `#!/bin/bash` 失效。
+#[cfg(not(windows))]
+fn expected_status_line_script() -> String {
+ DEFAULT_STATUS_LINE_SCRIPT.to_string()
+}
+
// 计算写入 settings.json 的 statusLine.command
// Windows 用绝对正斜杠路径调用 PowerShell,规避 ~ 在 -File 参数中不展开的问题
#[cfg(windows)]
fn status_line_preset_command(target_path: &std::path::Path) -> String {
let normalized = target_path.display().to_string().replace('\\', "/");
- format!("powershell -NoProfile -ExecutionPolicy Bypass -File {normalized}")
+ // 路径必须加引号:用户名含空格时(如 C:/Users/demo user)-File 参数会在空格处被截断
+ format!("powershell -NoProfile -ExecutionPolicy Bypass -File \"{normalized}\"")
}
#[cfg(not(windows))]
@@ -2059,11 +2079,12 @@ fn install_status_line_preset_inner(
ensure_status_line_preset_supported()?;
let target_path = status_line_preset_target_path()?;
+ let expected_script = expected_status_line_script();
if target_path.exists() {
let existing = fs::read_to_string(&target_path)
.map_err(|e| format!("读取状态行脚本失败 {:?}: {}", target_path, e))?;
- if existing == DEFAULT_STATUS_LINE_SCRIPT {
+ if existing == expected_script {
ensure_status_line_script_executable(&target_path)?;
return Ok(build_status_line_preset_result(
preset_id,
@@ -2083,7 +2104,7 @@ fn install_status_line_preset_inner(
}
}
- write_status_line_script(&target_path, DEFAULT_STATUS_LINE_SCRIPT)?;
+ write_status_line_script(&target_path, &expected_script)?;
Ok(build_status_line_preset_result(
preset_id,
&target_path,
@@ -3926,6 +3947,53 @@ mod tests {
assert!(DEFAULT_STATUS_LINE_SCRIPT.contains("ConvertFrom-Json"));
// 强制 UTF-8 输出,避免 emoji 与中文乱码
assert!(DEFAULT_STATUS_LINE_SCRIPT.contains("[Console]::OutputEncoding"));
+ // 编码实例必须无 BOM,否则输出头可能混入 EF BB BF
+ assert!(DEFAULT_STATUS_LINE_SCRIPT.contains("New-Object System.Text.UTF8Encoding $false"));
+ // 只禁止把带 preamble 的实例赋给 OutputEncoding;
+ // MD5 处的 [System.Text.Encoding]::UTF8.GetBytes() 不输出 preamble,属正常用法
+ assert!(!DEFAULT_STATUS_LINE_SCRIPT
+ .contains("[Console]::OutputEncoding = [System.Text.Encoding]::UTF8"));
+ // stdin 必须显式按 UTF-8 读取,否则含中文目录名的 JSON 会按系统代码页乱码
+ assert!(DEFAULT_STATUS_LINE_SCRIPT.contains("[Console]::OpenStandardInput()"));
+ }
+
+ #[cfg(windows)]
+ #[test]
+ fn expected_status_line_script_prepends_utf8_bom_on_windows() {
+ let expected = expected_status_line_script();
+
+ // PowerShell 5.1 靠 BOM 才会按 UTF-8 解析脚本
+ assert!(expected.as_bytes().starts_with(b"\xEF\xBB\xBF"));
+ // BOM 之后必须是原始脚本,不能篡改或重复内容
+ assert_eq!(
+ expected.strip_prefix('\u{feff}'),
+ Some(DEFAULT_STATUS_LINE_SCRIPT)
+ );
+ }
+
+ #[cfg(not(windows))]
+ #[test]
+ fn expected_status_line_script_has_no_bom_on_unix() {
+ let expected = expected_status_line_script();
+
+ // Bash 脚本带 BOM 会让 shebang 失效,必须保持裸内容
+ assert!(!expected.starts_with('\u{feff}'));
+ assert!(expected.starts_with("#!/bin/bash"));
+ assert_eq!(expected, DEFAULT_STATUS_LINE_SCRIPT);
+ }
+
+ #[cfg(windows)]
+ #[test]
+ fn status_line_preset_command_quotes_path_with_spaces() {
+ // 用户名含空格时,未加引号的 -File 参数会在空格处被截断,导致状态行整行无输出
+ let command = status_line_preset_command(std::path::Path::new(
+ "C:\\Users\\demo user\\.claude\\statusline.ps1",
+ ));
+
+ assert_eq!(
+ command,
+ "powershell -NoProfile -ExecutionPolicy Bypass -File \"C:/Users/demo user/.claude/statusline.ps1\""
+ );
}
#[cfg(windows)]
@@ -3947,8 +4015,55 @@ mod tests {
assert!(!result.needs_overwrite);
assert_eq!(
fs::read_to_string(&target_path).unwrap(),
- DEFAULT_STATUS_LINE_SCRIPT
+ expected_status_line_script()
);
+ // 落盘文件必须真带 BOM,否则 PS 5.1 会按系统代码页解析并报 ParserError
+ assert!(fs::read(&target_path).unwrap().starts_with(b"\xEF\xBB\xBF"));
+
+ clear_test_env();
+ }
+
+ #[cfg(windows)]
+ #[test]
+ fn install_status_line_preset_keeps_bom_script_without_requesting_overwrite() {
+ let _guard = crate::utils::lock_config().unwrap();
+ let root = temp_root("status-line-existing-bom-windows");
+ set_test_env(&root);
+ let target_path = root.join(".claude").join("statusline.ps1");
+ fs::create_dir_all(target_path.parent().unwrap()).unwrap();
+ // 模拟已安装(或用户手工补过 BOM)的脚本:必须判为已最新,而不是"已被自定义"
+ fs::write(&target_path, expected_status_line_script()).unwrap();
+
+ let result = install_status_line_preset_inner("default", false).unwrap();
+
+ assert!(!result.installed);
+ assert!(!result.needs_overwrite);
+ assert_eq!(
+ fs::read_to_string(&target_path).unwrap(),
+ expected_status_line_script()
+ );
+
+ clear_test_env();
+ }
+
+ #[cfg(windows)]
+ #[test]
+ fn install_status_line_preset_reports_overwrite_needed_for_bomless_script() {
+ let _guard = crate::utils::lock_config().unwrap();
+ let root = temp_root("status-line-existing-bomless-windows");
+ set_test_env(&root);
+ let target_path = root.join(".claude").join("statusline.ps1");
+ fs::create_dir_all(target_path.parent().unwrap()).unwrap();
+ // 旧版本装下的无 BOM 脚本:内容虽同,但需要重装才能修好解析问题
+ fs::write(&target_path, DEFAULT_STATUS_LINE_SCRIPT).unwrap();
+
+ let result = install_status_line_preset_inner("default", false).unwrap();
+ assert!(!result.installed);
+ assert!(result.needs_overwrite);
+
+ let overwritten = install_status_line_preset_inner("default", true).unwrap();
+ assert!(overwritten.installed);
+ assert!(fs::read(&target_path).unwrap().starts_with(b"\xEF\xBB\xBF"));
clear_test_env();
}
From 0d1e4647de2b9fb4abe89b8d05bfeab53956b780 Mon Sep 17 00:00:00 2001
From: maguowei
Date: Sun, 30 Aug 2026 21:21:42 +0800
Subject: [PATCH 6/6] =?UTF-8?q?fix(claude):=20=E4=BF=AE=E5=A4=8D=20Windows?=
=?UTF-8?q?=20=E4=B8=8B=20clippy=20=E5=9B=A0=E6=B5=8B=E8=AF=95=E6=A8=A1?=
=?UTF-8?q?=E5=9D=97=E6=9C=AA=E9=97=A8=E6=8E=A7=20unix=20=E8=80=8C?=
=?UTF-8?q?=E6=8A=A5=20unused/dead=5Fcode?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
claude_cli.rs 测试模块整体加 #[cfg(all(test, unix))],使 Windows 上
cargo clippy --all-targets -D warnings 不再因未使用导入与未构造结构体而失败。
Co-Authored-By: Claude Opus 5 (1M context)
---
src-tauri/src/claude_cli.rs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src-tauri/src/claude_cli.rs b/src-tauri/src/claude_cli.rs
index 71a6059..3adead9 100644
--- a/src-tauri/src/claude_cli.rs
+++ b/src-tauri/src/claude_cli.rs
@@ -116,7 +116,7 @@ fn is_executable(path: &Path) -> bool {
}
}
-#[cfg(test)]
+#[cfg(all(test, unix))]
mod tests {
use super::run;
use std::env;