diff --git a/README.md b/README.md index 97b930cd..eceaf511 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Claude Code Haha

- Claude Code Haha + Claude Code Haha

diff --git a/desktop/bun.lock b/desktop/bun.lock index 52d20da9..13c4092d 100644 --- a/desktop/bun.lock +++ b/desktop/bun.lock @@ -7,6 +7,8 @@ "dependencies": { "@tailwindcss/typography": "^0.5.19", "@types/dompurify": "^3.2.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "dompurify": "^3.3.3", "lucide-react": "^0.469.0", "marked": "^15.0.7", @@ -470,6 +472,10 @@ "@vitest/utils": ["@vitest/utils@3.2.4", "https://registry.npmmirror.com/@vitest/utils/-/utils-3.2.4.tgz", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="], + "@xterm/addon-fit": ["@xterm/addon-fit@0.11.0", "https://registry.npmmirror.com/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", {}, "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="], + + "@xterm/xterm": ["@xterm/xterm@6.0.0", "https://registry.npmmirror.com/@xterm/xterm/-/xterm-6.0.0.tgz", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="], + "acorn": ["acorn@8.16.0", "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "agent-base": ["agent-base@7.1.4", "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], diff --git a/desktop/package.json b/desktop/package.json index b43d7818..7cd33e83 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -18,6 +18,8 @@ "dependencies": { "@tailwindcss/typography": "^0.5.19", "@types/dompurify": "^3.2.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "dompurify": "^3.3.3", "lucide-react": "^0.469.0", "marked": "^15.0.7", diff --git a/desktop/public/app-icon.jpg b/desktop/public/app-icon.jpg deleted file mode 100644 index 03098747..00000000 Binary files a/desktop/public/app-icon.jpg and /dev/null differ diff --git a/desktop/public/app-icon.png b/desktop/public/app-icon.png index 4f415f61..02f20c4c 100644 Binary files a/desktop/public/app-icon.png and b/desktop/public/app-icon.png differ diff --git a/desktop/public/app-icon.svg b/desktop/public/app-icon.svg new file mode 100644 index 00000000..582d3c54 --- /dev/null +++ b/desktop/public/app-icon.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/desktop/scripts/build-sidecars.ts b/desktop/scripts/build-sidecars.ts index 5c01096a..a46293dc 100644 --- a/desktop/scripts/build-sidecars.ts +++ b/desktop/scripts/build-sidecars.ts @@ -162,21 +162,25 @@ async function compileExecutable({ // that causes "load code signature error 4" and SIGKILL at launch. // Fix: strip the broken signature, then ad-hoc sign. if (process.platform === 'darwin') { - console.log(`[build-sidecars] ad-hoc signing ${outputPath} for macOS ...`) - const strip = Bun.spawn(['codesign', '--remove-signature', outputPath], { - stdout: 'inherit', - stderr: 'inherit', - }) - await strip.exited - - const sign = Bun.spawn( - ['codesign', '--sign', '-', '--force', '--timestamp=none', outputPath], - { stdout: 'inherit', stderr: 'inherit' }, - ) - const signExit = await sign.exited - if (signExit !== 0) { - throw new Error(`[build-sidecars] ad-hoc codesign failed for ${outputPath} (exit ${signExit})`) - } - console.log(`[build-sidecars] ad-hoc signed ${outputPath}`) + await adHocSignMacBinary(outputPath) } } + +async function adHocSignMacBinary(outputPath: string) { + console.log(`[build-sidecars] ad-hoc signing ${outputPath} for macOS ...`) + const strip = Bun.spawn(['codesign', '--remove-signature', outputPath], { + stdout: 'inherit', + stderr: 'inherit', + }) + await strip.exited + + const sign = Bun.spawn( + ['codesign', '--sign', '-', '--force', '--timestamp=none', outputPath], + { stdout: 'inherit', stderr: 'inherit' }, + ) + const signExit = await sign.exited + if (signExit !== 0) { + throw new Error(`[build-sidecars] ad-hoc codesign failed for ${outputPath} (exit ${signExit})`) + } + console.log(`[build-sidecars] ad-hoc signed ${outputPath}`) +} diff --git a/desktop/sidecars/claude-sidecar.ts b/desktop/sidecars/claude-sidecar.ts index db070432..cd0937f0 100644 --- a/desktop/sidecars/claude-sidecar.ts +++ b/desktop/sidecars/claude-sidecar.ts @@ -17,18 +17,21 @@ * launcher-only 参数。 */ +import { parseLauncherArgs, resolveSidecarInvocation } from './launcherRouting' + const rawArgs = process.argv.slice(2) -if (rawArgs.length === 0) { +const invocation = resolveSidecarInvocation(rawArgs) +if (!invocation.mode) { console.error('claude-sidecar: missing mode argument (expected "server", "cli" or "adapters")') process.exit(2) } -const mode = rawArgs[0]! -const restArgs = rawArgs.slice(1) +const mode = invocation.mode +const restArgs = invocation.restArgs if (mode === 'adapters') { await runAdapters(restArgs) } else { - const { appRoot, args } = parseLauncherArgs(restArgs) + const { appRoot, args } = parseLauncherArgs(restArgs, invocation.defaultAppRoot) process.env.CLAUDE_APP_ROOT = appRoot process.env.CALLER_DIR ||= process.cwd() @@ -133,24 +136,3 @@ async function runAdapters(rawArgs: string[]): Promise { // / grammY long-polling)持有 event loop,自然不会退出。这里不需要额外 // setInterval 兜底。两个 adapter 自己注册的 SIGINT handler 都会触发。 } - -function parseLauncherArgs(rawArgs: string[]): { appRoot: string; args: string[] } { - const nextArgs: string[] = [] - let appRoot: string | null = process.env.CLAUDE_APP_ROOT ?? null - - for (let index = 0; index < rawArgs.length; index++) { - const arg = rawArgs[index] - if (arg === '--app-root') { - appRoot = rawArgs[index + 1] ?? null - index += 1 - continue - } - nextArgs.push(arg!) - } - - if (!appRoot) { - throw new Error('Missing --app-root for claude-sidecar') - } - - return { appRoot, args: nextArgs } -} diff --git a/desktop/sidecars/launcherRouting.test.ts b/desktop/sidecars/launcherRouting.test.ts new file mode 100644 index 00000000..84d26637 --- /dev/null +++ b/desktop/sidecars/launcherRouting.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' + +import { parseLauncherArgs, resolveSidecarInvocation } from './launcherRouting' + +describe('resolveSidecarInvocation', () => { + it('keeps explicit sidecar modes unchanged', () => { + expect( + resolveSidecarInvocation( + ['server', '--host', '127.0.0.1'], + '/tmp/claude-sidecar', + ), + ).toEqual({ + mode: 'server', + restArgs: ['--host', '127.0.0.1'], + defaultAppRoot: null, + }) + }) + + it('defaults claude-haha invocations to cli mode', () => { + expect( + resolveSidecarInvocation( + ['plugin', 'install', 'demo'], + '/Users/demo/.local/bin/claude-haha', + ), + ).toEqual({ + mode: 'cli', + restArgs: ['plugin', 'install', 'demo'], + defaultAppRoot: '/Users/demo/.local/bin', + }) + }) +}) + +describe('parseLauncherArgs', () => { + it('falls back to the provided default app root', () => { + expect( + parseLauncherArgs(['plugin', 'install', 'demo'], '/Users/demo/.local/bin'), + ).toEqual({ + appRoot: '/Users/demo/.local/bin', + args: ['plugin', 'install', 'demo'], + }) + }) + + it('lets explicit app root override the default', () => { + expect( + parseLauncherArgs( + ['--app-root', '/tmp/app', 'plugin', 'install', 'demo'], + '/Users/demo/.local/bin', + ), + ).toEqual({ + appRoot: '/tmp/app', + args: ['plugin', 'install', 'demo'], + }) + }) +}) diff --git a/desktop/sidecars/launcherRouting.ts b/desktop/sidecars/launcherRouting.ts new file mode 100644 index 00000000..2af59c6a --- /dev/null +++ b/desktop/sidecars/launcherRouting.ts @@ -0,0 +1,64 @@ +import path from 'node:path' + +export type SidecarMode = 'server' | 'cli' | 'adapters' + +const EXPLICIT_MODES = new Set(['server', 'cli', 'adapters']) +const DESKTOP_CLI_NAMES = new Set(['claude-haha', 'claude-haha.exe']) + +export function resolveSidecarInvocation( + rawArgs: string[], + execPath: string = process.execPath, + envAppRoot: string | null = process.env.CLAUDE_APP_ROOT ?? null, +): { + mode: SidecarMode | null + restArgs: string[] + defaultAppRoot: string | null +} { + const explicitMode = rawArgs[0] + if (explicitMode && EXPLICIT_MODES.has(explicitMode as SidecarMode)) { + return { + mode: explicitMode as SidecarMode, + restArgs: rawArgs.slice(1), + defaultAppRoot: envAppRoot, + } + } + + const execName = path.basename(execPath).toLowerCase() + if (DESKTOP_CLI_NAMES.has(execName)) { + return { + mode: 'cli', + restArgs: rawArgs, + defaultAppRoot: envAppRoot ?? path.dirname(execPath), + } + } + + return { + mode: null, + restArgs: rawArgs, + defaultAppRoot: envAppRoot, + } +} + +export function parseLauncherArgs( + rawArgs: string[], + defaultAppRoot: string | null = process.env.CLAUDE_APP_ROOT ?? null, +): { appRoot: string; args: string[] } { + const nextArgs: string[] = [] + let appRoot: string | null = defaultAppRoot + + for (let index = 0; index < rawArgs.length; index++) { + const arg = rawArgs[index] + if (arg === '--app-root') { + appRoot = rawArgs[index + 1] ?? null + index += 1 + continue + } + nextArgs.push(arg!) + } + + if (!appRoot) { + throw new Error('Missing --app-root for claude-sidecar') + } + + return { appRoot, args: nextArgs } +} diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 98b02845..8933a60e 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -309,6 +309,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + [[package]] name = "chrono" version = "0.4.44" @@ -325,6 +331,8 @@ dependencies = [ name = "claude-code-desktop" version = "0.1.5" dependencies = [ + "anyhow", + "portable-pty", "serde", "serde_json", "tauri", @@ -675,6 +683,12 @@ dependencies = [ "tendril 0.5.0", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dpi" version = "0.1.2" @@ -722,7 +736,7 @@ dependencies = [ "rustc_version", "toml 0.9.12+spec-1.1.0", "vswhom", - "winreg", + "winreg 0.55.0", ] [[package]] @@ -792,6 +806,17 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "filetime" version = "0.2.27" @@ -1771,6 +1796,12 @@ dependencies = [ "selectors 0.24.0", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -2009,6 +2040,18 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nodrop" version = "0.1.14" @@ -2533,6 +2576,27 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "portable-pty" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix", + "serial2", + "shared_library", + "shell-words", + "winapi", + "winreg 0.10.1", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -3291,6 +3355,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serial2" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcdbc46aa3882ec3d48ec2b5abcb4f0d863a13d7599265f3faa6d851f23c12f3" +dependencies = [ + "cfg-if", + "libc", + "winapi", +] + [[package]] name = "serialize-to-javascript" version = "0.1.2" @@ -3354,6 +3429,22 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" @@ -5242,6 +5333,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "winreg" version = "0.55.0" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 03f749e0..98f28b9d 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -18,3 +18,5 @@ tauri-plugin-process = "2" tauri-plugin-updater = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" +anyhow = "1.0.102" +portable-pty = "0.9.0" diff --git a/desktop/src-tauri/app-icon-macos.svg b/desktop/src-tauri/app-icon-macos.svg new file mode 100644 index 00000000..af11a9e2 --- /dev/null +++ b/desktop/src-tauri/app-icon-macos.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/desktop/src-tauri/app-icon.svg b/desktop/src-tauri/app-icon.svg index 2ddf7d13..f4f73e67 100644 --- a/desktop/src-tauri/app-icon.svg +++ b/desktop/src-tauri/app-icon.svg @@ -1,67 +1,16 @@ - - - - - - - - - - - - - - - - - - - - - - - - - + - - - - - + + + + + + - - - - - - - + + + + + + diff --git a/desktop/src-tauri/icons/128x128.png b/desktop/src-tauri/icons/128x128.png index 4385cafb..9c55c9b0 100644 Binary files a/desktop/src-tauri/icons/128x128.png and b/desktop/src-tauri/icons/128x128.png differ diff --git a/desktop/src-tauri/icons/128x128@2x.png b/desktop/src-tauri/icons/128x128@2x.png index 23031353..3993a862 100644 Binary files a/desktop/src-tauri/icons/128x128@2x.png and b/desktop/src-tauri/icons/128x128@2x.png differ diff --git a/desktop/src-tauri/icons/32x32.png b/desktop/src-tauri/icons/32x32.png index bd182b75..9e7fee4e 100644 Binary files a/desktop/src-tauri/icons/32x32.png and b/desktop/src-tauri/icons/32x32.png differ diff --git a/desktop/src-tauri/icons/64x64.png b/desktop/src-tauri/icons/64x64.png index 2d11cacd..1af7f418 100644 Binary files a/desktop/src-tauri/icons/64x64.png and b/desktop/src-tauri/icons/64x64.png differ diff --git a/desktop/src-tauri/icons/Square107x107Logo.png b/desktop/src-tauri/icons/Square107x107Logo.png index 75743661..93827f1b 100644 Binary files a/desktop/src-tauri/icons/Square107x107Logo.png and b/desktop/src-tauri/icons/Square107x107Logo.png differ diff --git a/desktop/src-tauri/icons/Square142x142Logo.png b/desktop/src-tauri/icons/Square142x142Logo.png index e1cdbdea..2cb58edb 100644 Binary files a/desktop/src-tauri/icons/Square142x142Logo.png and b/desktop/src-tauri/icons/Square142x142Logo.png differ diff --git a/desktop/src-tauri/icons/Square150x150Logo.png b/desktop/src-tauri/icons/Square150x150Logo.png index e9236bac..296ecbfb 100644 Binary files a/desktop/src-tauri/icons/Square150x150Logo.png and b/desktop/src-tauri/icons/Square150x150Logo.png differ diff --git a/desktop/src-tauri/icons/Square284x284Logo.png b/desktop/src-tauri/icons/Square284x284Logo.png index e82297e3..983a93a2 100644 Binary files a/desktop/src-tauri/icons/Square284x284Logo.png and b/desktop/src-tauri/icons/Square284x284Logo.png differ diff --git a/desktop/src-tauri/icons/Square30x30Logo.png b/desktop/src-tauri/icons/Square30x30Logo.png index 8c78c988..330ee213 100644 Binary files a/desktop/src-tauri/icons/Square30x30Logo.png and b/desktop/src-tauri/icons/Square30x30Logo.png differ diff --git a/desktop/src-tauri/icons/Square310x310Logo.png b/desktop/src-tauri/icons/Square310x310Logo.png index 281538d2..c371943a 100644 Binary files a/desktop/src-tauri/icons/Square310x310Logo.png and b/desktop/src-tauri/icons/Square310x310Logo.png differ diff --git a/desktop/src-tauri/icons/Square44x44Logo.png b/desktop/src-tauri/icons/Square44x44Logo.png index 510dbf79..266ea978 100644 Binary files a/desktop/src-tauri/icons/Square44x44Logo.png and b/desktop/src-tauri/icons/Square44x44Logo.png differ diff --git a/desktop/src-tauri/icons/Square71x71Logo.png b/desktop/src-tauri/icons/Square71x71Logo.png index 90ac2049..4e5e1a81 100644 Binary files a/desktop/src-tauri/icons/Square71x71Logo.png and b/desktop/src-tauri/icons/Square71x71Logo.png differ diff --git a/desktop/src-tauri/icons/Square89x89Logo.png b/desktop/src-tauri/icons/Square89x89Logo.png index c266dc85..cac6b571 100644 Binary files a/desktop/src-tauri/icons/Square89x89Logo.png and b/desktop/src-tauri/icons/Square89x89Logo.png differ diff --git a/desktop/src-tauri/icons/StoreLogo.png b/desktop/src-tauri/icons/StoreLogo.png index cd0c0266..dc2cc91d 100644 Binary files a/desktop/src-tauri/icons/StoreLogo.png and b/desktop/src-tauri/icons/StoreLogo.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png index 533478c5..c37e2cda 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png and b/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png index a6b36bac..274bb8df 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png and b/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png b/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png index 9332b6a1..c5a1d511 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png and b/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png b/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png index fb28c4fc..806a5227 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png and b/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png index 092b10a8..ffa26a92 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png and b/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png b/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png index 3b7fa755..671e6925 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png and b/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png b/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png index afb60a52..2b73cacd 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png and b/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png index 9fe160e6..6f8a6591 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png and b/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png b/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png index 9ecb360d..d14ea701 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png and b/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png index 8f4a2816..ec497f3f 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png and b/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png index 2dbd7801..4cd2cce0 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png and b/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png index 1cc8ed6d..686cba35 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png and b/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png index e4fd63d6..8b79823a 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png and b/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png index e19625b1..cc36933d 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png and b/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png index 13d5f894..d991e6f6 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png and b/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/desktop/src-tauri/icons/icon.icns b/desktop/src-tauri/icons/icon.icns index c005481d..398851de 100644 Binary files a/desktop/src-tauri/icons/icon.icns and b/desktop/src-tauri/icons/icon.icns differ diff --git a/desktop/src-tauri/icons/icon.ico b/desktop/src-tauri/icons/icon.ico index c914299a..f951ee90 100644 Binary files a/desktop/src-tauri/icons/icon.ico and b/desktop/src-tauri/icons/icon.ico differ diff --git a/desktop/src-tauri/icons/icon.png b/desktop/src-tauri/icons/icon.png index 4f415f61..bf3255e0 100644 Binary files a/desktop/src-tauri/icons/icon.png and b/desktop/src-tauri/icons/icon.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png b/desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png index ea97dfb1..a166f1f6 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png and b/desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png index 04ff6a6e..f01debfa 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png and b/desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png b/desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png index 04ff6a6e..f01debfa 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png and b/desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png b/desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png index 98bc7501..6d48bc00 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png and b/desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png b/desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png index d222072e..437dce50 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png and b/desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png index 2b1b3a85..9bfa4244 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png and b/desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png b/desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png index 2b1b3a85..9bfa4244 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png and b/desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png b/desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png index fa40e74c..a43989b0 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png and b/desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png b/desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png index 04ff6a6e..f01debfa 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png and b/desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png index 880a13d8..f63dac44 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png and b/desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png b/desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png index 880a13d8..f63dac44 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png and b/desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png b/desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png index 271a343e..8f04e2de 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png and b/desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-512@2x.png b/desktop/src-tauri/icons/ios/AppIcon-512@2x.png index 62bbd5f6..aaf24f03 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-512@2x.png and b/desktop/src-tauri/icons/ios/AppIcon-512@2x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png b/desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png index 271a343e..8f04e2de 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png and b/desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png b/desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png index 4fffc80d..8a2c4614 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png and b/desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png b/desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png index 866cd10a..caf57fd2 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png and b/desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png b/desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png index bcfc5631..633238a4 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png and b/desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png index 3780ce30..3c01a7b1 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png and b/desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png differ diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 7671fce4..44b68110 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -1,14 +1,22 @@ use std::{ - io::{Error as IoError, ErrorKind}, + collections::HashMap, + io::{Error as IoError, ErrorKind, Read, Write}, net::{SocketAddr, TcpListener, TcpStream}, path::PathBuf, - sync::Mutex, + process::{Command as StdCommand, Stdio}, + str, + sync::{ + atomic::{AtomicU32, Ordering}, + Mutex, + }, + thread, time::{Duration, Instant}, }; +use portable_pty::{native_pty_system, ChildKiller, CommandBuilder, MasterPty, PtySize}; +use serde::Serialize; #[cfg(target_os = "macos")] use tauri::menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder}; -#[cfg(target_os = "macos")] use tauri::Emitter; use tauri::{AppHandle, Manager, RunEvent, State}; use tauri_plugin_shell::{ @@ -39,6 +47,38 @@ struct ServerStatus { #[derive(Default)] struct AdapterState(Mutex>); +#[derive(Default)] +struct TerminalState { + next_id: AtomicU32, + sessions: Mutex>, +} + +struct TerminalSession { + master: Box, + writer: Mutex>, + killer: Mutex>, +} + +#[derive(Serialize, Clone)] +struct TerminalSpawnResult { + session_id: u32, + shell: String, + cwd: String, +} + +#[derive(Serialize, Clone)] +struct TerminalOutputPayload { + session_id: u32, + data: String, +} + +#[derive(Serialize, Clone)] +struct TerminalExitPayload { + session_id: u32, + code: u32, + signal: Option, +} + #[tauri::command] fn get_server_url(state: State<'_, ServerState>) -> Result { let guard = state @@ -72,6 +112,395 @@ fn restart_adapters_sidecar(app: AppHandle) -> Result<(), String> { Ok(()) } +#[tauri::command] +fn terminal_spawn( + app: AppHandle, + state: State<'_, TerminalState>, + cols: u16, + rows: u16, + cwd: Option, +) -> Result { + let cwd_path = resolve_terminal_cwd(cwd)?; + let shell = default_shell(); + let pty_system = native_pty_system(); + let pair = pty_system + .openpty(PtySize { + rows: rows.max(8), + cols: cols.max(20), + pixel_width: 0, + pixel_height: 0, + }) + .map_err(|err| format!("open terminal pty: {err}"))?; + + let mut cmd = CommandBuilder::new(&shell); + cmd.cwd(cwd_path.as_os_str()); + for (key, value) in terminal_environment(&shell) { + cmd.env(key, value); + } + cmd.env("TERM", "xterm-256color"); + cmd.env("COLORTERM", "truecolor"); + + let mut child = pair + .slave + .spawn_command(cmd) + .map_err(|err| format!("spawn terminal shell: {err}"))?; + drop(pair.slave); + + let mut reader = pair + .master + .try_clone_reader() + .map_err(|err| format!("clone terminal reader: {err}"))?; + let writer = pair + .master + .take_writer() + .map_err(|err| format!("open terminal writer: {err}"))?; + let killer = child.clone_killer(); + let session_id = state.next_id.fetch_add(1, Ordering::Relaxed) + 1; + + { + let mut sessions = state + .sessions + .lock() + .map_err(|_| "terminal state is unavailable".to_string())?; + sessions.insert( + session_id, + TerminalSession { + master: pair.master, + writer: Mutex::new(writer), + killer: Mutex::new(killer), + }, + ); + } + + let output_app = app.clone(); + thread::spawn(move || { + let mut buffer = [0_u8; 8192]; + let mut pending_utf8 = Vec::new(); + loop { + match reader.read(&mut buffer) { + Ok(0) => break, + Ok(n) => { + let data = decode_terminal_output(&mut pending_utf8, &buffer[..n]); + if !data.is_empty() { + let _ = output_app.emit( + "terminal-output", + TerminalOutputPayload { session_id, data }, + ); + } + } + Err(err) => { + let _ = output_app.emit( + "terminal-output", + TerminalOutputPayload { + session_id, + data: format!("\r\n[terminal read error: {err}]\r\n"), + }, + ); + break; + } + } + } + if !pending_utf8.is_empty() { + let data = String::from_utf8_lossy(&pending_utf8).to_string(); + let _ = output_app.emit( + "terminal-output", + TerminalOutputPayload { session_id, data }, + ); + } + }); + + let exit_app = app.clone(); + thread::spawn(move || { + let status = child.wait(); + if let Some(state) = exit_app.try_state::() { + if let Ok(mut sessions) = state.sessions.lock() { + sessions.remove(&session_id); + } + } + match status { + Ok(status) => { + let _ = exit_app.emit( + "terminal-exit", + TerminalExitPayload { + session_id, + code: status.exit_code(), + signal: status.signal().map(ToString::to_string), + }, + ); + } + Err(err) => { + let _ = exit_app.emit( + "terminal-output", + TerminalOutputPayload { + session_id, + data: format!("\r\n[terminal wait error: {err}]\r\n"), + }, + ); + } + } + }); + + Ok(TerminalSpawnResult { + session_id, + shell, + cwd: cwd_path.to_string_lossy().to_string(), + }) +} + +#[tauri::command] +fn terminal_write( + state: State<'_, TerminalState>, + session_id: u32, + data: String, +) -> Result<(), String> { + let sessions = state + .sessions + .lock() + .map_err(|_| "terminal state is unavailable".to_string())?; + let session = sessions + .get(&session_id) + .ok_or_else(|| "terminal session is not running".to_string())?; + let mut writer = session + .writer + .lock() + .map_err(|_| "terminal writer is unavailable".to_string())?; + writer + .write_all(data.as_bytes()) + .map_err(|err| format!("write terminal input: {err}"))?; + writer + .flush() + .map_err(|err| format!("flush terminal input: {err}"))?; + Ok(()) +} + +#[tauri::command] +fn terminal_resize( + state: State<'_, TerminalState>, + session_id: u32, + cols: u16, + rows: u16, +) -> Result<(), String> { + let sessions = state + .sessions + .lock() + .map_err(|_| "terminal state is unavailable".to_string())?; + let session = sessions + .get(&session_id) + .ok_or_else(|| "terminal session is not running".to_string())?; + session + .master + .resize(PtySize { + rows: rows.max(8), + cols: cols.max(20), + pixel_width: 0, + pixel_height: 0, + }) + .map_err(|err| format!("resize terminal: {err}"))?; + Ok(()) +} + +#[tauri::command] +fn terminal_kill(state: State<'_, TerminalState>, session_id: u32) -> Result<(), String> { + let session = { + let mut sessions = state + .sessions + .lock() + .map_err(|_| "terminal state is unavailable".to_string())?; + sessions.remove(&session_id) + }; + + if let Some(session) = session { + let mut killer = session + .killer + .lock() + .map_err(|_| "terminal killer is unavailable".to_string())?; + killer + .kill() + .map_err(|err| format!("kill terminal shell: {err}"))?; + } + Ok(()) +} + +fn decode_terminal_output(pending: &mut Vec, chunk: &[u8]) -> String { + pending.extend_from_slice(chunk); + let mut output = String::new(); + + loop { + match str::from_utf8(pending) { + Ok(text) => { + output.push_str(text); + pending.clear(); + break; + } + Err(err) => { + let valid_up_to = err.valid_up_to(); + if valid_up_to > 0 { + let text = str::from_utf8(&pending[..valid_up_to]) + .expect("valid_up_to marks a valid UTF-8 prefix"); + output.push_str(text); + pending.drain(..valid_up_to); + continue; + } + + match err.error_len() { + Some(error_len) => { + output.push('\u{fffd}'); + pending.drain(..error_len); + } + None => break, + } + } + } + } + + output +} + +fn terminal_environment(shell: &str) -> HashMap { + let mut env: HashMap = std::env::vars().collect(); + env.extend(login_shell_environment(shell)); + ensure_utf8_locale(&mut env); + env +} + +fn ensure_utf8_locale(env: &mut HashMap) { + let fallback = default_utf8_locale(); + for key in ["LANG", "LC_CTYPE", "LC_ALL"] { + let needs_fallback = env + .get(key) + .map(|value| !is_utf8_locale(value)) + .unwrap_or(true); + if needs_fallback { + env.insert(key.to_string(), fallback.to_string()); + } + } +} + +fn is_utf8_locale(value: &str) -> bool { + let normalized = value.trim().to_ascii_lowercase().replace('-', ""); + normalized.contains("utf8") +} + +fn default_utf8_locale() -> &'static str { + #[cfg(target_os = "macos")] + { + "en_US.UTF-8" + } + #[cfg(all(unix, not(target_os = "macos")))] + { + "C.UTF-8" + } + #[cfg(not(unix))] + { + "C.UTF-8" + } +} + +#[cfg(not(target_os = "windows"))] +fn login_shell_environment(shell: &str) -> HashMap { + let Ok(mut child) = StdCommand::new(shell) + .args(["-l", "-c", "env -0"]) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + else { + return HashMap::new(); + }; + + let deadline = Instant::now() + Duration::from_secs(2); + loop { + match child.try_wait() { + Ok(Some(status)) => { + if !status.success() { + return HashMap::new(); + } + let mut stdout = Vec::new(); + if let Some(mut pipe) = child.stdout.take() { + let _ = pipe.read_to_end(&mut stdout); + } + return parse_env_block(&stdout); + } + Ok(None) if Instant::now() < deadline => { + thread::sleep(Duration::from_millis(25)); + } + Ok(None) => { + let _ = child.kill(); + let _ = child.wait(); + return HashMap::new(); + } + Err(_) => return HashMap::new(), + } + } +} + +#[cfg(target_os = "windows")] +fn login_shell_environment(_shell: &str) -> HashMap { + HashMap::new() +} + +fn parse_env_block(bytes: &[u8]) -> HashMap { + bytes + .split(|byte| *byte == 0) + .filter_map(|entry| { + if entry.is_empty() { + return None; + } + let equals = entry.iter().position(|byte| *byte == b'=')?; + if equals == 0 { + return None; + } + let key = String::from_utf8_lossy(&entry[..equals]).to_string(); + let value = String::from_utf8_lossy(&entry[equals + 1..]).to_string(); + Some((key, value)) + }) + .collect() +} + +fn resolve_terminal_cwd(cwd: Option) -> Result { + let path = match cwd.and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(PathBuf::from(trimmed)) + } + }) { + Some(path) => path, + None => home_dir().unwrap_or( + std::env::current_dir().map_err(|err| format!("resolve current directory: {err}"))?, + ), + }; + + if path.is_dir() { + Ok(path) + } else { + Err(format!("terminal cwd does not exist: {}", path.display())) + } +} + +fn home_dir() -> Option { + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) +} + +fn default_shell() -> String { + #[cfg(target_os = "windows")] + { + std::env::var("COMSPEC").unwrap_or_else(|_| "powershell.exe".to_string()) + } + #[cfg(not(target_os = "windows"))] + { + std::env::var("SHELL").unwrap_or_else(|_| { + if PathBuf::from("/bin/zsh").exists() { + "/bin/zsh".to_string() + } else { + "/bin/bash".to_string() + } + }) + } +} + fn reserve_local_port() -> Result { let listener = TcpListener::bind("127.0.0.1:0").map_err(|err| format!("bind local port: {err}"))?; @@ -114,8 +543,7 @@ fn resolve_app_root(_app: &AppHandle) -> Result { // 我们直接用当前可执行文件所在目录作为 app_root: // Dev: desktop/src-tauri/target// (rust 跑出来的 binary 那一层) // Prod: .app/Contents/MacOS/ (sidecar 二进制的同级目录) - let exe = std::env::current_exe() - .map_err(|err| format!("resolve current exe path: {err}"))?; + let exe = std::env::current_exe().map_err(|err| format!("resolve current exe path: {err}"))?; let dir = exe .parent() .ok_or_else(|| "current exe has no parent dir".to_string())? @@ -293,26 +721,114 @@ fn stop_adapters_sidecar(app: &AppHandle) { } } +#[cfg(test)] +mod tests { + use super::{decode_terminal_output, default_utf8_locale, ensure_utf8_locale, parse_env_block}; + use std::collections::HashMap; + + #[test] + fn terminal_output_decoder_preserves_split_chinese_characters() { + let mut pending = Vec::new(); + let bytes = "安装 Skills 成功\n".as_bytes(); + + assert_eq!(decode_terminal_output(&mut pending, &bytes[..2]), ""); + assert_eq!(decode_terminal_output(&mut pending, &bytes[2..4]), "安"); + assert_eq!( + decode_terminal_output(&mut pending, &bytes[4..]), + "装 Skills 成功\n" + ); + assert!(pending.is_empty()); + } + + #[test] + fn terminal_output_decoder_keeps_incomplete_suffix_pending() { + let mut pending = Vec::new(); + let bytes = "中文".as_bytes(); + + assert_eq!(decode_terminal_output(&mut pending, &bytes[..4]), "中"); + assert_eq!(pending, bytes[3..4]); + assert_eq!(decode_terminal_output(&mut pending, &bytes[4..]), "文"); + assert!(pending.is_empty()); + } + + #[test] + fn parse_env_block_reads_nul_delimited_values() { + let env = + parse_env_block(b"PATH=/opt/homebrew/bin:/usr/bin\0NODE_PATH=/tmp/node\0EMPTY=\0"); + + assert_eq!( + env.get("PATH").map(String::as_str), + Some("/opt/homebrew/bin:/usr/bin") + ); + assert_eq!(env.get("NODE_PATH").map(String::as_str), Some("/tmp/node")); + assert_eq!(env.get("EMPTY").map(String::as_str), Some("")); + } + + #[test] + fn terminal_environment_forces_utf8_locale_when_shell_uses_c_locale() { + let mut env = HashMap::from([ + ("LANG".to_string(), "C".to_string()), + ("LC_CTYPE".to_string(), "POSIX".to_string()), + ("LC_ALL".to_string(), "C".to_string()), + ]); + + ensure_utf8_locale(&mut env); + + assert_eq!( + env.get("LANG").map(String::as_str), + Some(default_utf8_locale()) + ); + assert_eq!( + env.get("LC_CTYPE").map(String::as_str), + Some(default_utf8_locale()) + ); + assert_eq!( + env.get("LC_ALL").map(String::as_str), + Some(default_utf8_locale()) + ); + } + + #[test] + fn terminal_environment_keeps_existing_utf8_locale() { + let mut env = HashMap::from([ + ("LANG".to_string(), "zh_CN.UTF-8".to_string()), + ("LC_CTYPE".to_string(), "en_US.UTF8".to_string()), + ("LC_ALL".to_string(), "C.UTF-8".to_string()), + ]); + + ensure_utf8_locale(&mut env); + + assert_eq!(env.get("LANG").map(String::as_str), Some("zh_CN.UTF-8")); + assert_eq!(env.get("LC_CTYPE").map(String::as_str), Some("en_US.UTF8")); + assert_eq!(env.get("LC_ALL").map(String::as_str), Some("C.UTF-8")); + } +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let builder = tauri::Builder::default() .manage(ServerState::default()) .manage(AdapterState::default()) + .manage(TerminalState::default()) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_updater::Builder::new().build()) .invoke_handler(tauri::generate_handler![ get_server_url, - restart_adapters_sidecar + restart_adapters_sidecar, + terminal_spawn, + terminal_write, + terminal_resize, + terminal_kill ]); // macOS: native menu bar (traffic-light overlay style) #[cfg(target_os = "macos")] let builder = builder .menu(|app| { - let about_item = MenuItemBuilder::with_id("nav_about", "关于 Claude Code Haha") - .build(app)?; + let about_item = + MenuItemBuilder::with_id("nav_about", "关于 Claude Code Haha").build(app)?; let settings_item = MenuItemBuilder::with_id("nav_settings", "设置...") .accelerator("CmdOrCtrl+,") .build(app)?; @@ -341,9 +857,7 @@ pub fn run() { .select_all() .build()?; - let view_submenu = SubmenuBuilder::new(app, "View") - .fullscreen() - .build()?; + let view_submenu = SubmenuBuilder::new(app, "View").fullscreen().build()?; let window_submenu = SubmenuBuilder::new(app, "Window") .minimize() diff --git a/desktop/src/__tests__/agentsSettings.test.tsx b/desktop/src/__tests__/agentsSettings.test.tsx index e124362d..c7c850ee 100644 --- a/desktop/src/__tests__/agentsSettings.test.tsx +++ b/desktop/src/__tests__/agentsSettings.test.tsx @@ -22,8 +22,11 @@ vi.mock('../stores/providerStore', () => ({ useProviderStore: () => ({ providers: [], activeId: null, + presets: [], isLoading: false, + isPresetsLoading: false, fetchProviders: vi.fn(), + fetchPresets: vi.fn(), deleteProvider: vi.fn(), activateProvider: vi.fn(), activateOfficial: vi.fn(), diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index c53c2996..6b3d031c 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -1,11 +1,30 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { fireEvent, render, screen } from '@testing-library/react' +import { fireEvent, render, screen, within } from '@testing-library/react' import '@testing-library/jest-dom' import { Settings } from '../pages/Settings' import { useSettingsStore } from '../stores/settingsStore' import { useUIStore } from '../stores/uiStore' import { useUpdateStore } from '../stores/updateStore' +import type { SavedProvider } from '../types/provider' + +const MOCK_DELETE_PROVIDER = vi.fn() +const providerStoreState = { + providers: [] as SavedProvider[], + activeId: null, + presets: [], + isLoading: false, + isPresetsLoading: false, + fetchProviders: vi.fn(), + fetchPresets: vi.fn(), + deleteProvider: MOCK_DELETE_PROVIDER, + activateProvider: vi.fn(), + activateOfficial: vi.fn(), + testProvider: vi.fn(), + createProvider: vi.fn(), + updateProvider: vi.fn(), + testConfig: vi.fn(), +} vi.mock('../api/agents', () => ({ agentsApi: { @@ -14,19 +33,7 @@ vi.mock('../api/agents', () => ({ })) vi.mock('../stores/providerStore', () => ({ - useProviderStore: () => ({ - providers: [], - activeId: null, - isLoading: false, - fetchProviders: vi.fn(), - deleteProvider: vi.fn(), - activateProvider: vi.fn(), - activateOfficial: vi.fn(), - testProvider: vi.fn(), - createProvider: vi.fn(), - updateProvider: vi.fn(), - testConfig: vi.fn(), - }), + useProviderStore: () => providerStoreState, })) vi.mock('../pages/AdapterSettings', () => ({ @@ -64,6 +71,21 @@ vi.mock('../components/chat/CodeViewer', () => ({ describe('Settings > General tab', () => { beforeEach(() => { + MOCK_DELETE_PROVIDER.mockReset() + providerStoreState.providers = [] + providerStoreState.activeId = null + providerStoreState.presets = [] + providerStoreState.isLoading = false + providerStoreState.isPresetsLoading = false + providerStoreState.fetchProviders = vi.fn() + providerStoreState.fetchPresets = vi.fn() + providerStoreState.activateProvider = vi.fn() + providerStoreState.activateOfficial = vi.fn() + providerStoreState.testProvider = vi.fn() + providerStoreState.createProvider = vi.fn() + providerStoreState.updateProvider = vi.fn() + providerStoreState.testConfig = vi.fn() + useSettingsStore.setState({ locale: 'en', skipWebFetchPreflight: true, @@ -109,6 +131,53 @@ describe('Settings > General tab', () => { expect(useSettingsStore.getState().setSkipWebFetchPreflight).toHaveBeenCalledWith(false) }) + + it('keeps extension tabs available alongside the terminal tab', () => { + render() + + expect(screen.queryByText('Install')).not.toBeInTheDocument() + expect(screen.getByText('Terminal')).toBeInTheDocument() + expect(screen.getByText('MCP')).toBeInTheDocument() + expect(screen.getByText('Plugins')).toBeInTheDocument() + }) +}) + +describe('Settings > Providers tab', () => { + beforeEach(() => { + MOCK_DELETE_PROVIDER.mockReset() + providerStoreState.providers = [ + { + id: 'provider-1', + name: 'MiniMax-M2.7-highspeed(openai)', + presetId: 'custom', + apiKey: '***', + baseUrl: 'https://api.minimaxi.com', + apiFormat: 'openai_chat', + models: { + main: 'MiniMax-M2.7-highspeed', + haiku: '', + sonnet: '', + opus: '', + }, + notes: '', + }, + ] + }) + + it('requires confirmation before deleting a provider', async () => { + render() + + fireEvent.click(screen.getAllByText('Delete')[0]!) + + expect(MOCK_DELETE_PROVIDER).not.toHaveBeenCalled() + expect(screen.getByRole('dialog')).toBeInTheDocument() + expect(screen.getByText('Delete provider "MiniMax-M2.7-highspeed(openai)"? This cannot be undone.')).toBeInTheDocument() + + const dialog = screen.getByRole('dialog') + fireEvent.click(within(dialog).getByRole('button', { name: 'Delete' })) + + expect(MOCK_DELETE_PROVIDER).toHaveBeenCalledWith('provider-1') + }) }) describe('Settings > About tab', () => { diff --git a/desktop/src/__tests__/mcpSettings.test.tsx b/desktop/src/__tests__/mcpSettings.test.tsx index 973cad37..418dd5c3 100644 --- a/desktop/src/__tests__/mcpSettings.test.tsx +++ b/desktop/src/__tests__/mcpSettings.test.tsx @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { render, screen } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import '@testing-library/jest-dom' import { McpSettings } from '../pages/McpSettings' @@ -38,6 +38,7 @@ describe('McpSettings', () => { }) useMcpStore.setState({ servers: [], + selectedServer: null, isLoading: false, error: null, fetchServers: vi.fn(), @@ -46,6 +47,8 @@ describe('McpSettings', () => { deleteServer: vi.fn(), toggleServer: vi.fn(), reconnectServer: vi.fn(), + refreshServerStatus: vi.fn(), + selectServer: vi.fn(), }) }) @@ -109,4 +112,166 @@ describe('McpSettings', () => { expect(screen.getByText('plugin:telegram:telegram')).toBeInTheDocument() expect(screen.getByText('global-user')).toBeInTheDocument() }) + + it('starts background status refresh after the fast list render', async () => { + const server = { + name: 'deepwiki', + scope: 'user', + transport: 'http', + enabled: true, + status: 'checking' as const, + statusLabel: 'Checking', + configLocation: '/tmp/config', + summary: 'https://example.com/mcp', + canEdit: true, + canRemove: true, + canReconnect: true, + canToggle: true, + config: { type: 'http' as const, url: 'https://example.com/mcp', headers: {} }, + } + const refreshServerStatus = vi.fn().mockResolvedValue({ + ...server, + status: 'connected' as const, + statusLabel: 'Connected', + }) + + useMcpStore.setState({ + servers: [server], + refreshServerStatus, + }) + + render() + + expect(screen.getByText('Checking')).toBeInTheDocument() + + await waitFor(() => { + expect(refreshServerStatus).toHaveBeenCalledWith(server, '/workspace/project') + }) + }) + + it('opens the delete confirmation modal from the edit view and deletes with the active cwd', async () => { + const deleteServer = vi.fn().mockResolvedValue(undefined) + const server = { + name: 'global-user', + scope: 'user', + transport: 'http', + enabled: true, + status: 'connected', + statusLabel: 'Connected', + configLocation: '/tmp/config', + summary: 'https://example.com/mcp', + canEdit: true, + canRemove: true, + canReconnect: true, + canToggle: true, + config: { type: 'http', url: 'https://example.com/mcp', headers: {} }, + } as const + + useMcpStore.setState({ + servers: [server], + deleteServer, + }) + + render() + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Open global-user' })) + }) + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /uninstall/i })) + }) + + expect(screen.getByRole('dialog')).toBeInTheDocument() + expect(screen.getByText('Delete MCP server')).toBeInTheDocument() + expect(screen.getByText('Delete MCP server "global-user"? This action cannot be undone.')).toBeInTheDocument() + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Delete' })) + }) + + expect(deleteServer).toHaveBeenCalledWith(server, '/workspace/project') + }) + + it('uses the active cwd when toggling a server', async () => { + const toggleServer = vi.fn().mockResolvedValue(undefined) + const server = { + name: 'global-user', + scope: 'user', + transport: 'http', + enabled: true, + status: 'connected', + statusLabel: 'Connected', + configLocation: '/tmp/config', + summary: 'https://example.com/mcp', + canEdit: true, + canRemove: true, + canReconnect: true, + canToggle: true, + config: { type: 'http', url: 'https://example.com/mcp', headers: {} }, + } as const + + useMcpStore.setState({ + servers: [server], + toggleServer, + }) + + render() + + await act(async () => { + fireEvent.click(screen.getByRole('switch')) + }) + + expect(toggleServer).toHaveBeenCalledWith(server, '/workspace/project') + }) + + it('shows reconnecting status immediately in the detail view', async () => { + let resolveReconnect: ((value: typeof server) => void) | null = null + const server = { + name: 'plugin:telegram:telegram', + scope: 'dynamic', + transport: 'stdio', + enabled: true, + status: 'failed' as 'connected' | 'needs-auth' | 'failed' | 'disabled' | 'checking', + statusLabel: 'Unavailable', + statusDetail: 'Timed out' as string | undefined, + configLocation: '/tmp/config', + summary: 'bun run start', + canEdit: false, + canRemove: false, + canReconnect: true, + canToggle: true, + config: { type: 'stdio' as const, command: 'bun', args: ['run', 'start'], env: {} }, + } + const reconnectServer = vi.fn().mockImplementation(() => new Promise((resolve) => { + resolveReconnect = resolve + })) + + useMcpStore.setState({ + servers: [server], + reconnectServer, + }) + + render() + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Open plugin:telegram:telegram' })) + }) + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /reconnect/i })) + }) + + expect(screen.getAllByText('Reconnecting...').length).toBeGreaterThan(0) + expect(reconnectServer).toHaveBeenCalledWith(server, '/workspace/project') + + await act(async () => { + resolveReconnect?.({ + ...server, + status: 'connected', + statusLabel: 'Connected', + statusDetail: undefined, + }) + }) + }) }) diff --git a/desktop/src/__tests__/pages.test.tsx b/desktop/src/__tests__/pages.test.tsx index b8656cf2..38990c7f 100644 --- a/desktop/src/__tests__/pages.test.tsx +++ b/desktop/src/__tests__/pages.test.tsx @@ -15,6 +15,23 @@ vi.mock('../api/skills', () => ({ vi.mock('../api/mcp', () => ({ mcpApi: { list: vi.fn(async () => ({ servers: [] })), + status: vi.fn(async (name: string) => ({ + server: { + name, + scope: 'user', + transport: 'http', + enabled: true, + status: 'connected', + statusLabel: 'Connected', + configLocation: 'User', + summary: 'https://mcp.example.com/mcp', + canEdit: true, + canRemove: true, + canReconnect: true, + canToggle: true, + config: { type: 'http', url: 'https://mcp.example.com/mcp', headers: {} }, + }, + })), }, })) diff --git a/desktop/src/__tests__/pluginsSettings.test.tsx b/desktop/src/__tests__/pluginsSettings.test.tsx index 3e8848e8..11e20e49 100644 --- a/desktop/src/__tests__/pluginsSettings.test.tsx +++ b/desktop/src/__tests__/pluginsSettings.test.tsx @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { fireEvent, render, screen } from '@testing-library/react' +import { act, fireEvent, render, screen } from '@testing-library/react' import '@testing-library/jest-dom' import { Settings } from '../pages/Settings' @@ -23,8 +23,11 @@ vi.mock('../stores/providerStore', () => ({ useProviderStore: () => ({ providers: [], activeId: null, + presets: [], isLoading: false, + isPresetsLoading: false, fetchProviders: vi.fn(), + fetchPresets: vi.fn(), deleteProvider: vi.fn(), activateProvider: vi.fn(), activateOfficial: vi.fn(), @@ -335,6 +338,59 @@ describe('Settings > Plugins tab', () => { expect(screen.getByText('Uninstall')).toBeInTheDocument() }) + it('keeps plugin detail hook order stable while the selected plugin reloads', () => { + usePluginStore.setState({ + selectedPlugin: { + id: 'github@claude-plugins-official', + name: 'github', + marketplace: 'claude-plugins-official', + scope: 'user', + enabled: false, + hasErrors: false, + isBuiltin: false, + description: 'GitHub integration', + componentCounts: { + commands: 1, + agents: 0, + skills: 0, + hooks: 0, + mcpServers: 0, + lspServers: 0, + }, + capabilities: { + commands: ['review-pr'], + agents: [], + skills: [], + hooks: [], + mcpServers: [], + lspServers: [], + }, + commandEntries: [ + { + name: 'review-pr', + description: 'Review the current pull request.', + }, + ], + agentEntries: [], + hookEntries: [], + skillEntries: [], + mcpServerEntries: [], + errors: [], + }, + }) + + const { container } = render() + switchToPluginsTab() + + expect(screen.getByText('GitHub integration')).toBeInTheDocument() + + act(() => { + usePluginStore.setState({ isDetailLoading: true }) + }) + + expect(container.querySelector('.animate-spin')).toBeInTheDocument() + }) + it('navigates plugin skills into the shared Skills page flow', () => { usePluginStore.setState({ selectedPlugin: { diff --git a/desktop/src/__tests__/skillsSettings.test.tsx b/desktop/src/__tests__/skillsSettings.test.tsx index 8280b647..d13a996d 100644 --- a/desktop/src/__tests__/skillsSettings.test.tsx +++ b/desktop/src/__tests__/skillsSettings.test.tsx @@ -19,8 +19,11 @@ vi.mock('../stores/providerStore', () => ({ useProviderStore: () => ({ providers: [], activeId: null, + presets: [], isLoading: false, + isPresetsLoading: false, fetchProviders: vi.fn(), + fetchPresets: vi.fn(), deleteProvider: vi.fn(), activateProvider: vi.fn(), activateOfficial: vi.fn(), diff --git a/desktop/src/api/client.ts b/desktop/src/api/client.ts index 78468751..883d14c7 100644 --- a/desktop/src/api/client.ts +++ b/desktop/src/api/client.ts @@ -9,6 +9,18 @@ const DEFAULT_BASE_URL = ENV_BASE_URL || 'http://127.0.0.1:3456' let baseUrl = DEFAULT_BASE_URL +function getErrorMessage(status: number, body: unknown) { + if (body && typeof body === 'object' && 'message' in body && typeof body.message === 'string') { + return body.message + } + + if (typeof body === 'string' && body.trim().length > 0) { + return body + } + + return `API error ${status}` +} + export function setBaseUrl(url: string) { baseUrl = url.replace(/\/$/, '') } @@ -26,7 +38,7 @@ export class ApiError extends Error { public status: number, public body: unknown, ) { - super(`API error ${status}: ${typeof body === 'string' ? body : JSON.stringify(body)}`) + super(getErrorMessage(status, body)) this.name = 'ApiError' } } @@ -38,7 +50,8 @@ async function request(method: string, path: string, body?: unknown, options? } const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), options?.timeout ?? 30_000) + const timeoutMs = options?.timeout ?? 30_000 + const timeout = setTimeout(() => controller.abort(), timeoutMs) try { const res = await fetch(url, { method, @@ -57,6 +70,9 @@ async function request(method: string, path: string, body?: unknown, options? return res.json() as Promise } catch (err) { clearTimeout(timeout) + if (controller.signal.aborted) { + throw new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`) + } throw err } } diff --git a/desktop/src/api/mcp.ts b/desktop/src/api/mcp.ts index 550c9b67..113aa81b 100644 --- a/desktop/src/api/mcp.ts +++ b/desktop/src/api/mcp.ts @@ -7,6 +7,11 @@ export const mcpApi = { return api.get<{ servers: McpServerRecord[] }>(`/api/mcp${query}`) }, + status: (name: string, cwd?: string) => { + const query = cwd ? `?cwd=${encodeURIComponent(cwd)}` : '' + return api.get<{ server: McpServerRecord }>(`/api/mcp/${encodeURIComponent(name)}/status${query}`) + }, + create: (name: string, payload: McpUpsertPayload, cwd?: string) => { return api.post<{ server: McpServerRecord }>('/api/mcp', { name, @@ -36,4 +41,3 @@ export const mcpApi = { return api.post<{ server: McpServerRecord }>(`/api/mcp/${encodeURIComponent(name)}/reconnect`, cwd ? { cwd } : {}) }, } - diff --git a/desktop/src/api/plugins.ts b/desktop/src/api/plugins.ts index 11452b77..56bddc8b 100644 --- a/desktop/src/api/plugins.ts +++ b/desktop/src/api/plugins.ts @@ -38,6 +38,10 @@ export const pluginsApi = { reload: (cwd?: string) => { const query = cwd ? `?cwd=${encodeURIComponent(cwd)}` : '' - return api.post<{ ok: true; summary: PluginReloadSummary }>(`/api/plugins/reload${query}`) + return api.post<{ ok: true; summary: PluginReloadSummary }>( + `/api/plugins/reload${query}`, + undefined, + { timeout: 120_000 }, + ) }, } diff --git a/desktop/src/api/providers.ts b/desktop/src/api/providers.ts index 6531a4a2..e19ac1b7 100644 --- a/desktop/src/api/providers.ts +++ b/desktop/src/api/providers.ts @@ -8,7 +8,7 @@ import type { TestProviderConfigInput, ProviderTestResult, } from '../types/provider' -import type { ProviderPreset } from '../config/providerPresets' +import type { ProviderPreset } from '../types/providerPreset' type ProvidersResponse = { providers: SavedProvider[]; activeId: string | null } type ProviderResponse = { provider: SavedProvider } @@ -33,6 +33,14 @@ export const providersApi = { return api.get('/api/providers/auth-status') }, + getSettings() { + return api.get>('/api/providers/settings') + }, + + updateSettings(settings: Record) { + return api.put<{ ok: true }>('/api/providers/settings', settings) + }, + create(input: CreateProviderInput) { return api.post('/api/providers', input) }, diff --git a/desktop/src/api/settings.ts b/desktop/src/api/settings.ts index b3c34d54..4255f943 100644 --- a/desktop/src/api/settings.ts +++ b/desktop/src/api/settings.ts @@ -1,6 +1,20 @@ import { api } from './client' import type { PermissionMode, UserSettings } from '../types/settings' +export type CliLauncherStatus = { + supported: boolean + command: string + installed: boolean + launcherPath: string + binDir: string + pathConfigured: boolean + pathInCurrentShell: boolean + availableInNewTerminals: boolean + needsTerminalRestart: boolean + configTarget: string | null + lastError: string | null +} + export const settingsApi = { getUser() { return api.get('/api/settings/user') @@ -17,4 +31,8 @@ export const settingsApi = { setPermissionMode(mode: PermissionMode) { return api.put<{ ok: true; mode: PermissionMode }>('/api/permissions/mode', { mode }) }, + + getCliLauncherStatus() { + return api.get('/api/settings/cli-launcher') + }, } diff --git a/desktop/src/api/skills.ts b/desktop/src/api/skills.ts index 18b0c878..c0816997 100644 --- a/desktop/src/api/skills.ts +++ b/desktop/src/api/skills.ts @@ -4,7 +4,7 @@ import type { SkillMeta, SkillDetail } from '../types/skill' export const skillsApi = { list: (cwd?: string) => { const query = cwd ? `?cwd=${encodeURIComponent(cwd)}` : '' - return api.get<{ skills: SkillMeta[] }>(`/api/skills${query}`) + return api.get<{ skills: SkillMeta[] }>(`/api/skills${query}`, { timeout: 120_000 }) }, detail: (source: string, name: string, cwd?: string) => { @@ -14,6 +14,9 @@ export const skillsApi = { }) if (cwd) query.set('cwd', cwd) - return api.get<{ detail: SkillDetail }>(`/api/skills/detail?${query.toString()}`) + return api.get<{ detail: SkillDetail }>( + `/api/skills/detail?${query.toString()}`, + { timeout: 120_000 }, + ) }, } diff --git a/desktop/src/api/terminal.ts b/desktop/src/api/terminal.ts new file mode 100644 index 00000000..cd09a6fa --- /dev/null +++ b/desktop/src/api/terminal.ts @@ -0,0 +1,58 @@ +import { isTauriRuntime } from '../lib/desktopRuntime' + +export type TerminalSpawnResult = { + session_id: number + shell: string + cwd: string +} + +export type TerminalOutputPayload = { + session_id: number + data: string +} + +export type TerminalExitPayload = { + session_id: number + code: number + signal?: string | null +} + +type Unlisten = () => void + +async function invoke(command: string, args?: Record): Promise { + if (!isTauriRuntime()) { + throw new Error('Terminal is available in the desktop app runtime.') + } + const api = await import(/* @vite-ignore */ '@tauri-apps/api/core') + return api.invoke(command, args) +} + +export const terminalApi = { + isAvailable: isTauriRuntime, + + spawn(input: { cols: number; rows: number; cwd?: string }) { + return invoke('terminal_spawn', input) + }, + + write(sessionId: number, data: string) { + return invoke('terminal_write', { sessionId, data }) + }, + + resize(sessionId: number, cols: number, rows: number) { + return invoke('terminal_resize', { sessionId, cols, rows }) + }, + + kill(sessionId: number) { + return invoke('terminal_kill', { sessionId }) + }, + + async onOutput(handler: (payload: TerminalOutputPayload) => void): Promise { + const events = await import(/* @vite-ignore */ '@tauri-apps/api/event') + return events.listen('terminal-output', (event) => handler(event.payload)) + }, + + async onExit(handler: (payload: TerminalExitPayload) => void): Promise { + const events = await import(/* @vite-ignore */ '@tauri-apps/api/event') + return events.listen('terminal-exit', (event) => handler(event.payload)) + }, +} diff --git a/desktop/src/components/chat/AskUserQuestion.tsx b/desktop/src/components/chat/AskUserQuestion.tsx index 67006926..93694544 100644 --- a/desktop/src/components/chat/AskUserQuestion.tsx +++ b/desktop/src/components/chat/AskUserQuestion.tsx @@ -135,7 +135,7 @@ export function AskUserQuestion({ toolUseId, input, result }: Props) { if (!activeQuestion) return null return ( -
-
-
- +
+
+
+ {!isStreaming && } {isStreaming && ( )}
-
- + +
) } + +function shouldUseDocumentLayout(content: string) { + const normalized = content.trim() + if (!normalized) return false + + if (/```/.test(normalized)) return true + if (/^\s{0,3}(#{1,6}\s|[-*+]\s|\d+\.\s|>\s|\|.+\|)/m.test(normalized)) return true + + const paragraphs = normalized + .split(/\n\s*\n/) + .map((chunk) => chunk.trim()) + .filter(Boolean) + + return paragraphs.length >= 2 || normalized.split('\n').filter((line) => line.trim()).length >= 8 +} diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index d6004b38..3b29772d 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -4,6 +4,7 @@ import { useChatStore } from '../../stores/chatStore' import { SETTINGS_TAB_ID, useTabStore } from '../../stores/tabStore' import { useUIStore } from '../../stores/uiStore' import { useSessionStore } from '../../stores/sessionStore' +import { useSessionRuntimeStore } from '../../stores/sessionRuntimeStore' import { useTeamStore } from '../../stores/teamStore' import { sessionsApi } from '../../api/sessions' import { PermissionModeSelector } from '../controls/PermissionModeSelector' @@ -669,7 +670,9 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) {
- {!isMemberSession && } + {!isMemberSession && activeTabId && ( + + )}
diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index 09e8866d..a0b77b1d 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -338,6 +338,79 @@ describe('MessageList nested tool calls', () => { ) }) + it('keeps user actions anchored to the right bubble and assistant actions to the left bubble', () => { + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + messages: [ + { + id: 'user-1', + type: 'user_text', + content: '请把这条 prompt 放在右侧', + timestamp: 1, + }, + { + id: 'assistant-1', + type: 'assistant_text', + content: '这条回复应该停在左侧。', + timestamp: 2, + }, + ], + }), + }, + }) + + render() + + const userShell = screen.getByText('请把这条 prompt 放在右侧').closest('[data-message-shell="user"]') + const assistantShell = screen.getByText('这条回复应该停在左侧。').closest('[data-message-shell="assistant"]') + const userActions = screen.getByRole('button', { name: 'Copy prompt' }).closest('[data-message-actions]') + const assistantActions = screen.getByRole('button', { name: 'Copy reply' }).closest('[data-message-actions]') + + expect(userShell).toBeTruthy() + expect(userShell?.className).toContain('items-end') + expect(assistantShell).toBeTruthy() + expect(assistantShell?.className).toContain('items-start') + expect(assistantShell?.className).not.toContain('ml-10') + expect(userActions?.getAttribute('data-align')).toBe('end') + expect(assistantActions?.getAttribute('data-align')).toBe('start') + }) + + it('uses the document column for markdown-heavy assistant replies', () => { + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + messages: [ + { + id: 'assistant-doc', + type: 'assistant_text', + content: [ + '## 交付结果', + '', + '已完成以下内容:', + '', + '- 添加任务', + '- 删除任务', + '', + '```bash', + 'npm run build', + '```', + ].join('\n'), + timestamp: 1, + }, + ], + }), + }, + }) + + render() + + const assistantShell = screen.getByText('交付结果').closest('[data-message-shell="assistant"]') + expect(assistantShell?.getAttribute('data-layout')).toBe('document') + expect(assistantShell?.className).toContain('w-full') + expect(assistantShell?.className).not.toContain('ml-10') + }) + it('opens a rewind preview modal for user messages', async () => { vi.spyOn(sessionsApi, 'rewind').mockResolvedValue({ target: { diff --git a/desktop/src/components/chat/PermissionDialog.tsx b/desktop/src/components/chat/PermissionDialog.tsx index e1de3566..8e12ac17 100644 --- a/desktop/src/components/chat/PermissionDialog.tsx +++ b/desktop/src/components/chat/PermissionDialog.tsx @@ -127,7 +127,7 @@ export function PermissionDialog({ requestId, toolName, input, description }: Pr const allowRawToggle = !preview return ( -
+
{verb}... {elapsedSeconds > 0 && ( diff --git a/desktop/src/components/chat/ThinkingBlock.tsx b/desktop/src/components/chat/ThinkingBlock.tsx index dc97706e..3975525d 100644 --- a/desktop/src/components/chat/ThinkingBlock.tsx +++ b/desktop/src/components/chat/ThinkingBlock.tsx @@ -18,7 +18,7 @@ export function ThinkingBlock({ content, isActive = false }: { content: string; const preview = firstLine.length > 80 ? firstLine.slice(0, 80) + '...' : firstLine return ( -
+
{open && ( -
- {/* Models */} -
-
+
+
+
{t('model.configuration')}
-
- {availableModels.map((model) => { - const isSelected = model.id === selectedModel?.id - return ( - + ) + })}
- - ) - })} -
+
+ ))} +
+ ) : ( +
+ {availableModels.map((model) => { + const isSelected = model.id === selectedModel?.id + return ( + + ) + })} +
+ )}
- {/* Effort — hidden in controlled mode (not relevant for task creation) */} - {!isControlled &&
-
- {t('model.effort')} + {!isControlled && !isRuntimeScoped && ( +
+
+ {t('model.effort')} +
+
+ {EFFORT_OPTIONS.map((opt) => { + const isSelected = opt.value === effortLevel + return ( + + ) + })} +
-
- {EFFORT_OPTIONS.map((opt) => { - const isSelected = opt.value === effortLevel - return ( - - ) - })} -
-
} + )}
)}
diff --git a/desktop/src/components/layout/AppShell.tsx b/desktop/src/components/layout/AppShell.tsx index 44245885..1d661d8a 100644 --- a/desktop/src/components/layout/AppShell.tsx +++ b/desktop/src/components/layout/AppShell.tsx @@ -29,8 +29,9 @@ export function AppShell() { // Restore tabs from localStorage await useTabStore.getState().restoreTabs() - const activeId = useTabStore.getState().activeTabId - if (activeId) { + const { activeTabId: activeId, tabs } = useTabStore.getState() + const activeTab = tabs.find((tab) => tab.sessionId === activeId) + if (activeId && activeTab?.type === 'session') { useChatStore.getState().connectToSession(activeId) } if (!cancelled) { diff --git a/desktop/src/components/layout/ProjectFilter.tsx b/desktop/src/components/layout/ProjectFilter.tsx index f94fc0c2..3a370929 100644 --- a/desktop/src/components/layout/ProjectFilter.tsx +++ b/desktop/src/components/layout/ProjectFilter.tsx @@ -23,7 +23,7 @@ let cachedProjects: RecentProject[] | null = null let cacheTimestamp = 0 const CACHE_TTL = 30_000 -export function ProjectFilter() { +export function ProjectFilter({ variant = 'default' }: { variant?: 'default' | 'embedded' }) { const t = useTranslation() const { availableProjects, selectedProjects, setSelectedProjects } = useSessionStore() const [open, setOpen] = useState(false) @@ -134,6 +134,7 @@ export function ProjectFilter() { : selectedProjects.length === 1 ? optionByPath.get(selectedProjects[0]!)?.title || fallbackProjectTitle(selectedProjects[0]!, t('sidebar.other')) : `${selectedProjects.length} projects` + const triggerLabel = isAllSelected ? t('sidebar.allProjects') : label const toggleProject = (projectPath: string) => { if (isAllSelected) { @@ -160,19 +161,45 @@ export function ProjectFilter() { type="button" onClick={() => setOpen((current) => !current)} aria-expanded={open} - className="flex items-center gap-1.5 rounded-[var(--radius-lg)] bg-[var(--color-surface-container-low)] px-3 py-2 text-sm font-medium text-[var(--color-text-primary)] shadow-[0_1px_0_rgba(255,255,255,0.6)] transition-colors hover:bg-[var(--color-surface-hover)]" + aria-label={triggerLabel} + title={triggerLabel} + className={ + variant === 'embedded' + ? `inline-flex h-7 w-7 items-center justify-center rounded-[8px] border transition-colors duration-200 ${ + isAllSelected + ? 'border-transparent text-[var(--color-text-tertiary)] hover:bg-[var(--color-sidebar-item-hover)] hover:text-[var(--color-text-secondary)]' + : 'border-[var(--color-sidebar-item-active-border)] bg-[var(--color-sidebar-item-active)] text-[var(--color-text-primary)] hover:bg-[var(--color-sidebar-item-hover)]' + }` + : 'inline-flex h-8 max-w-full items-center gap-1.5 rounded-[10px] border border-[var(--color-sidebar-filter-border)] bg-[var(--color-sidebar-filter-bg)] px-2 text-left text-[14px] text-[var(--color-text-primary)] transition-colors duration-200 hover:bg-[var(--color-sidebar-item-hover)]' + } > - {label} - + {variant === 'embedded' ? ( + + + {!isAllSelected && ( + + )} + + ) : ( + <> + + + {label} + + + + + + )} {open && dropdownPos && createPortal(
- +
{t('sidebar.allProjects')}
@@ -207,11 +238,13 @@ export function ProjectFilter() { key={option.projectPath} type="button" onClick={() => toggleProject(option.projectPath)} - className={`flex w-full items-center gap-3 rounded-[16px] px-4 py-3 text-left transition-colors hover:bg-[var(--color-surface-hover)] ${ - checked ? 'bg-[var(--color-surface-selected)]' : '' + className={`flex w-full items-center gap-3 rounded-[12px] px-3 py-2.5 text-left transition-colors ${ + checked + ? 'bg-[var(--color-sidebar-item-active)]' + : 'hover:bg-[var(--color-sidebar-item-hover)]' }`} > - {option.isGit ? : } + {option.isGit ? : }
{option.title}
{option.subtitle && ( @@ -277,7 +310,7 @@ function ChevronIcon({ open }: { open: boolean }) { ) } -function FolderIcon() { +function FolderIcon({ className = 'text-[var(--color-text-secondary)]' }: { className?: string }) { return ( ) } -function GitBranchIcon() { +function GitBranchIcon({ className = 'text-[var(--color-text-secondary)]' }: { className?: string }) { return ( diff --git a/desktop/src/components/layout/Sidebar.test.tsx b/desktop/src/components/layout/Sidebar.test.tsx index c98ef8fe..f380c64a 100644 --- a/desktop/src/components/layout/Sidebar.test.tsx +++ b/desktop/src/components/layout/Sidebar.test.tsx @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import '@testing-library/jest-dom' vi.mock('./ProjectFilter', () => ({ @@ -17,6 +17,7 @@ vi.mock('../../i18n', () => ({ 'sidebar.noMatching': 'No matching sessions', 'sidebar.sessionListFailed': 'Session list failed', 'common.retry': 'Retry', + 'common.cancel': 'Cancel', 'common.delete': 'Delete', 'common.rename': 'Rename', 'sidebar.timeGroup.today': 'Today', @@ -25,6 +26,7 @@ vi.mock('../../i18n', () => ({ 'sidebar.timeGroup.last30days': 'Last 30 Days', 'sidebar.timeGroup.older': 'Older', 'sidebar.missingDir': 'Missing', + 'sidebar.confirmDelete': 'Delete this session? This cannot be undone.', 'sidebar.collapse': 'Collapse sidebar', 'sidebar.expand': 'Expand sidebar', } @@ -121,7 +123,7 @@ describe('Sidebar', () => { expect(useTabStore.getState().tabs).toEqual([]) }) - it('removes the matching tab when deleting a session from the sidebar', async () => { + it('requires confirmation before deleting a session from the sidebar', async () => { deleteSession.mockResolvedValue(undefined) useSessionStore.setState({ sessions: [ @@ -146,8 +148,15 @@ describe('Sidebar', () => { fireEvent.contextMenu(screen.getByRole('button', { name: /Open Session/ })) + fireEvent.click(screen.getByRole('button', { name: 'Delete' })) + + expect(deleteSession).not.toHaveBeenCalled() + const dialog = screen.getByRole('dialog') + expect(dialog).toBeInTheDocument() + expect(screen.getByText('Delete this session? This cannot be undone.')).toBeInTheDocument() + await act(async () => { - fireEvent.click(screen.getByRole('button', { name: 'Delete' })) + fireEvent.click(within(dialog).getByRole('button', { name: 'Delete' })) }) await waitFor(() => { diff --git a/desktop/src/components/layout/Sidebar.tsx b/desktop/src/components/layout/Sidebar.tsx index 81011251..c0add799 100644 --- a/desktop/src/components/layout/Sidebar.tsx +++ b/desktop/src/components/layout/Sidebar.tsx @@ -3,6 +3,7 @@ import { useSessionStore } from '../../stores/sessionStore' import { useUIStore } from '../../stores/uiStore' import { useTranslation } from '../../i18n' import { ProjectFilter } from './ProjectFilter' +import { ConfirmDialog } from '../shared/ConfirmDialog' import type { SessionListItem } from '../../types/session' import { useTabStore, SETTINGS_TAB_ID, SCHEDULED_TAB_ID } from '../../stores/tabStore' import { useChatStore } from '../../stores/chatStore' @@ -29,6 +30,7 @@ export function Sidebar() { const disconnectSession = useChatStore((s) => s.disconnectSession) const [searchQuery, setSearchQuery] = useState('') const [contextMenu, setContextMenu] = useState<{ id: string; x: number; y: number } | null>(null) + const [pendingDeleteSessionId, setPendingDeleteSessionId] = useState(null) const [renamingId, setRenamingId] = useState(null) const [renameValue, setRenameValue] = useState('') @@ -67,12 +69,18 @@ export function Sidebar() { setContextMenu({ id, x: e.clientX, y: e.clientY }) }, []) - const handleDelete = useCallback(async (id: string) => { + const handleDelete = useCallback((id: string) => { setContextMenu(null) - await deleteSession(id) - disconnectSession(id) - closeTab(id) - }, [closeTab, deleteSession, disconnectSession]) + setPendingDeleteSessionId(id) + }, []) + + const confirmDelete = useCallback(async () => { + if (!pendingDeleteSessionId) return + await deleteSession(pendingDeleteSessionId) + disconnectSession(pendingDeleteSessionId) + closeTab(pendingDeleteSessionId) + setPendingDeleteSessionId(null) + }, [closeTab, deleteSession, disconnectSession, pendingDeleteSessionId]) const handleStartRename = useCallback((id: string, currentTitle: string) => { setContextMenu(null) @@ -125,7 +133,7 @@ export function Sidebar() {
- +
-
- +
+ +
-
- setSearchQuery(e.target.value)} - className="w-full h-8 px-2.5 text-xs rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] outline-none transition-colors focus:border-[var(--color-border-focus)]" - /> -
-
-
+
{error && (
{t('sidebar.sessionListFailed')}
@@ -246,7 +255,7 @@ export function Sidebar() { if (!items || items.length === 0) return null return (
-
+
{timeGroupLabels[group]}
{items.map((session) => ( @@ -274,22 +283,22 @@ export function Sidebar() { }} onContextMenu={(e) => handleContextMenu(e, session.id)} className={` - group w-full rounded-[var(--radius-md)] py-1.5 pl-4 pr-3 text-left text-sm transition-colors duration-200 + group w-full rounded-[12px] px-3 py-2 text-left text-sm transition-colors duration-200 ${session.id === activeTabId - ? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)]' - : 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]' + ? 'bg-[var(--color-sidebar-item-active)] text-[var(--color-text-primary)]' + : 'text-[var(--color-text-secondary)] hover:bg-[var(--color-sidebar-item-hover)]' } `} > - + - {session.title || 'Untitled'} + {session.title || 'Untitled'} {!session.workDirExists && (
)} + + setPendingDeleteSessionId(null)} + onConfirm={confirmDelete} + title={t('common.delete')} + body={pendingDeleteSessionId ? t('sidebar.confirmDelete') : ''} + confirmLabel={t('common.delete')} + cancelLabel={t('common.cancel')} + confirmVariant="danger" + /> ) } @@ -399,11 +419,11 @@ function NavItem({ aria-label={label} title={collapsed ? label : undefined} className={` - flex items-center rounded-[var(--radius-md)] transition-all duration-200 - ${collapsed ? 'h-10 w-10 justify-center px-0 py-0' : 'w-full gap-2.5 px-3 py-2 text-sm'} + flex items-center transition-colors duration-200 + ${collapsed ? 'h-10 w-10 justify-center rounded-[var(--radius-md)] px-0 py-0' : 'w-full gap-2.5 rounded-[12px] px-3 py-2.5 text-sm'} ${active - ? 'bg-[var(--color-surface-selected)] font-medium text-[var(--color-text-primary)] shadow-[0_8px_24px_rgba(15,23,42,0.08)]' - : 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]' + ? 'bg-[var(--color-sidebar-item-active)] font-medium text-[var(--color-text-primary)]' + : 'text-[var(--color-text-secondary)] hover:bg-[var(--color-sidebar-item-hover)] hover:text-[var(--color-text-primary)]' } `} > @@ -455,6 +475,15 @@ function ClockIcon() { ) } +function SearchIcon() { + return ( + + + + + ) +} + function SidebarToggleIcon({ collapsed }: { collapsed: boolean }) { return ( s.activeTabId) + const runtimeSelection = useSessionRuntimeStore((s) => + activeTabId ? s.selections[activeTabId] : undefined, + ) const projectPath = useSessionStore((s) => s.sessions.find((session) => session.id === activeTabId)?.projectPath) const projectName = projectPath ? projectPath.split('-').filter(Boolean).pop() || '' : '' + const modelLabel = runtimeSelection?.modelId ?? currentModel?.name ?? null return (
@@ -20,9 +25,9 @@ export function StatusBar() {
- {currentModel && ( + {modelLabel && ( - {currentModel.name} + {modelLabel} )}
diff --git a/desktop/src/components/plugins/PluginDetail.tsx b/desktop/src/components/plugins/PluginDetail.tsx index ef0acfe2..14c0d897 100644 --- a/desktop/src/components/plugins/PluginDetail.tsx +++ b/desktop/src/components/plugins/PluginDetail.tsx @@ -4,6 +4,7 @@ import { useSessionStore } from '../../stores/sessionStore' import { useTranslation } from '../../i18n' import { useUIStore } from '../../stores/uiStore' import { Button } from '../shared/Button' +import { ConfirmDialog } from '../shared/ConfirmDialog' import type { PluginCapabilityKey } from '../../types/plugin' import { SETTINGS_TAB_ID, useTabStore } from '../../stores/tabStore' import { useSkillStore } from '../../stores/skillStore' @@ -36,10 +37,20 @@ export function PluginDetail() { const selectServer = useMcpStore((s) => s.selectServer) const t = useTranslation() const [actionKey, setActionKey] = useState(null) + const [showUninstallDialog, setShowUninstallDialog] = useState(false) const activeSession = sessions.find((session) => session.id === activeSessionId) const currentWorkDir = activeSession?.workDir || undefined + const otherCapabilityItems = useMemo( + () => + CAPABILITY_ORDER.map((key) => ({ + key, + items: selectedPlugin?.capabilities[key] ?? [], + })), + [selectedPlugin], + ) + if (isDetailLoading) { return (
@@ -52,14 +63,6 @@ export function PluginDetail() { const canMutate = selectedPlugin.scope !== 'managed' && selectedPlugin.scope !== 'builtin' const canNavigateSharedCapabilities = selectedPlugin.enabled - const otherCapabilityItems = useMemo( - () => - CAPABILITY_ORDER.map((key) => ({ - key, - items: selectedPlugin.capabilities[key], - })), - [selectedPlugin], - ) const runAction = async (key: string, fn: () => Promise) => { setActionKey(key) @@ -98,11 +101,6 @@ export function PluginDetail() { } } - const confirmUninstall = () => { - const label = t('settings.plugins.confirmUninstall', { name: selectedPlugin.name }) - return window.confirm(label) - } - const openSettingsTab = (tab: 'skills' | 'agents' | 'mcp') => { useUIStore.getState().setPendingSettingsTab(tab) useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings') @@ -290,8 +288,7 @@ export function PluginDetail() { size="sm" loading={isApplying && actionKey === 'uninstall'} onClick={() => { - if (!confirmUninstall()) return - void runAction('uninstall', () => uninstallPlugin(selectedPlugin.id, selectedPlugin.scope, false, currentWorkDir)) + setShowUninstallDialog(true) }} > {t('settings.plugins.uninstall')} @@ -483,6 +480,24 @@ export function PluginDetail() {
+ + { + if (isApplying && actionKey === 'uninstall') return + setShowUninstallDialog(false) + }} + onConfirm={async () => { + setShowUninstallDialog(false) + await runAction('uninstall', () => uninstallPlugin(selectedPlugin.id, selectedPlugin.scope, false, currentWorkDir)) + }} + title={t('settings.plugins.uninstall')} + body={t('settings.plugins.confirmUninstall', { name: selectedPlugin.name })} + confirmLabel={t('settings.plugins.uninstall')} + cancelLabel={t('common.cancel')} + confirmVariant="danger" + loading={isApplying && actionKey === 'uninstall'} + />
) } diff --git a/desktop/src/components/plugins/PluginList.tsx b/desktop/src/components/plugins/PluginList.tsx index 273a44bd..7d8df76a 100644 --- a/desktop/src/components/plugins/PluginList.tsx +++ b/desktop/src/components/plugins/PluginList.tsx @@ -102,7 +102,7 @@ export function PluginList() { return (
-
+
{t('settings.plugins.browserEyebrow')} @@ -129,8 +129,8 @@ export function PluginList() { )}
-
-
+
+
-
- - @@ -316,12 +326,14 @@ function SummaryCard({ icon: string }) { return ( -
-
+
+
{icon} - {label} + + {label} +
-
+
{value}
diff --git a/desktop/src/components/settings/InstallCenter.tsx b/desktop/src/components/settings/InstallCenter.tsx deleted file mode 100644 index 9a81aaa3..00000000 --- a/desktop/src/components/settings/InstallCenter.tsx +++ /dev/null @@ -1,433 +0,0 @@ -import { useEffect, useMemo, useRef, useState } from 'react' -import { sessionsApi } from '../../api/sessions' -import { useChatStore } from '../../stores/chatStore' -import { useMcpStore } from '../../stores/mcpStore' -import { usePluginStore } from '../../stores/pluginStore' -import { useSessionStore } from '../../stores/sessionStore' -import { useSkillStore } from '../../stores/skillStore' -import { useTranslation } from '../../i18n' -import { MessageList } from '../chat/MessageList' -import { ComputerUsePermissionModal } from '../chat/ComputerUsePermissionModal' -import { Button } from '../shared/Button' -import { Textarea } from '../shared/Textarea' -import { DirectoryPicker } from '../shared/DirectoryPicker' -import { useUIStore } from '../../stores/uiStore' -import { buildInstallerPrompt } from '../../lib/installAssistantPrompt' - -const INSTALLER_SESSION_KEY = 'cc-haha-installer-session-id' -const INSTALLER_CONTEXT_KEY = 'cc-haha-installer-context-dir' - -const EXAMPLE_PROMPTS = [ - '安装 plugin:skill-creator@claude-plugins-official,并应用到当前桌面端', - '添加一个 MCP:name=linear,url=https://example.com/mcp,优先用户级', - '帮我把一个 GitHub 仓库里的 skill 装到 ~/.claude/skills,并告诉我装到了哪里', -] - -function readStoredValue(key: string) { - try { - return localStorage.getItem(key) || '' - } catch { - return '' - } -} - -function writeStoredValue(key: string, value: string) { - try { - if (value) { - localStorage.setItem(key, value) - } else { - localStorage.removeItem(key) - } - } catch { - // noop - } -} - -export function InstallCenter() { - const t = useTranslation() - const sessions = useSessionStore((s) => s.sessions) - const fetchSessions = useSessionStore((s) => s.fetchSessions) - const connectToSession = useChatStore((s) => s.connectToSession) - const disconnectSession = useChatStore((s) => s.disconnectSession) - const sendMessage = useChatStore((s) => s.sendMessage) - const stopGeneration = useChatStore((s) => s.stopGeneration) - const fetchPlugins = usePluginStore((s) => s.fetchPlugins) - const reloadPlugins = usePluginStore((s) => s.reloadPlugins) - const fetchSkills = useSkillStore((s) => s.fetchSkills) - const fetchServers = useMcpStore((s) => s.fetchServers) - const addToast = useUIStore((s) => s.addToast) - const setPendingSettingsTab = useUIStore((s) => s.setPendingSettingsTab) - - const [sessionId, setSessionId] = useState(() => { - const stored = readStoredValue(INSTALLER_SESSION_KEY) - return stored || null - }) - const [contextDir, setContextDir] = useState(() => readStoredValue(INSTALLER_CONTEXT_KEY)) - const [draft, setDraft] = useState('') - const [isCreating, setIsCreating] = useState(false) - const createPromiseRef = useRef | null>(null) - const previousChatStateRef = useRef<'idle' | 'thinking' | 'tool_executing' | 'streaming' | 'permission_pending'>('idle') - - const installerSession = useMemo( - () => sessions.find((session) => session.id === sessionId) || null, - [sessionId, sessions], - ) - const sessionState = useChatStore((s) => - sessionId ? s.sessions[sessionId] : undefined, - ) - const chatState = sessionState?.chatState ?? 'idle' - const pendingComputerUsePermission = - sessionState?.pendingComputerUsePermission?.request ?? null - const isBusy = isCreating || chatState !== 'idle' - - useEffect(() => { - if (!sessionId) return - connectToSession(sessionId) - return () => { - disconnectSession(sessionId) - } - }, [connectToSession, disconnectSession, sessionId]) - - useEffect(() => { - writeStoredValue(INSTALLER_CONTEXT_KEY, contextDir.trim()) - }, [contextDir]) - - useEffect(() => { - if (!sessionId) return - const previousState = previousChatStateRef.current - previousChatStateRef.current = chatState - - if (previousState === 'idle' || chatState !== 'idle') return - - const cwd = installerSession?.workDir || undefined - - void (async () => { - await reloadPlugins(cwd).catch(() => null) - await Promise.allSettled([ - fetchPlugins(cwd), - fetchSkills(cwd), - fetchServers(cwd ? [cwd] : undefined, cwd), - ]) - })() - }, [ - chatState, - fetchPlugins, - fetchServers, - fetchSkills, - installerSession?.workDir, - reloadPlugins, - sessionId, - ]) - - const ensureInstallerSession = async () => { - if (sessionId && installerSession) { - return sessionId - } - - if (createPromiseRef.current) { - return createPromiseRef.current - } - - setIsCreating(true) - createPromiseRef.current = (async () => { - const { sessionId: createdSessionId } = await sessionsApi.create( - contextDir.trim() || undefined, - ) - await sessionsApi.rename( - createdSessionId, - t('settings.install.sessionTitle'), - ) - writeStoredValue(INSTALLER_SESSION_KEY, createdSessionId) - setSessionId(createdSessionId) - await fetchSessions() - connectToSession(createdSessionId) - return createdSessionId - })() - - try { - return await createPromiseRef.current - } finally { - createPromiseRef.current = null - setIsCreating(false) - } - } - - const handleSubmit = async () => { - const request = draft.trim() - if (!request || isBusy) return - - try { - const ensuredSessionId = await ensureInstallerSession() - sendMessage( - ensuredSessionId, - buildInstallerPrompt(request), - undefined, - { displayContent: request }, - ) - setDraft('') - } catch (error) { - addToast({ - type: 'error', - message: - error instanceof Error - ? error.message - : t('settings.install.createFailed'), - }) - } - } - - const handleRefresh = async () => { - const cwd = installerSession?.workDir || undefined - await Promise.all([ - fetchPlugins(cwd), - fetchSkills(cwd), - fetchServers(cwd ? [cwd] : undefined, cwd), - ]) - addToast({ - type: 'success', - message: t('settings.install.refreshDone'), - }) - } - - const startFreshConversation = async () => { - if (sessionId) { - disconnectSession(sessionId) - } - writeStoredValue(INSTALLER_SESSION_KEY, '') - setSessionId(null) - previousChatStateRef.current = 'idle' - addToast({ - type: 'info', - message: t('settings.install.newConversationReady'), - }) - } - - return ( -
-
-
-
-
- {t('settings.install.eyebrow')} -
-
- - download - -

- {t('settings.install.title')} -

-
-

- {t('settings.install.description')} -

-
- -
- - - - -
-
-
- -
-
-
-

- {t('settings.install.composeTitle')} -

-

- {t('settings.install.composeHint')} -

-
-
- - -
-
- -
-
-