mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-11-06 00:15:06 +00:00
Merge origin/main into feat/v5-modals
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
<details>
|
||||
<summary>The full support matrix, all twenty-five</summary>
|
||||
<summary>The full support matrix, all twenty-six</summary>
|
||||
|
||||
| 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** | ✓ | ✓ | |
|
||||
|
||||
@@ -81,6 +81,8 @@ macOS、Windows、Linux 的原生构建都在 [**Releases**](https://github.com/
|
||||
| **Droid** | ✓ | ✓ | ✓ |
|
||||
| **Qwen Code** | ✓ | ✓ | ✓ |
|
||||
| **Goose** | ✓ | ✓ | ✓ |
|
||||
| **Qoder CLI** | ✓ | ✓ | ✓ |
|
||||
| **Qoder CN CLI** | ✓ | ✓ | ✓ |
|
||||
| **Gemini** | ✓ | ✓ | |
|
||||
| **Copilot** | ✓ | ✓ | |
|
||||
| **Kimi Code** | ✓ | ✓ | |
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 304 304" width="304" height="304">
|
||||
<path d="M231.5 283.39C212.11 292.92 189 301.61 166.75 297.81C153.5 295.55 142.22 289.6 130.68 283.17C115.17 274.53 99.21 266.04 83.28 258.21C74.31 253.8 65.61 248.73 56.69 244.16C39.8 235.52 27.3 228.1 17.19 211.3C6.03 192.76 6.99 171.12 6.99 150.17C6.99 134.63 5.88 118.64 9.18 103.38C16.37 70.11 37.46 40.35 66.32 22.17C85.66 9.98 115.65 1.77 138.41 7.15C150.97 10.12 161.64 17.11 173.02 22.81C189.54 31.08 205.77 40.01 222.1 48.74C245.52 61.26 270.45 69.84 283.84 94.65C297.12 119.25 293.01 150.06 293.01 177.17C293.01 197.72 293.01 218.28 293.01 238.83C293.01 248.73 294.8 260.66 291.76 270.19C287.47 283.7 273.26 294.72 258.6 293.5C252.45 292.98 247.76 290.52 242.24 288.23C238.74 286.77 234.68 285.48 231.5 283.39ZM191.9 63.5C187.95 59.69 181.25 57.23 176.29 54.85C167.96 50.85 159.83 46.17 151.7 41.8C145.39 38.41 139.18 34.97 132.17 33.26C122.25 30.84 112.15 33.2 102.61 35.81C72.6 44.02 50.57 67.35 39.21 95.73C32.39 112.77 33.84 131.45 33.84 149.5C33.84 166.9 32 185.63 42.15 200.69C49.73 211.94 62.59 217.37 74.29 223.2C82.51 227.3 90.62 231.76 98.72 236.14C101.09 237.43 105.96 241.15 108.54 240.83C108.5 236.87 107.2 232.85 107.07 228.83C106.72 217.74 106.99 206.6 106.99 195.5C106.99 184.24 106.63 173.01 108.11 161.84C111.9 133.07 128.92 104.76 150.97 86.14C159.42 79 168.85 72.6 179.02 68.19C182.64 66.62 188.93 65.5 191.9 63.5ZM134.04 226.5C133.71 230.93 134.73 235.44 135.82 239.68C141.78 262.78 164.37 275.32 187.6 270.81C196.28 269.13 204.62 266.22 212.7 262.84C217.41 260.88 221.61 256.31 226.59 255.15C235.92 252.97 245.64 261.06 253.98 264.19C258.08 265.72 263.09 268.43 265.74 262.9C267.25 259.77 266.06 249.39 266.06 245.5C266.06 231.17 266.06 216.83 266.06 202.5C266.06 184.5 266.16 166.5 266.16 148.5C266.16 137.6 267.14 125.92 263.79 115.4C257.07 94.29 236.49 81.97 214.51 85.73C206.58 87.08 198.51 89.11 191.17 92.27C190.26 96.24 190.94 101.07 190.94 105.17C190.94 117.19 191.88 129.73 189.82 141.62C185.49 166.6 174.02 189.29 156.68 207.84C151.56 213.31 145.67 218.38 139.59 222.76C137.8 224.05 135.67 225.04 134.04 226.5ZM163.5 111C147.55 126.96 134.79 149.54 134 172.5C133.84 177.17 133.84 181.83 133.84 186.5C133.84 188.15 133.3 190.79 134.5 192C150.7 175.79 163.86 153.06 163.99 129.5C164.01 126.31 165.05 112.87 163.5 111Z" fill="#0f0d0c" fill-rule="evenodd" stroke="#0f0d0c" stroke-width="0.25" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -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<PastSession> {
|
||||
&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<PastSession> {
|
||||
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(),
|
||||
|
||||
@@ -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::<serde_json::Value>(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<String> {
|
||||
| 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";
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -41,6 +41,8 @@ or right-click the **tab or sidebar row** to open the fork in a new tab.
|
||||
| Codex | `codex fork <id>` |
|
||||
| TraeCode | `traecli fork <id>` |
|
||||
| Grok | `grok --resume <id> --fork-session` |
|
||||
| Qoder CLI | `qodercli --resume <id> --fork-session` |
|
||||
| Qoder CN CLI | `qodercn --resume <id> --fork-session` |
|
||||
| OpenCode | `opencode --session <id> --fork` |
|
||||
| Oh My Pi | `omp --fork <id>` |
|
||||
| Prime Agent | `prime-agent --fork <id>` |
|
||||
|
||||
@@ -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
|
||||
|
||||
+123
-21
@@ -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<String> {
|
||||
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<String> {
|
||||
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() {
|
||||
|
||||
@@ -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")
|
||||
|
||||
+341
-8
@@ -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<BufferId>),
|
||||
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<String> {
|
||||
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<usize>,
|
||||
) -> 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<Self>,
|
||||
) {
|
||||
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<Self>) {
|
||||
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<BufferId>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let tab_ix = self.active;
|
||||
let Some(tab) = self.tabs.get(tab_ix).map(|t| t.tree_id.get()) else {
|
||||
return;
|
||||
};
|
||||
let unsaved: Vec<BufferId> = 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<Self>) {
|
||||
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<Self>,
|
||||
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<Self>,
|
||||
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<BufferId> = files.iter().copied().filter(|f| *f != id).collect();
|
||||
let right = files[pos + 1..].to_vec();
|
||||
let close = |label: L10nKey, ids: Vec<BufferId>| {
|
||||
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<Self>,
|
||||
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)));
|
||||
|
||||
+23
-5
@@ -1607,20 +1607,38 @@ impl Tty7App {
|
||||
}
|
||||
|
||||
fn file_tree_attach_to_agent(&mut self, path: &Path, cx: &mut Context<Self>) {
|
||||
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<Self>,
|
||||
) {
|
||||
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<PathBuf> {
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+7
-2
@@ -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",
|
||||
|
||||
@@ -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 => "名前を付けて保存…",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 => "另存为…",
|
||||
|
||||
+1
-1
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user