diff --git a/CHANGELOG.md b/CHANGELOG.md index 332ae1a9..ce0e406f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -279,6 +279,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Quick launch found only the agents in the GUI's own bare `PATH`.** The app + starts with the launchd environment (`/usr/bin:/bin:/usr/sbin:/sbin`) and + fills it in by running the login shell, but only as a *login* shell — which + reads `.zprofile` / `.bash_profile` and never `.zshrc` / `.bashrc`, where + most people export their `PATH`. Anything installed under + `$HOME/.../env/node/bin`, `~/.local/bin` or an `rc`-only directory was + invisible: `Agent:` rows for it disappeared from Search Everywhere and the + `NEW Agent Tab` menu, and a matching `agent_launch` entry did not help, + because the override's own program is looked up on the same `PATH`. The probe + now runs interactively (`-i -l`, fish unchanged — it reads its config in + every mode), reads the PATH between markers so an `rc` that prints cannot be + mistaken for it, and gives up after 5 seconds so a stuck `rc` cannot keep + the app from opening. + - **Chinese UI copy reads like Chinese** (#980). Half-width `?` and `,` in the hard-reset confirmation, the passphrase, Finder and server naming used two words each, and three quote/dash styles were mixed; these are now one each, diff --git a/README.md b/README.md index 23f4ad34..e14a2043 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ after a reboot. **Fork** needs both — the agent's own fork command, and the ho that tells tty7 which session to fork.
-The full support matrix, all twenty-five +The full support matrix, all twenty-six | Agent | Detected | Status · resume | Fork | |---|:-:|:-:|:-:| @@ -84,6 +84,7 @@ that tells tty7 which session to fork. | **Qwen Code** | ✓ | ✓ | ✓ | | **Goose** | ✓ | ✓ | ✓ | | **Qoder CLI** | ✓ | ✓ | ✓ | +| **Qoder CN CLI** | ✓ | ✓ | ✓ | | **CodeBuddy** | ✓ | ✓ | ✓ | | **Gemini** | ✓ | ✓ | | | **Copilot** | ✓ | ✓ | | diff --git a/README.zh-CN.md b/README.zh-CN.md index 6ceb6bbd..c2ad7570 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -81,6 +81,8 @@ macOS、Windows、Linux 的原生构建都在 [**Releases**](https://github.com/ | **Droid** | ✓ | ✓ | ✓ | | **Qwen Code** | ✓ | ✓ | ✓ | | **Goose** | ✓ | ✓ | ✓ | +| **Qoder CLI** | ✓ | ✓ | ✓ | +| **Qoder CN CLI** | ✓ | ✓ | ✓ | | **Gemini** | ✓ | ✓ | | | **Copilot** | ✓ | ✓ | | | **Kimi Code** | ✓ | ✓ | | diff --git a/assets/icons/agents/qoderclicn.svg b/assets/icons/agents/qoderclicn.svg new file mode 100644 index 00000000..44eb1276 --- /dev/null +++ b/assets/icons/agents/qoderclicn.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/crates/tty7-core/src/core/agent_history.rs b/crates/tty7-core/src/core/agent_history.rs index a8a698d1..25a332df 100644 --- a/crates/tty7-core/src/core/agent_history.rs +++ b/crates/tty7-core/src/core/agent_history.rs @@ -70,6 +70,8 @@ pub struct Roots { pub droid: PathBuf, /// `$QODER_CONFIG_DIR`, else `~/.qoder`. pub qoder: PathBuf, + /// `$QODERCN_CONFIG_DIR`, else `~/.qoder-cn`. + pub qoder_cn: PathBuf, /// `$CODEBUDDY_CONFIG_DIR`, else `~/.codebuddy`. pub codebuddy: PathBuf, /// `$CURSOR_CONFIG_DIR`, else `~/.cursor`. @@ -95,6 +97,7 @@ impl Roots { copilot: home.join(".copilot"), droid: home.join(".factory"), qoder: home.join(".qoder"), + qoder_cn: home.join(".qoder-cn"), codebuddy: home.join(".codebuddy"), cursor: home.join(".cursor"), opencode: home.join(".local").join("share").join("opencode"), @@ -142,6 +145,9 @@ impl Roots { if let Some(dir) = var("QODER_CONFIG_DIR") { roots.qoder = dir; } + if let Some(dir) = var("QODERCN_CONFIG_DIR") { + roots.qoder_cn = dir; + } if let Some(dir) = var("CODEBUDDY_CONFIG_DIR") { roots.codebuddy = dir; } @@ -173,6 +179,10 @@ pub fn scan(roots: &Roots, known_dirs: &[PathBuf]) -> Vec { &roots.qoder.join("projects"), CLIAgent::QoderCLI, )); + files.extend(claude_files( + &roots.qoder_cn.join("projects"), + CLIAgent::QoderCLICn, + )); files.extend(claude_files( &roots.codebuddy.join("projects"), CLIAgent::CodeBuddy, @@ -305,7 +315,7 @@ fn read(file: &Found) -> Option { let updated = unix(file.modified); match file.agent { CLIAgent::Codex => read_codex(&file.path, updated), - CLIAgent::Claude | CLIAgent::QoderCLI => { + CLIAgent::Claude | CLIAgent::QoderCLI | CLIAgent::QoderCLICn => { let id = file.path.file_stem()?.to_str()?.to_string(); let (head, tail) = ends(&file.path, file.len).ok()?; let mut s = parse_claude(id, &head, &tail, updated)?; @@ -1875,6 +1885,7 @@ mod tests { }; let claude_like = user("hello"); write(".qoder/projects/-q/q1.jsonl", claude_like); + write(".qoder-cn/projects/-q/q2.jsonl", user("hello cn")); write( ".qwen/projects/q/chats/w1.jsonl", line(json!({"type": "user", "message": {"parts": [{"text": "hi qwen"}]}})), @@ -1933,6 +1944,7 @@ mod tests { (CLIAgent::Droid, "d1".into(), "hi droid".into()), (CLIAgent::Pi, "p1".into(), "hi pi".into()), (CLIAgent::QoderCLI, "q1".into(), "hello".into()), + (CLIAgent::QoderCLICn, "q2".into(), "hello cn".into()), ( CLIAgent::OpenCode, "ses_default".into(), diff --git a/crates/tty7-core/src/core/agent_hooks.rs b/crates/tty7-core/src/core/agent_hooks.rs index e83f558e..61aaa797 100644 --- a/crates/tty7-core/src/core/agent_hooks.rs +++ b/crates/tty7-core/src/core/agent_hooks.rs @@ -63,9 +63,10 @@ fn effective_event<'a>(agent: &str, event: &'a str, stdin_json: &str) -> Option< return None; } } - // Qoder and CodeBuddy also emit SessionStart after compacting the active - // turn. Preserve its status until a real turn or session boundary arrives. - if matches!(agent, "qodercli" | "codebuddy") + // Qoder, its China build and CodeBuddy also emit SessionStart after + // compacting the active turn. Preserve its status until a real turn or + // session boundary arrives. + if matches!(agent, "qodercli" | "qoderclicn" | "codebuddy") && event == "session-start" && let Ok(payload) = serde_json::from_str::(stdin_json) && payload.get("source").and_then(|value| value.as_str()) == Some("compact") @@ -321,10 +322,11 @@ pub enum HookAgent { Cursor, PrimeAgent, Antigravity, + QoderCLICn, } impl HookAgent { - pub const ALL: [HookAgent; 19] = [ + pub const ALL: [HookAgent; 20] = [ HookAgent::Claude, HookAgent::Codex, HookAgent::TraeCode, @@ -339,6 +341,7 @@ impl HookAgent { HookAgent::Goose, HookAgent::Kimi, HookAgent::QoderCLI, + HookAgent::QoderCLICn, HookAgent::Crush, HookAgent::CodeBuddy, HookAgent::Cursor, @@ -369,6 +372,7 @@ impl HookAgent { CLIAgent::Goose => Some(HookAgent::Goose), CLIAgent::Kimi => Some(HookAgent::Kimi), CLIAgent::QoderCLI => Some(HookAgent::QoderCLI), + CLIAgent::QoderCLICn => Some(HookAgent::QoderCLICn), CLIAgent::Crush => Some(HookAgent::Crush), CLIAgent::CodeBuddy => Some(HookAgent::CodeBuddy), CLIAgent::Cursor => Some(HookAgent::Cursor), @@ -392,7 +396,7 @@ impl HookAgent { HookAgent::Gemini => Some(GEMINI_HOOK_EVENTS), HookAgent::Droid => Some(DROID_HOOK_EVENTS), HookAgent::Qwen => Some(QWEN_HOOK_EVENTS), - HookAgent::QoderCLI => Some(QODER_HOOK_EVENTS), + HookAgent::QoderCLI | HookAgent::QoderCLICn => Some(QODER_HOOK_EVENTS), HookAgent::Crush => Some(CRUSH_HOOK_EVENTS), HookAgent::CodeBuddy => Some(CODEBUDDY_HOOK_EVENTS), HookAgent::Cursor => Some(CURSOR_HOOK_EVENTS), @@ -459,6 +463,7 @@ impl HookAgent { HookAgent::Crush => "crush", HookAgent::CodeBuddy => "codebuddy", HookAgent::Cursor => "cursor", + HookAgent::QoderCLICn => "qoderclicn", } } @@ -483,6 +488,7 @@ impl HookAgent { HookAgent::Crush => "Crush", HookAgent::CodeBuddy => "CodeBuddy", HookAgent::Cursor => "Cursor CLI", + HookAgent::QoderCLICn => "Qoder CN CLI", } } @@ -520,6 +526,7 @@ impl HookAgent { } HookAgent::Kimi => target.kimi_config_path(), HookAgent::QoderCLI => target.qoder_settings_path(), + HookAgent::QoderCLICn => target.qoder_cn_settings_path(), HookAgent::Crush => target.crush_settings_path(), HookAgent::CodeBuddy => target.codebuddy_settings_path(), // The user-level file; Cursor merges it under any project one. @@ -625,6 +632,22 @@ impl<'a> HookTarget<'a> { self.under_home(&[".qoder", "settings.json"]) } + /// The China build keeps its settings in `~/.qoder-cn`. It resolves the + /// directory the same way the global build does, under a `-cn` name: + /// `QODERCN_CONFIG_DIR` wins, otherwise the home falls back through + /// `QODERCN_CLI_HOME` and `GEMINI_CLI_HOME` before the user's own, with + /// `.qoder-cn` as the directory name. Only the first is honoured here — + /// local-only, like every other override, so a local env var must not + /// redirect a remote machine's hooks. + fn qoder_cn_settings_path(&self) -> PathBuf { + if self.is_local() + && let Some(dir) = std::env::var_os("QODERCN_CONFIG_DIR").filter(|d| !d.is_empty()) + { + return PathBuf::from(dir).join("settings.json"); + } + self.under_home(&[".qoder-cn", "settings.json"]) + } + /// The global `crush.json`. Crush resolves it through `CRUSH_GLOBAL_CONFIG` /// when set, otherwise `$XDG_CONFIG_HOME/crush/crush.json` (and `~/.config` /// when that is unset) — which is exactly what [`Self::xdg_config_dir`] @@ -1495,6 +1518,7 @@ fn owned_file_content(target: &HookTarget, agent: HookAgent) -> Option { | HookAgent::Droid | HookAgent::Qwen | HookAgent::QoderCLI + | HookAgent::QoderCLICn | HookAgent::Crush | HookAgent::CodeBuddy | HookAgent::Cursor @@ -1994,6 +2018,7 @@ mod tests { ), (HookAgent::Kimi, "/home/me/.kimi-code/config.toml"), (HookAgent::QoderCLI, "/home/me/.qoder/settings.json"), + (HookAgent::QoderCLICn, "/home/me/.qoder-cn/settings.json"), (HookAgent::Crush, "/home/me/.config/crush/crush.json"), (HookAgent::CodeBuddy, "/home/me/.codebuddy/settings.json"), (HookAgent::Cursor, "/home/me/.cursor/hooks.json"), @@ -2022,6 +2047,7 @@ mod tests { HookAgent::Goose, HookAgent::Kimi, HookAgent::QoderCLI, + HookAgent::QoderCLICn, HookAgent::Crush, HookAgent::CodeBuddy, HookAgent::Cursor, @@ -2713,6 +2739,7 @@ mod tests { ), (HookAgent::Kimi, "/home/me/.kimi-code/config.toml"), (HookAgent::QoderCLI, "/home/me/.qoder/settings.json"), + (HookAgent::QoderCLICn, "/home/me/.qoder-cn/settings.json"), (HookAgent::Crush, "/home/me/.config/crush/crush.json"), (HookAgent::CodeBuddy, "/home/me/.codebuddy/settings.json"), (HookAgent::Cursor, "/home/me/.cursor/hooks.json"), @@ -3171,6 +3198,102 @@ mod tests { } } + #[test] + fn qoder_cn_config_dir_controls_local_hook_lifecycle() { + const CASE_ENV: &str = "TTY7_TEST_QODER_CN_CONFIG_CASE"; + const ROOT_ENV: &str = "TTY7_TEST_QODER_CN_CONFIG_ROOT"; + let Ok(case) = std::env::var(CASE_ENV) else { + for case in ["override", "empty", "unset"] { + let sandbox = tempfile::tempdir().unwrap(); + let mut child = std::process::Command::new(std::env::current_exe().unwrap()); + child + .args([ + "--exact", + "core::agent_hooks::tests::qoder_cn_config_dir_controls_local_hook_lifecycle", + "--nocapture", + ]) + .env(CASE_ENV, case) + .env(ROOT_ENV, sandbox.path()); + match case { + "override" => { + child.env("QODERCN_CONFIG_DIR", sandbox.path().join("custom config")) + } + "empty" => child.env("QODERCN_CONFIG_DIR", ""), + _ => child.env_remove("QODERCN_CONFIG_DIR"), + }; + let output = crate::core::proc::output_within( + crate::core::proc::hide_console(&mut child), + std::time::Duration::from_secs(30), + ) + .expect("run the isolated Qoder CN hook test"); + assert!( + output.status.success(), + "{case}:\n{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + } + return; + }; + + let root = PathBuf::from(std::env::var_os(ROOT_ENV).unwrap()); + let host = local_host(); + let target = HookTarget { + host: &*host, + home: root.join("home"), + exe: std::env::current_exe().unwrap(), + }; + let default_settings = target.home.join(".qoder-cn").join("settings.json"); + let custom_settings = root.join("custom config").join("settings.json"); + let (settings, untouched) = if case == "override" { + (&custom_settings, &default_settings) + } else { + (&default_settings, &custom_settings) + }; + let user_config = serde_json::json!({ + "model": "qoder-cn-test", + "hooks": { + "Stop": [{ "hooks": [{ "type": "command", "command": "echo user-hook" }] }] + } + }); + for path in [settings, untouched] { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, user_config.to_string()).unwrap(); + } + + let agent = HookAgent::QoderCLICn; + assert_eq!(agent.target_path(&target), *settings); + let remote_host = FakeRemote::shared(); + let remote = HookTarget::remote(&*remote_host, PathBuf::from("/home/me")); + assert_eq!( + agent.target_path(&remote), + PathBuf::from("/home/me/.qoder-cn/settings.json"), + "a local override must not redirect remote hooks" + ); + + assert_eq!(hooks_state(&target, agent), HooksState::NotInstalled); + assert_eq!( + install_hooks(&target, agent).unwrap(), + HookOutcome::Installed + ); + assert_eq!(hooks_state(&target, agent), HooksState::Installed); + assert!( + std::fs::read_to_string(settings) + .unwrap() + .contains("agent-hook qoderclicn") + ); + assert_eq!( + uninstall_hooks(&target, agent).unwrap(), + HookOutcome::Removed + ); + assert_eq!(hooks_state(&target, agent), HooksState::NotInstalled); + for path in [settings, untouched] { + let actual: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap(); + assert_eq!(actual, user_config, "{}", path.display()); + } + } + #[test] fn codebuddy_config_dir_controls_local_hook_lifecycle() { const CASE_ENV: &str = "TTY7_TEST_CODEBUDDY_CONFIG_CASE"; diff --git a/crates/tty7-core/src/core/cli_agent.rs b/crates/tty7-core/src/core/cli_agent.rs index b3e7bc30..8b033eb3 100644 --- a/crates/tty7-core/src/core/cli_agent.rs +++ b/crates/tty7-core/src/core/cli_agent.rs @@ -31,10 +31,11 @@ pub enum CLIAgent { CodeBuddy, Empryo, PrimeAgent, + QoderCLICn, } impl CLIAgent { - pub const ALL: [CLIAgent; 25] = [ + pub const ALL: [CLIAgent; 26] = [ CLIAgent::Claude, CLIAgent::Codex, CLIAgent::TraeCode, @@ -56,6 +57,7 @@ impl CLIAgent { CLIAgent::OhMyPi, CLIAgent::Kimi, CLIAgent::QoderCLI, + CLIAgent::QoderCLICn, CLIAgent::Crush, CLIAgent::CodeBuddy, CLIAgent::Empryo, @@ -102,6 +104,12 @@ impl CLIAgent { // launch is the cost: it wears the CLI's avatar for as long as the // launcher takes to exit. CLIAgent::QoderCLI => &["qoder", "qodercli"], + // The mainland-China build of Qoder: the same vendor as the global + // one, a separate install that keeps its state in `~/.qoder-cn`. + // It ships the same dispatcher-plus-CLI pair under `-cn` names, so + // `qoder-cn` is detected too. A bare `qoder` still belongs to the + // global build. + CLIAgent::QoderCLICn => &["qodercn", "qoderclicn", "qoder-cn"], // Charm's terminal agent. One binary, and the name on `PATH` is // the one it starts as. CLIAgent::Crush => &["crush"], @@ -142,6 +150,7 @@ impl CLIAgent { CLIAgent::CodeBuddy => "codebuddy", CLIAgent::Empryo => "empryo", CLIAgent::PrimeAgent => "prime-agent", + CLIAgent::QoderCLICn => "qoderclicn", } } @@ -177,6 +186,7 @@ impl CLIAgent { CLIAgent::CodeBuddy => "CodeBuddy", CLIAgent::Empryo => "Empryo", CLIAgent::PrimeAgent => "Prime Agent", + CLIAgent::QoderCLICn => "Qoder CN CLI", } } @@ -209,6 +219,7 @@ impl CLIAgent { CLIAgent::Copilot => Some(format!("copilot{flags} --resume {session_id}")), CLIAgent::Grok => Some(format!("grok{flags} --resume {session_id}")), CLIAgent::QoderCLI => Some(format!("qodercli{flags} --resume {session_id}")), + CLIAgent::QoderCLICn => Some(format!("qodercn{flags} --resume {session_id}")), CLIAgent::Pi => Some(format!("pi{flags} --session {session_id}")), CLIAgent::OhMyPi => Some(format!("omp{flags} --resume {session_id}")), CLIAgent::Kimi => Some(format!("kimi{flags} --session {session_id}")), @@ -231,7 +242,9 @@ impl CLIAgent { CLIAgent::Qwen => &["--no-chat-recording"], // Print mode still emits a session id in hooks when persistence // is disabled, but there is no saved conversation to reopen. - CLIAgent::QoderCLI | CLIAgent::CodeBuddy => &["--no-session-persistence"], + CLIAgent::QoderCLI | CLIAgent::CodeBuddy | CLIAgent::QoderCLICn => { + &["--no-session-persistence"] + } _ => &[], }; argv.iter().any(|t| ephemeral.contains(&t.as_str())) @@ -252,6 +265,11 @@ impl CLIAgent { CLIAgent::QoderCLI => Some(format!( "qodercli{flags} --resume {session_id} --fork-session" )), + // qoder-cn is the same CLI against a different config directory, so + // it takes the same switches. + CLIAgent::QoderCLICn => Some(format!( + "qodercn{flags} --resume {session_id} --fork-session" + )), CLIAgent::CodeBuddy => Some(format!( "codebuddy{flags} --resume {session_id} --fork-session" )), @@ -286,6 +304,7 @@ impl CLIAgent { | CLIAgent::Qwen | CLIAgent::Goose | CLIAgent::QoderCLI + | CLIAgent::QoderCLICn | CLIAgent::CodeBuddy => Some("Fork Session"), _ => None, } @@ -492,7 +511,7 @@ impl CLIAgent { // appends, and `--fork-session` is the flag the fork variant // appends itself. `--worktree` would create or switch trees again; // Qoder's `-w` means `--cwd` and must survive. - CLIAgent::QoderCLI => &[ + CLIAgent::QoderCLI | CLIAgent::QoderCLICn => &[ "--resume", "-r", "--continue", @@ -570,6 +589,8 @@ impl CLIAgent { // black, which Codex and Grok already have covered. CLIAgent::Kimi => 0x027AFF, CLIAgent::QoderCLI => 0xFFFFFF, + // qoder-cn ships the same mark on the same black field. + CLIAgent::QoderCLICn => 0xFFFFFF, // The blue-violet field Charm ships the Crush heart on. CLIAgent::Crush => 0x6B50FF, CLIAgent::Empryo => 0xE8663D, @@ -594,7 +615,7 @@ impl CLIAgent { pub fn icon_rgb(self) -> u32 { match self { CLIAgent::TraeCode => 0x32F08C, - CLIAgent::QoderCLI => 0x000000, + CLIAgent::QoderCLI | CLIAgent::QoderCLICn => 0x000000, _ => 0xFFFFFF, } } @@ -617,6 +638,7 @@ impl CLIAgent { CLIAgent::Qwen => "icons/agents/qwen.svg", CLIAgent::Kimi => "icons/agents/kimi.svg", CLIAgent::QoderCLI => "icons/agents/qodercli.svg", + CLIAgent::QoderCLICn => "icons/agents/qoderclicn.svg", CLIAgent::Crush => "icons/agents/crush.svg", CLIAgent::CodeBuddy => "icons/agents/codebuddy.svg", CLIAgent::Aider @@ -1167,6 +1189,35 @@ mod tests { } } + /// The China build installs the same dispatcher-plus-CLI pair under `-cn` + /// names, so a bare `qoder` has to stay with the global one. + #[test] + fn qoder_cn_is_detected_through_either_of_its_binaries() { + for launcher in [ + "qodercn", + "qoderclicn", + "qoder-cn", + "/c/Users/me/.qoder-cn/entry/qodercn.cmd", + ] { + assert_eq!( + CLIAgent::detect_from_argv(&argv(&[launcher])), + Some(CLIAgent::QoderCLICn), + "on {launcher}" + ); + } + for other in ["qoder", "qodercli"] { + assert_eq!( + CLIAgent::detect_from_argv(&argv(&[other])), + Some(CLIAgent::QoderCLI), + "the global build keeps {other}" + ); + } + assert_eq!( + CLIAgent::from_slug("qoderclicn"), + Some(CLIAgent::QoderCLICn) + ); + } + /// All three of CodeBuddy's npm bins are node scripts pointing at one file, /// so the pty sees node plus whichever shim name was typed. #[test] diff --git a/docs/agents/overview.mdx b/docs/agents/overview.mdx index 641d70eb..ed3b5aae 100644 --- a/docs/agents/overview.mdx +++ b/docs/agents/overview.mdx @@ -1,6 +1,6 @@ --- title: "Coding agents" -description: "What tty7 does around Claude Code, Codex, TraeCode, and 20 others — without ever wrapping them." +description: "What tty7 does around Claude Code, Codex, TraeCode, and 21 others — without ever wrapping them." --- tty7 recognises coding agents running in a pane and builds around them. It does @@ -15,7 +15,7 @@ need, and what changed. ## Which agents -Twenty-five CLIs are recognised on sight, by the command running in the pane: +Twenty-six CLIs are recognised on sight, by the command running in the pane: | Agent | Command | |---|---| @@ -34,6 +34,7 @@ Twenty-five CLIs are recognised on sight, by the command running in the pane: | Qwen Code | `qwen`, `qwen-code` | | Kimi Code | `kimi`, `kimi-code` | | Qoder CLI | `qoder`, `qodercli` | +| Qoder CN CLI | `qodercn`, `qoderclicn`, `qoder-cn` | | Crush | `crush` | | CodeBuddy | `codebuddy`, `codebuddy-code`, `cbc` | | Auggie | `auggie` | @@ -66,7 +67,7 @@ If you launch agents through a wrapper script, map its name to an agent in The key is your command's name; the value is one of the slugs above (`claude`, `codex`, `traecli`, `gemini`, `aider`, `amp`, `opencode`, `copilot`, `cursor`, `goose`, `droid`, `pi`, `auggie`, `hermes`, `vibe`, `antigravity`, `grok`, `qwen`, -`omp`, `kimi`, `qodercli`, `crush`, `codebuddy`, `empryo`, `prime-agent`). +`omp`, `kimi`, `qodercli`, `qoderclicn`, `crush`, `codebuddy`, `empryo`, `prime-agent`). ## Quick launch diff --git a/docs/agents/sessions.mdx b/docs/agents/sessions.mdx index 4a25c8d4..76a59dda 100644 --- a/docs/agents/sessions.mdx +++ b/docs/agents/sessions.mdx @@ -41,6 +41,8 @@ or right-click the **tab or sidebar row** to open the fork in a new tab. | Codex | `codex fork ` | | TraeCode | `traecli fork ` | | Grok | `grok --resume --fork-session` | +| Qoder CLI | `qodercli --resume --fork-session` | +| Qoder CN CLI | `qodercn --resume --fork-session` | | OpenCode | `opencode --session --fork` | | Oh My Pi | `omp --fork ` | | Prime Agent | `prime-agent --fork ` | diff --git a/docs/agents/status.mdx b/docs/agents/status.mdx index 18e62d5b..07e549a5 100644 --- a/docs/agents/status.mdx +++ b/docs/agents/status.mdx @@ -14,7 +14,7 @@ the agent say which one it is. | Agent | | |---|---| -| Claude Code · Codex · TraeCode · Copilot CLI · OpenCode · Pi · Grok Build · Oh My Pi · Gemini · Droid · Qwen Code · Goose · Kimi Code · Qoder CLI · Crush · CodeBuddy · Cursor CLI · Prime Agent · Antigravity | Hooks available | +| Claude Code · Codex · TraeCode · Copilot CLI · OpenCode · Pi · Grok Build · Oh My Pi · Gemini · Droid · Qwen Code · Goose · Kimi Code · Qoder CLI · Qoder CN CLI · Crush · CodeBuddy · Cursor CLI · Prime Agent · Antigravity | Hooks available | | Aider · Amp · Auggie · Hermes · Vibe · Empryo | Detected and labelled, but no status channel yet | Installing writes into that agent's own configuration directory. Once installed diff --git a/src/main.rs b/src/main.rs index 90e4905c..1c62a4fd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -389,29 +389,109 @@ fn merge_paths(primary: &str, secondary: &str) -> String { .join(":") } +/// Brackets the PATH in the probe's output. An interactive rc prints what it +/// likes — a greeting above, an exit hook below — so neither the whole output +/// nor any one line of it can be trusted to be the PATH. +#[cfg(unix)] +const PATH_MARKER: &str = "__TTY7_PATH__"; + +/// How long startup waits on the user's rc files before giving up on them. +#[cfg(unix)] +const PATH_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); + +/// The PATH between the first pair of markers, or `None` until both are in. +#[cfg(unix)] +fn path_from_shell_output(out: &[u8]) -> Option { + let out = String::from_utf8_lossy(out); + let (_, rest) = out.split_once(PATH_MARKER)?; + let (path, _) = rest.split_once(PATH_MARKER)?; + Some(path.trim().to_string()) +} + +/// Runs the probe and returns the PATH as soon as the closing marker arrives, +/// without waiting for EOF: an rc that backgrounds a daemon hands it our +/// stdout, and the pipe then never closes. Past the timeout the shell is +/// killed and startup goes on with the PATH it has. +#[cfg(unix)] +fn read_path_from_shell(shell: &str, args: &[&str]) -> Option { + use std::io::Read; + use std::process::{Command, Stdio}; + + let mut child = match Command::new(shell) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + { + Ok(child) => child, + Err(e) => { + log::warn!("failed to spawn login shell {shell} for PATH: {e}"); + return None; + } + }; + let mut stdout = child.stdout.take()?; + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut out = Vec::new(); + let mut buf = [0u8; 4096]; + loop { + match stdout.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => out.extend_from_slice(&buf[..n]), + } + if let Some(path) = path_from_shell_output(&out) { + let _ = tx.send(Some(path)); + return; + } + } + let _ = tx.send(None); + }); + let path = match rx.recv_timeout(PATH_PROBE_TIMEOUT) { + Ok(path) => path, + Err(_) => { + log::warn!( + "login shell {shell} did not report PATH within {PATH_PROBE_TIMEOUT:?}; \ + continuing without it" + ); + let _ = child.kill(); + None + } + }; + if path.is_none() { + let _ = child.kill(); + } + // Reap off the startup path: after the marker the shell may still be + // running exit hooks, and none of them is worth a delay here. + std::thread::spawn(move || { + let _ = child.wait(); + }); + path +} + #[cfg(unix)] fn enrich_path_from_login_shell() { let shell = crate::core::shells::login_shell(); - let cmd = if std::path::Path::new(&shell).file_name() == Some("fish".as_ref()) { - "string join ':' $PATH" + let fish = std::path::Path::new(&shell).file_name() == Some("fish".as_ref()); + let cmd = if fish { + format!("printf '{PATH_MARKER}%s{PATH_MARKER}' (string join ':' $PATH)") } else { - "echo $PATH" + format!("printf '{PATH_MARKER}%s{PATH_MARKER}' \"$PATH\"") }; - let out = match std::process::Command::new(&shell) - .args(["-l", "-c", cmd]) - .output() - { - Ok(out) if out.status.success() => out.stdout, - Ok(out) => { - log::warn!("login shell exited with {} while reading PATH", out.status); - return; - } - Err(e) => { - log::warn!("failed to spawn login shell {shell} for PATH: {e}"); - return; - } + // A pane gets an interactive shell, and for zsh/bash that is the only one + // that reads `.zshrc` / `.bashrc` — where most people put their PATH. A + // login shell alone reads `.zprofile` / `.bash_profile` and quietly hands + // back the GUI's own bare PATH, so half the tools on `PATH` in a terminal + // do not exist as far as this app is concerned. fish reads its config in + // every mode; `-i` without a tty only makes it complain on stderr. + let args: Vec<&str> = if fish { + vec!["-l", "-c", &cmd] + } else { + vec!["-i", "-l", "-c", &cmd] + }; + let Some(login_path) = read_path_from_shell(&shell, &args) else { + return; }; - let login_path = String::from_utf8_lossy(&out).trim().to_string(); if login_path.is_empty() { return; } @@ -607,14 +687,16 @@ fn main() { return; } - #[cfg(unix)] - enrich_path_from_login_shell(); - let open_path = open_path_from(args.into_iter()); if forward_open_path(open_path.as_deref()) { return; } + // After the forward: a launch that only hands a path to the running + // window has no use for the PATH, and running the user's rc costs time. + #[cfg(unix)] + enrich_path_from_login_shell(); + // A package the user asked to have applied at the next launch. Deliberately // here: after the forward above, so a second launch that is really a // request to an existing window never replaces the bundle out from under @@ -913,7 +995,27 @@ mod config_reload_tests { #[cfg(all(test, unix))] mod tests { - use super::merge_paths; + use super::{merge_paths, path_from_shell_output}; + + #[test] + fn path_from_shell_output_reads_between_the_markers() { + // The shape `zsh -i` actually prints: an rc that greets the user, the + // `can't change option: zle` chatter `-i` earns without a tty, and an + // exit hook that prints after the PATH. + assert_eq!( + path_from_shell_output( + b"hello\n(eval):1: can't change option: zle\n\ + __TTY7_PATH__/opt/bin:/usr/bin__TTY7_PATH__bye\n" + ) + .as_deref(), + Some("/opt/bin:/usr/bin") + ); + assert_eq!( + path_from_shell_output(b"hello\n__TTY7_PATH__/opt/bin"), + None + ); + assert_eq!(path_from_shell_output(b"/opt/bin:/usr/bin\n"), None); + } #[test] fn merge_paths_prefers_primary_dedupes_and_drops_empties() { diff --git a/src/ui/assets.rs b/src/ui/assets.rs index 4c3b9368..9758a760 100644 --- a/src/ui/assets.rs +++ b/src/ui/assets.rs @@ -96,6 +96,10 @@ fn agent_icon(path: &str) -> Option<&'static [u8]> { "icons/agents/qwen.svg" => include_bytes!("../../assets/icons/agents/qwen.svg"), "icons/agents/kimi.svg" => include_bytes!("../../assets/icons/agents/kimi.svg"), "icons/agents/qodercli.svg" => include_bytes!("../../assets/icons/agents/qodercli.svg"), + // The China build ships the same mark. + "icons/agents/qoderclicn.svg" => { + include_bytes!("../../assets/icons/agents/qoderclicn.svg") + } "icons/agents/crush.svg" => include_bytes!("../../assets/icons/agents/crush.svg"), "icons/agents/codebuddy.svg" => { include_bytes!("../../assets/icons/agents/codebuddy.svg") diff --git a/src/ui/code_editor.rs b/src/ui/code_editor.rs index 64b29479..b03f169e 100644 --- a/src/ui/code_editor.rs +++ b/src/ui/code_editor.rs @@ -9,7 +9,7 @@ use gpui::{ }; use gpui_component::button::{Button, ButtonVariants as _}; use gpui_component::input::{Input, InputEvent, InputState, Position, RopeExt as _, TabSize}; -use gpui_component::menu::ContextMenuExt as _; +use gpui_component::menu::{ContextMenuExt as _, PopupMenu, PopupMenuItem}; use gpui_component::{ ActiveTheme as _, Icon, IconName, Sizable as _, WindowExt as _, h_flex, v_flex, }; @@ -224,9 +224,12 @@ impl TabCode { } /// What a question about unsaved files was standing in the way of. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Debug)] pub(crate) enum AfterUnsaved { CloseTab(TabId), + /// Take these files out of that tab's strip — Close Others and Close to + /// the Right. + CloseFiles(TabId, Vec), ClosePane, CloseWindow, Quit, @@ -605,6 +608,66 @@ fn is_program(path: &Path) -> bool { } } +/// A file a browser renders rather than showing its source. +fn opens_in_browser(path: &Path) -> bool { + path.extension().and_then(|e| e.to_str()).is_some_and(|e| { + matches!( + e.to_ascii_lowercase().as_str(), + "html" | "htm" | "xhtml" | "svg" + ) + }) +} + +/// Opens a local file in the default web browser. On macOS that is asked +/// for by name: what `.html` is associated with is as often an editor. The +/// other desktops go by the association. +fn open_in_browser(path: &Path) -> std::io::Result<()> { + #[cfg(target_os = "macos")] + if let Some(bundle) = default_browser_bundle_id() { + std::process::Command::new("open") + .arg("-b") + .arg(bundle) + .arg(path) + .spawn()?; + return Ok(()); + } + crate::terminal::view::open_file_path(path) +} + +/// The app that handles `https:` links — the default browser. +#[cfg(target_os = "macos")] +fn default_browser_bundle_id() -> Option { + use core_foundation::base::TCFType as _; + use core_foundation::string::{CFString, CFStringRef}; + + #[link(name = "CoreServices", kind = "framework")] + unsafe extern "C" { + fn LSCopyDefaultHandlerForURLScheme(scheme: CFStringRef) -> CFStringRef; + } + let scheme = CFString::new("https"); + let handler = unsafe { LSCopyDefaultHandlerForURLScheme(scheme.as_concrete_TypeRef()) }; + // A Copy function: the string is ours to release, which the wrapper does. + (!handler.is_null()).then(|| unsafe { CFString::wrap_under_create_rule(handler) }.to_string()) +} + +/// The lines a selection covers, 1-based and inclusive. One that ends at the +/// very start of a line — a whole-line selection — does not take that line. +fn selected_lines( + text: &gpui_component::input::Rope, + range: std::ops::Range, +) -> Option<(usize, usize)> { + if range.is_empty() { + return None; + } + let start = text.offset_to_point(range.start); + let end = text.offset_to_point(range.end); + let last = match end.column == 0 && end.row > start.row { + true => end.row - 1, + false => end.row, + }; + Some((start.row + 1, last + 1)) +} + /// What a watcher saw at a path: the file's modification time, or that it /// is not there any more. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -1044,8 +1107,18 @@ impl Tty7App { ); return; } - // The OS association can fail to spawn like any other opener (#542). - if let Err(e) = crate::terminal::view::open_file_path(path) { + Self::open_with(path, crate::terminal::view::open_file_path, window, cx); + } + + /// Runs one of the desktop openers on a local file, and says so when it + /// fails to spawn — as any opener can (#542). + fn open_with( + path: &Path, + opener: fn(&Path) -> std::io::Result<()>, + window: &mut Window, + cx: &mut Context, + ) { + if let Err(e) = opener(path) { log::warn!("failed to open {}: {e}", path.display()); window.push_notification( crate::ui::host_ops::failure( @@ -1168,6 +1241,9 @@ impl Tty7App { .replaceable(true) .folding(true) .soft_wrap(wrap) + // Ours is drawn by `editor_body_menu`, like every other menu + // in the window; the built-in one is a native OS menu. + .context_menu(false) .default_value(text) }); let id = input.entity_id(); @@ -1922,6 +1998,231 @@ impl Tty7App { .detach(); } + /// Closes the file with this buffer in the front tab's strip, wherever + /// it has moved to since a menu was built for it. + fn editor_close_buffer(&mut self, id: BufferId, window: &mut Window, cx: &mut Context) { + if let Some(pos) = self + .tab_code() + .and_then(|c| c.files.iter().position(|f| *f == id)) + { + self.editor_close_file(pos, window, cx); + } + } + + /// Closes several files in the front tab's strip, asking once about the + /// ones whose edits would be lost with them — not those another tab still + /// shows. + fn editor_close_buffers( + &mut self, + ids: Vec, + window: &mut Window, + cx: &mut Context, + ) { + let tab_ix = self.active; + let Some(tab) = self.tabs.get(tab_ix).map(|t| t.tree_id.get()) else { + return; + }; + let unsaved: Vec = ids + .iter() + .copied() + .filter(|id| self.buffer(*id).is_some_and(|f| f.dirty) && self.buffer_refs(*id) == 1) + .collect(); + if self.editor_guard_unsaved( + unsaved, + AfterUnsaved::CloseFiles(tab, ids.clone()), + window, + cx, + ) { + return; + } + for id in ids { + self.editor_remove_from_tab(tab_ix, id, cx); + } + } + + /// Types `@path` into the running agent's prompt, with the selected + /// lines after it when there are any. + fn editor_attach_to_agent(&mut self, id: BufferId, cx: &mut Context) { + let Some(f) = self.buffer(id).filter(|f| f.untitled.is_none()) else { + return; + }; + let state = f.input.read(cx); + let suffix = match selected_lines(state.text(), state.selected_range()) { + Some((a, b)) if a == b => format!("#L{a}"), + Some((a, b)) => format!("#L{a}-{b}"), + None => String::new(), + }; + let path = f.path.clone(); + self.attach_path_to_agent(&path, &suffix, cx); + } + + /// Right-click in the text. Edits dispatch to the editor itself, so each + /// row shows the chord that does the same thing. + fn editor_body_menu( + menu: PopupMenu, + app: &gpui::WeakEntity, + id: BufferId, + cx: &gpui::App, + ) -> PopupMenu { + use gpui_component::input as edit; + let Some(this) = app.upgrade() else { + return menu; + }; + let this = this.read(cx); + let Some(f) = this.buffer(id) else { + return menu; + }; + let state = f.input.read(cx); + let selected = !state.selected_range().is_empty(); + let mut menu = menu.min_w(px(220.)).action_context(state.focus_handle(cx)); + if f.untitled.is_none() { + menu = menu + .item( + PopupMenuItem::new(t(L10nKey::FileTreeContextAttachAgent)).on_click({ + let app = app.clone(); + move |_, _window, cx| { + let _ = app.update(cx, |this, cx| this.editor_attach_to_agent(id, cx)); + } + }), + ) + .separator(); + } + let menu = menu + .menu(t(L10nKey::AppMenuUndo), Box::new(edit::Undo)) + .menu(t(L10nKey::AppMenuRedo), Box::new(edit::Redo)) + .separator() + .menu_with_disabled(t(L10nKey::AppMenuCut), Box::new(edit::Cut), !selected) + .menu_with_disabled(t(L10nKey::AppMenuCopy), Box::new(edit::Copy), !selected) + .menu_with_disabled( + t(L10nKey::AppMenuPaste), + Box::new(edit::Paste), + cx.read_from_clipboard().is_none(), + ) + .menu(t(L10nKey::AppMenuSelectAll), Box::new(edit::SelectAll)) + .separator() + .menu(t(L10nKey::AppMenuFind), Box::new(edit::Search)) + .menu( + t(L10nKey::EditorGoToLineAction), + Box::new(crate::core::actions::EditorGoToLine), + ); + this.editor_file_menu_items(menu, id, app, cx) + } + + /// Right-click on a file in the header's strip. + fn editor_tab_menu( + menu: PopupMenu, + app: &gpui::WeakEntity, + pos: usize, + cx: &gpui::App, + ) -> PopupMenu { + let Some(this) = app.upgrade() else { + return menu; + }; + let this = this.read(cx); + let Some(files) = this.tab_code().map(|c| c.files.clone()) else { + return menu; + }; + let Some(&id) = files.get(pos) else { + return menu; + }; + let others: Vec = files.iter().copied().filter(|f| *f != id).collect(); + let right = files[pos + 1..].to_vec(); + let close = |label: L10nKey, ids: Vec| { + let app = app.clone(); + PopupMenuItem::new(t(label)) + .disabled(ids.is_empty()) + .on_click(move |_, window, cx| { + let ids = ids.clone(); + let _ = app.update(cx, |this, cx| this.editor_close_buffers(ids, window, cx)); + }) + }; + let menu = menu + .min_w(px(220.)) + .item( + PopupMenuItem::new(t(L10nKey::TabContextCloseTab)).on_click({ + let app = app.clone(); + move |_, window, cx| { + let _ = app.update(cx, |this, cx| this.editor_close_buffer(id, window, cx)); + } + }), + ) + .item(close(L10nKey::AppMenuCloseOtherTabs, others)) + .item(close(L10nKey::AppMenuCloseTabsRight, right)); + this.editor_file_menu_items(menu, id, app, cx) + } + + /// What both menus offer for the file as a whole: open it outside tty7, + /// show it in its folder, copy where it is. Nothing for a file that has + /// never been saved, since it is not anywhere yet. + fn editor_file_menu_items( + &self, + menu: PopupMenu, + id: BufferId, + app: &gpui::WeakEntity, + cx: &gpui::App, + ) -> PopupMenu { + let Some(f) = self.buffer(id).filter(|f| f.untitled.is_none()) else { + return menu; + }; + let path = f.path.clone(); + // Only this machine's desktop can open or show a file, and only a + // file that is on this machine — the same rule as the file tree. + let local = f.host.id().is_local(); + let mut menu = menu.separator(); + if local && self.can_spawn_locally(cx) && !is_program(&path) { + let browser = opens_in_browser(&path); + let label = match browser { + true => t(L10nKey::PanelOpenInBrowser), + false => t(L10nKey::AppMenuOpenLinkWithDefaultApp), + }; + let opener: fn(&Path) -> std::io::Result<()> = match browser { + true => open_in_browser, + false => crate::terminal::view::open_file_path, + }; + menu = menu.item(PopupMenuItem::new(label).on_click({ + let app = app.clone(); + let path = path.clone(); + move |_, window, cx| { + let _ = app.update(cx, |_, cx| Self::open_with(&path, opener, window, cx)); + } + })); + } + if local { + menu = menu.item( + PopupMenuItem::new(crate::ui::right_panel::reveal_label()).on_click({ + let path = path.clone(); + move |_, _window, cx| { + cx.reveal_path(&crate::ui::path_display::native_separators(&path)); + } + }), + ); + } + let copy = |label: L10nKey, text: String| { + PopupMenuItem::new(t(label)).on_click(move |_, _window, cx| { + cx.write_to_clipboard(gpui::ClipboardItem::new_string(text.clone())); + }) + }; + // A remote host's paths are already spelled its own way; only one on + // this machine is re-spelled with its separators. + let full = match local { + true => crate::ui::path_display::native_separators(&path) + .display() + .to_string(), + false => path.display().to_string(), + }; + menu = menu.item(copy(L10nKey::FileTreeContextCopyPath, full)); + if let Some(rel) = self.path_under_tree_root(&path) { + let rel = match local { + true => crate::ui::path_display::native_separators(&rel) + .display() + .to_string(), + false => rel.display().to_string(), + }; + menu = menu.item(copy(L10nKey::EditorCopyRelativePath, rel)); + } + menu + } + pub(crate) fn editor_close_active_if_focused( &mut self, window: &mut Window, @@ -2102,6 +2403,13 @@ impl Tty7App { self.close_tab(ix, window, cx); } } + AfterUnsaved::CloseFiles(tab, ids) => { + if let Some(ix) = self.tab_index_of(tab) { + for id in ids { + self.editor_remove_from_tab(ix, id, cx); + } + } + } AfterUnsaved::ClosePane => self.close_pane_after_unsaved(window, cx), AfterUnsaved::CloseWindow => self.close_window_after_unsaved(window, cx), AfterUnsaved::Quit => self.quit_after_unsaved(window, cx), @@ -2549,11 +2857,21 @@ impl Tty7App { } Some(f) => { let input = f.input.clone(); - Input::new(&input) - .appearance(false) - .font_family(cx.theme().mono_font_family.clone()) - .text_size(cx.theme().mono_font_size) + let id = f.id(); + let app = cx.entity().downgrade(); + div() + .id("editor-body") .size_full() + .child( + Input::new(&input) + .appearance(false) + .font_family(cx.theme().mono_font_family.clone()) + .text_size(cx.theme().mono_font_size) + .size_full(), + ) + .context_menu(move |menu, _window, cx| { + Self::editor_body_menu(menu, &app, id, cx) + }) .into_any_element() } }; @@ -2849,6 +3167,10 @@ impl Tty7App { this.editor_close_file(pos, window, cx); }), ) + .context_menu({ + let app = cx.entity().downgrade(); + move |menu, _window, cx| Self::editor_tab_menu(menu, &app, pos, cx) + }) .into_any_element() } @@ -3266,6 +3588,17 @@ mod tests { assert_eq!(&new[s..ne], "丰"); } + #[test] + fn an_agent_is_told_the_lines_a_selection_covers() { + let text = gpui_component::input::Rope::from("one\ntwo\nthree\n"); + assert_eq!(selected_lines(&text, 0..0), None); + assert_eq!(selected_lines(&text, 1..2), Some((1, 1))); + assert_eq!(selected_lines(&text, 2..9), Some((1, 3))); + // Whole lines picked by dragging to the start of the next one. + assert_eq!(selected_lines(&text, 4..14), Some((2, 3))); + assert_eq!(selected_lines(&text, 0..4), Some((1, 1))); + } + #[test] fn go_to_line_reads_the_forms_people_type() { assert_eq!(parse_line_target("120"), Some((120, 1))); diff --git a/src/ui/file_tree.rs b/src/ui/file_tree.rs index 0595b259..19da9ed9 100644 --- a/src/ui/file_tree.rs +++ b/src/ui/file_tree.rs @@ -1607,20 +1607,38 @@ impl Tty7App { } fn file_tree_attach_to_agent(&mut self, path: &Path, cx: &mut Context) { + self.attach_path_to_agent(path, "", cx); + } + + /// Types `@path` into the running agent's prompt, relative to the tree's + /// root when the file is under one. `suffix` goes straight after the + /// path — the editor adds the selected lines as `#L3-9`. + pub(crate) fn attach_path_to_agent( + &mut self, + path: &Path, + suffix: &str, + cx: &mut Context, + ) { let Some(target) = self.agent_target_leaf(cx) else { crate::terminal::notify_desktop(Some("tty7"), t(L10nKey::AppNoRunningCodingAgent)); return; }; let rel = self - .tab_code() + .path_under_tree_root(path) + .unwrap_or_else(|| path.to_path_buf()); + target.update(cx, |view, cx| { + view.paste(format!("@{}{suffix} ", rel.display()), cx); + }); + } + + /// `path` relative to the first of this tab's tree roots it sits under. + pub(crate) fn path_under_tree_root(&self, path: &Path) -> Option { + self.tab_code() .into_iter() .flat_map(|c| c.roots.iter()) .find_map(|r| path.strip_prefix(r).ok()) + .filter(|p| !p.as_os_str().is_empty()) .map(|p| p.to_path_buf()) - .unwrap_or_else(|| path.to_path_buf()); - target.update(cx, |view, cx| { - view.paste(format!("@{} ", rel.display()), cx); - }); } } diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 0539995a..930d0df0 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -168,7 +168,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::HomeNewTab => "New Tab", L10nKey::HomeReopenClosedTab => "Reopen Closed Tab", L10nKey::HomeSwitchWorkspace => "Switch Workspace…", - L10nKey::HomeSearchEverywhere => "Search Everywhere…", + L10nKey::HomeSearchEverywhere => "Search…", L10nKey::HomeSplitRight => "Split Right", L10nKey::HomeSplitDown => "Split Down", L10nKey::HomeSettings => "Settings…", @@ -866,6 +866,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsAgentCursorCli => "Cursor CLI", L10nKey::SettingsAgentPrimeAgent => "Prime Agent", L10nKey::SettingsAgentAntigravity => "Antigravity", + L10nKey::SettingsAgentQoderCn => "Qoder CN CLI", L10nKey::SettingsSearchAboutKeywords => "version license credits build update check github", L10nKey::SettingsSearchAppHttpProxyKeywords => { "proxy http https socks socks5 clash v2ray network download update" @@ -978,6 +979,9 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsSearchAntigravityKeywords => { "agent integration hooks install antigravity agy google" } + L10nKey::SettingsSearchQoderCnKeywords => { + "agent integration hooks install qodercn qoderclicn qoder-cn qoder china" + } L10nKey::SettingsSearchPiKeywords => "agent integration extension install pi", L10nKey::SettingsSearchPortForwardingKeywords => { "ssh tunnel local remote dynamic socks forward rule" @@ -1197,6 +1201,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::EditorAlreadyOpen => "\"{path}\" is already open in the editor", L10nKey::EditorGoToLine => "Go to line", L10nKey::EditorGoToLineAction => "Go to Line…", + L10nKey::EditorCopyRelativePath => "Copy Relative Path", L10nKey::EditorGoToLinePlaceholder => "Line, or line:column (1–{total})", L10nKey::EditorSaveAs => "Save as", L10nKey::EditorSaveAsAction => "Save As…", @@ -1983,7 +1988,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::AppMenuFind => "Find…", L10nKey::AppMenuFindNext => "Find Next", L10nKey::AppMenuFindPrevious => "Find Previous", - L10nKey::AppMenuSearchEverywhere => "Search Everywhere…", + L10nKey::AppMenuSearchEverywhere => "Search…", L10nKey::AppMenuIncreaseFontSize => "Increase Font Size", L10nKey::AppMenuDecreaseFontSize => "Decrease Font Size", L10nKey::AppMenuResetFontSize => "Reset Font Size", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index f477f18a..8f262b7b 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -859,6 +859,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsAgentCursorCli => "Cursor CLI", L10nKey::SettingsAgentPrimeAgent => "Prime Agent", L10nKey::SettingsAgentAntigravity => "Antigravity", + L10nKey::SettingsAgentQoderCn => "Qoder CN CLI", L10nKey::SettingsSearchAboutKeywords => { "バージョン ライセンス クレジット ビルド 更新 確認 github about version license credits update check" } @@ -1017,6 +1018,9 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsSearchAntigravityKeywords => { "エージェント 統合 フック インストール antigravity agy google agent integration hooks install" } + L10nKey::SettingsSearchQoderCnKeywords => { + "エージェント 統合 フック インストール qodercn qoderclicn qoder 中国版 agent integration hooks install" + } L10nKey::SettingsSearchPiKeywords => { "エージェント 統合 拡張 インストール pi agent integration extension install" } @@ -1245,6 +1249,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::EditorAlreadyOpen => "「{path}」はすでにエディタで開かれています", L10nKey::EditorGoToLine => "行へ移動", L10nKey::EditorGoToLineAction => "行へ移動…", + L10nKey::EditorCopyRelativePath => "相対パスをコピー", L10nKey::EditorGoToLinePlaceholder => "行番号、または 行:列(1–{total})", L10nKey::EditorSaveAs => "名前を付けて保存", L10nKey::EditorSaveAsAction => "名前を付けて保存…", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index ea4ba80f..c9634988 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -725,6 +725,7 @@ l10n_keys! { SettingsAgentCursorCli, SettingsAgentPrimeAgent, SettingsAgentAntigravity, + SettingsAgentQoderCn, SettingsSearchAppHttpProxyKeywords, SettingsSearchAboutKeywords, SettingsSearchAutoDownloadKeywords, @@ -786,6 +787,7 @@ l10n_keys! { SettingsSearchCursorCliKeywords, SettingsSearchPrimeAgentKeywords, SettingsSearchAntigravityKeywords, + SettingsSearchQoderCnKeywords, SettingsSearchRememberWindowSizeKeywords, SettingsSearchReportMouseToAppsKeywords, SettingsSearchRestoreLastLayoutKeywords, @@ -933,6 +935,7 @@ l10n_keys! { EditorAlreadyOpen, EditorGoToLine, EditorGoToLineAction, + EditorCopyRelativePath, EditorGoToLinePlaceholder, EditorSaveAs, EditorSaveAsAction, @@ -1840,6 +1843,7 @@ mod tests { L10nKey::SettingsAgentCursorCli, L10nKey::SettingsAgentPrimeAgent, L10nKey::SettingsAgentAntigravity, + L10nKey::SettingsAgentQoderCn, // Windows names its backdrop materials, and Japanese Windows keeps // those names in Latin script — so does this list. Chinese does // translate them (云母 / 亚克力), which is what Microsoft's own diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index afc23def..f12cdcce 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -749,6 +749,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsAgentCursorCli => "Cursor CLI", L10nKey::SettingsAgentPrimeAgent => "Prime Agent", L10nKey::SettingsAgentAntigravity => "Antigravity", + L10nKey::SettingsAgentQoderCn => "Qoder CN CLI", L10nKey::SettingsSearchAboutKeywords => { "关于 版本 许可证 致谢 构建 更新 检查 github about version license credits update" } @@ -901,6 +902,9 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsSearchAntigravityKeywords => { "Antigravity agent 集成 钩子 安装 antigravity agy google" } + L10nKey::SettingsSearchQoderCnKeywords => { + "Qoder CN CLI 国内版 agent 集成 钩子 安装 qodercn qoderclicn qoder-cn qoder" + } L10nKey::SettingsSearchPiKeywords => { "Pi agent 集成 扩展 安装 pi agent integration extension install" } @@ -1117,6 +1121,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::EditorAlreadyOpen => "“{path}”已在编辑器中打开", L10nKey::EditorGoToLine => "跳转到行", L10nKey::EditorGoToLineAction => "跳转到行…", + L10nKey::EditorCopyRelativePath => "复制相对路径", L10nKey::EditorGoToLinePlaceholder => "行号,或 行:列(1–{total})", L10nKey::EditorSaveAs => "另存为", L10nKey::EditorSaveAsAction => "另存为…", diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs index 4f476310..e28d7b91 100644 --- a/src/ui/keymap.rs +++ b/src/ui/keymap.rs @@ -1685,7 +1685,7 @@ mod tests { assert_eq!(action_entry("NewWindow").1, "New Window"); assert_eq!(action_entry("CloseWindow").1, "Close Window"); assert_eq!(action_entry("ClearScrollback").1, "Clear Scrollback"); - assert_eq!(action_entry("TogglePalette").1, "Search Everywhere…"); + assert_eq!(action_entry("TogglePalette").1, "Search…"); assert_eq!(action_entry("ToggleSwitcher").1, "Switch Workspace…"); // The numbered families are templated, not nine strings per locale. assert_eq!(action_entry("ActivateTab3").1, "Go to Tab 3"); diff --git a/src/ui/settings.rs b/src/ui/settings.rs index e0338afa..cda90751 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -611,6 +611,11 @@ fn settings_search_entries() -> &'static [SearchEntry] { title: SettingsAgentQoderCLI, keywords: SettingsSearchQoderCLIKeywords, }, + SearchEntry { + section: Agents, + title: SettingsAgentQoderCn, + keywords: SettingsSearchQoderCnKeywords, + }, SearchEntry { section: Agents, title: SettingsAgentCrush, diff --git a/src/ui/settings/agents.rs b/src/ui/settings/agents.rs index 44b474d5..62034039 100644 --- a/src/ui/settings/agents.rs +++ b/src/ui/settings/agents.rs @@ -25,6 +25,7 @@ fn cli_agent(agent: HookAgent) -> crate::core::cli_agent::CLIAgent { HookAgent::Goose => C::Goose, HookAgent::Kimi => C::Kimi, HookAgent::QoderCLI => C::QoderCLI, + HookAgent::QoderCLICn => C::QoderCLICn, HookAgent::Crush => C::Crush, HookAgent::CodeBuddy => C::CodeBuddy, HookAgent::Cursor => C::Cursor,