From e94c0d39cbfb052c8b97493f18aa4f79780756d0 Mon Sep 17 00:00:00 2001 From: LiuSir <32128085+CodeHourra@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:12:55 +0800 Subject: [PATCH 1/4] fix(path): read the interactive shell's PATH, not just the login shell's (#989) * fix(path): read the interactive shell's PATH, not just the login shell's The GUI starts with launchd's bare PATH and refills it by running the login shell. A login shell reads .zprofile/.bash_profile and never .zshrc/.bashrc, so every directory exported there was invisible to the app: agent quick launch found only what /opt/homebrew/bin and /usr/local/bin happened to hold, and an agent_launch override could not bring a missing program back, since the override's program is looked up on the same PATH. Probe with -i as well (fish unchanged: it reads its config in every mode) and take the last non-empty line, so an rc that prints a greeting above the echo $PATH cannot be mistaken for the value. * fix(path): bound the interactive PATH probe and bracket its output Running .zshrc/.bashrc on the startup path needs guards the login-only probe could do without: - Read the PATH between markers instead of the last line, so an exit hook that prints after it cannot be taken for the PATH. - Return as soon as the closing marker arrives rather than at EOF: an rc that backgrounds a daemon hands it the pipe, which then never closes. - Give up after 5s and kill the shell, so a slow or stuck rc delays startup instead of preventing it. - Probe after forwarding an open path to a running window, which has no use for the PATH. * docs(changelog): describe the bounded PATH probe --------- Co-authored-by: stevelliu Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> --- CHANGELOG.md | 14 +++++ src/main.rs | 144 +++++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 137 insertions(+), 21 deletions(-) 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/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() { From 388ddf43038891fa6996174e8180c5d84c6099e3 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:35:06 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(search):=20label=20the=20search=20entry?= =?UTF-8?q?=20point=20"Search=E2=80=A6"=20(#990)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Everywhere" added nothing: the modal's tabs already say what it covers. --- src/ui/i18n/en.rs | 4 ++-- src/ui/keymap.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 0539995a..f28f8049 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…", @@ -1983,7 +1983,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/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"); From 643e0f7d9500c1a898b3e24b96b6dbd756ffc0e5 Mon Sep 17 00:00:00 2001 From: ARNO Date: Mon, 28 Sep 2026 13:35:10 +0800 Subject: [PATCH 3/4] feat(agents): add Qoder CN CLI integration (#988) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(agents): add Qoder CN CLI integration * fix(agents): spell Qoder CN's config override QODERCN_CONFIG_DIR The China build of Qoder resolves its configuration through QODERCN_CONFIG_DIR. The first pass invented QODER_CN_CONFIG_DIR instead, so an install that set the real one was treated as unrelocated: hooks went to ~/.qoder-cn and history was scanned from a directory the CLI never writes to. The test that should have caught it set the same invented name, so it only proved the name agreed with itself. Picks up the rest of the review as well — detect the `qoder-cn` dispatcher beside the other two binaries, call it the mainland-China build rather than a different vendor, and drop the QODER_CN_HOOK_EVENTS alias for the table it only pointed at. The serialized enums keep their variants appended; only the independent ALL arrays moved QoderCLICn next to QoderCLI. --- README.md | 3 +- README.zh-CN.md | 2 + assets/icons/agents/qoderclicn.svg | 4 + crates/tty7-core/src/core/agent_history.rs | 14 ++- crates/tty7-core/src/core/agent_hooks.rs | 133 ++++++++++++++++++++- crates/tty7-core/src/core/cli_agent.rs | 59 ++++++++- docs/agents/overview.mdx | 7 +- docs/agents/sessions.mdx | 2 + docs/agents/status.mdx | 2 +- src/ui/assets.rs | 4 + src/ui/i18n/en.rs | 4 + src/ui/i18n/ja.rs | 4 + src/ui/i18n/mod.rs | 3 + src/ui/i18n/zh.rs | 4 + src/ui/settings.rs | 5 + src/ui/settings/agents.rs | 1 + 16 files changed, 236 insertions(+), 15 deletions(-) create mode 100644 assets/icons/agents/qoderclicn.svg 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/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/i18n/en.rs b/src/ui/i18n/en.rs index f28f8049..1acc06fa 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -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" diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index f477f18a..2fe53b52 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" } diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index ea4ba80f..6b2e075f 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, @@ -1840,6 +1842,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..62e3b829 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" } 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, From f647a197467edb582f9681860f87e26b8f76c3a4 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:35:15 +0800 Subject: [PATCH 4/4] feat(editor): draw the editor's right-click menus like the rest of the window (#992) * feat(editor): draw the editor's right-click menus like the rest of the window The text's menu was gpui-component's native OS menu: another font and material, no shortcuts, and Go to Definition / Show Code Actions that no language server ever enables. It is now a PopupMenu with Attach to Agent (the selected lines ride along as #L3-9), Undo/Redo, the clipboard, Find, Go to Line, and the file's own items: Open in Browser for web files or Open with Default App, Reveal, Copy Path, Copy Relative Path. The file tabs get a menu too: Close, Close Others, Close to the Right, and the same file items. Closing several asks once about unsaved edits. * chore(deps): gpui-component moves the caret on right-click under a host menu --- Cargo.lock | 40 ++--- src/ui/code_editor.rs | 349 +++++++++++++++++++++++++++++++++++++++++- src/ui/file_tree.rs | 28 +++- src/ui/i18n/en.rs | 1 + src/ui/i18n/ja.rs | 1 + src/ui/i18n/mod.rs | 1 + src/ui/i18n/zh.rs | 1 + 7 files changed, 388 insertions(+), 33 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2e80b87d..928beabb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -298,7 +298,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -309,7 +309,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2148,7 +2148,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2186,7 +2186,7 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" dependencies = [ - "libloading 0.7.4", + "libloading 0.8.9", ] [[package]] @@ -2438,7 +2438,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3365,7 +3365,7 @@ dependencies = [ [[package]] name = "gpui-component" version = "0.5.2" -source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#107d83743bda07cace85b9d0b31fda4b922fcf85" +source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#055f92dbf0d9c18065a614492965018656f10f36" dependencies = [ "aho-corasick", "anyhow", @@ -3448,7 +3448,7 @@ dependencies = [ [[package]] name = "gpui-component-assets" version = "0.5.1" -source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#107d83743bda07cace85b9d0b31fda4b922fcf85" +source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#055f92dbf0d9c18065a614492965018656f10f36" dependencies = [ "anyhow", "gpui", @@ -3462,7 +3462,7 @@ dependencies = [ [[package]] name = "gpui-component-macros" version = "0.5.1" -source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#107d83743bda07cace85b9d0b31fda4b922fcf85" +source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#055f92dbf0d9c18065a614492965018656f10f36" dependencies = [ "proc-macro2", "quote", @@ -5281,7 +5281,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5337,7 +5337,7 @@ dependencies = [ "once_cell", "png", "thiserror 2.0.20", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5560,7 +5560,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6985,7 +6985,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7721,7 +7721,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -8499,7 +8499,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -8637,7 +8637,7 @@ dependencies = [ "cfg-if", "libc", "psm", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -9039,7 +9039,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -9506,7 +9506,7 @@ dependencies = [ "once_cell", "png", "thiserror 2.0.20", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -10026,7 +10026,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset 0.9.1", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -10876,7 +10876,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -11627,7 +11627,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" dependencies = [ "cfg-if", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] 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 1acc06fa..930d0df0 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -1201,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…", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 2fe53b52..8f262b7b 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -1249,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 6b2e075f..c9634988 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -935,6 +935,7 @@ l10n_keys! { EditorAlreadyOpen, EditorGoToLine, EditorGoToLineAction, + EditorCopyRelativePath, EditorGoToLinePlaceholder, EditorSaveAs, EditorSaveAsAction, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 62e3b829..f12cdcce 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -1121,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 => "另存为…",