diff --git a/CHANGELOG.md b/CHANGELOG.md index e6cab309..b5328e8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Quick launch for the coding agents on your PATH** (#955). Every agent tty7 + recognises whose binary is on `PATH` is a palette command — "Agent: Claude + Code", "Agent: Codex", … — ordered by how often and how recently it was + launched or seen running, and each is bindable as `LaunchAgent:`. "New + Agent Tab" (⌘⇧A on macOS, unbound elsewhere, where Ctrl+Shift+A is + select-all) starts the one used last, and the New Tab menu's "Launch Agent…" + row opens the palette on the list. A launch always opens a new tab in the + current tab's directory and types the agent's command into its shell once the + pane exists, never into a pane that was already there, so detection, status + and resume work as for a hand-typed agent and quitting it returns to the + shell. The command is the agent's bare binary unless `agent_launch` in + `config.json` gives it one (`"claude": "claude --dangerously-skip-permissions"`); + a wrapper named there is detected as that agent without an `agent_commands` + entry, including a script run under its interpreter, and the daemon picks up + an edit without a restart. A running agent's pane menu has "Set Current Launch + Args as Default", which writes the flags it was started with — minus the + session it resumed and any prompt — into `agent_launch`. A remote workspace + cannot be asked for its `PATH`, so there the list is the agents already seen + running in that workspace. + - **Tabs can be put to sleep, and woken where they were** (#762). Right-click a tab → Hibernate, or "Hibernate Tab" in the command palette, stops every process in the tab to give its memory back while the tab keeps its place in diff --git a/README.md b/README.md index a29edb91..9754c716 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,8 @@ that tells tty7 which session to fork. None of them are wrapped or proxied — the agent you start is the agent you get, in a normal PTY, with its own interface. An agent launched through a wrapper script can be mapped to one by name with `agent_commands` in `config.json`. +Every agent on your `PATH` is also a palette command (**Agent: Claude Code**, …) +that opens it in a new tab; `agent_launch` sets the command line it starts with. ## Documentation diff --git a/README.zh-CN.md b/README.zh-CN.md index 67a78a13..571937ec 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -96,6 +96,7 @@ macOS、Windows、Linux 的原生构建都在 [**Releases**](https://github.com/ tty7 不包装、不代理其中任何一个 —— 你启动的就是那个 agent 本身,运行在普通 PTY 中,界面仍然是它自己的。 如果你通过 wrapper 脚本启动 agent,在 `config.json` 的 `agent_commands` 里把脚本名映射到对应 agent 即可。 +PATH 上的每个 agent 也都是一条命令面板命令(**Agent:Claude Code** 等),会在新标签页中启动它;`agent_launch` 可以设置它的启动命令行。 ## 文档 diff --git a/crates/tty7-core/src/core/cli_agent.rs b/crates/tty7-core/src/core/cli_agent.rs index eb7d5865..42b44526 100644 --- a/crates/tty7-core/src/core/cli_agent.rs +++ b/crates/tty7-core/src/core/cli_agent.rs @@ -304,6 +304,32 @@ impl CLIAgent { } fn replay_flags(self, argv: &[String]) -> Option> { + let tail = self.session_free_tail(argv)?; + let safe = |t: &str| { + !t.is_empty() + && t.bytes() + .all(|b| b.is_ascii_alphanumeric() || b"-_=./,:@+~".contains(&b)) + }; + if !tail.iter().all(|t| safe(t)) { + return None; + } + let mut prev_was_flag = false; + for t in &tail { + let is_flag = t.starts_with('-'); + if !is_flag && !prev_was_flag { + return None; + } + prev_was_flag = is_flag; + } + Some(tail.into_iter().map(String::from).collect()) + } + + /// What follows the agent's own name in `argv`, with everything that names + /// a session taken out: the resume/fork subcommands and the flags listed as + /// stale for this agent, each with its value. `None` when no token in + /// `argv` names this agent — a wrapper, where there is no telling which + /// arguments are the agent's. + fn session_free_tail(self, argv: &[String]) -> Option> { let names_self = |token: &str| { token.split(['/', '\\']).any(|seg| { CLIAgent::match_token(&base_stem(seg).to_ascii_lowercase()) == Some(self) @@ -490,24 +516,7 @@ impl CLIAgent { i += 1; } } - - let safe = |t: &str| { - !t.is_empty() - && t.bytes() - .all(|b| b.is_ascii_alphanumeric() || b"-_=./,:@+~".contains(&b)) - }; - if !tail.iter().all(|t| safe(t)) { - return None; - } - let mut prev_was_flag = false; - for t in &tail { - let is_flag = t.starts_with('-'); - if !is_flag && !prev_was_flag { - return None; - } - prev_was_flag = is_flag; - } - Some(tail.into_iter().map(String::from).collect()) + Some(tail) } pub fn accent_rgb(self) -> u32 { @@ -598,6 +607,58 @@ impl CLIAgent { .find(|a| a.aliases().contains(&token)) } + /// The program a launch with no override runs: the first name the agent + /// is detected by, which is the one its installer puts on `PATH`. + pub fn binary(self) -> &'static str { + self.aliases()[0] + } + + /// The command line a quick launch types for this agent: the user's + /// `agent_launch` entry when there is one, the bare binary otherwise. + /// + /// Keys are matched the way [`CLIAgent::from_slug`] reads them, so a + /// hand-written `"Claude"` still counts. A blank entry is no override. + pub fn launch_command(self, overrides: &HashMap) -> String { + overrides + .iter() + .find(|(slug, _)| CLIAgent::from_slug(slug) == Some(self)) + .map(|(_, line)| line.trim()) + .filter(|line| !line.is_empty()) + .map_or_else(|| self.binary().to_string(), str::to_string) + } + + /// `argv` as a command line worth launching this agent with again: its + /// binary followed by the flags it ran with, minus everything that names + /// one particular session (a resume, a fork, a session id) and minus + /// positional arguments such as a prompt, which belong to that one run. + /// + /// A run that went through something that does not name the agent — a + /// wrapper script mapped by `agent_commands` — cannot be taken apart, so + /// it comes back whole: it is what was typed, and it launched the agent. + pub fn launch_argv_for_default(self, argv: &[String]) -> Option> { + let env = argv.iter().take_while(|t| is_env_assignment(t)).count(); + if argv.len() == env { + return None; + } + let Some(tail) = self.session_free_tail(argv) else { + return Some(argv.to_vec()); + }; + let mut out: Vec = argv[..env].to_vec(); + out.push(self.binary().to_string()); + let mut takes_value = false; + for t in tail { + let is_flag = t.starts_with('-') && t.len() > 1; + if is_flag { + out.push(t.to_string()); + takes_value = !t.contains('='); + } else if takes_value { + out.push(t.to_string()); + takes_value = false; + } + } + Some(out) + } + #[cfg_attr(not(test), allow(dead_code))] pub fn detect_from_argv(argv: &[String]) -> Option { Self::detect_from_argv_with(argv, &HashMap::new()) @@ -625,6 +686,20 @@ impl CLIAgent { return Some(agent); } + // A wrapper that is a script runs as its interpreter, so the name the + // user mapped is the script's, one token in: `bash ~/bin/cc`, + // `node ~/bin/cc.js`. Only the mapped names are looked for there — the + // built-in ones already have the scan below. + if (is_interpreter(launcher_stem) || is_shell(launcher_stem)) + && let Some(agent) = rest + .clone() + .find(|a| !a.starts_with('-') && !is_env_assignment(a)) + .and_then(|script| custom.get(&base_stem(script).to_ascii_lowercase())) + .and_then(|slug| CLIAgent::from_slug(slug)) + { + return Some(agent); + } + if is_interpreter(launcher_stem) { for arg in rest { if arg.starts_with('-') { @@ -719,6 +794,99 @@ fn is_interpreter(stem: &str) -> bool { ) } +fn is_shell(stem: &str) -> bool { + matches!( + stem.to_ascii_lowercase().as_str(), + "sh" | "bash" | "zsh" | "dash" | "ksh" | "fish" + ) +} + +/// The program a launch command line runs: its first word that is not an +/// environment assignment. +pub fn launch_program(command: &str) -> Option { + command_argv(command) + .into_iter() + .find(|t| !is_env_assignment(t)) +} + +/// The wrapper names `agent_launch` implies, in the shape `agent_commands` +/// takes: `{"claude": "cc --fast"}` means a `cc` in a pane is Claude Code. +/// +/// This is how a pane opened by a quick launch through a wrapper is still +/// recognised without the user also writing an `agent_commands` entry: the +/// launch typed exactly this command, so its program is known to be that +/// agent. A program that is already an agent's own name needs no entry, and +/// an interpreter (`npx`, `node`, `env`, …) or a shell is never mapped +/// wholesale — `npx` is not Claude Code just because Claude Code is one of the +/// things it can run. +pub fn launch_aliases(overrides: &HashMap) -> HashMap { + let mut out = HashMap::new(); + for (slug, line) in overrides { + let Some(agent) = CLIAgent::from_slug(slug) else { + continue; + }; + let Some(program) = launch_program(line) else { + continue; + }; + let stem = base_stem(&program).to_ascii_lowercase(); + if stem.is_empty() + || CLIAgent::match_token(&stem).is_some() + || is_interpreter(&stem) + || is_shell(&stem) + { + continue; + } + out.insert(stem, agent.slug().to_string()); + } + out +} + +/// Whether `program` names an executable file, either directly (it has a +/// directory in it) or through one of the directories in `path`. +/// +/// On Windows a bare name is also tried with the extensions an npm or winget +/// install leaves behind, since `claude` there is really `claude.cmd`. +pub fn program_on_path(program: &str, path: &std::ffi::OsStr) -> bool { + let program = program.trim(); + if program.is_empty() { + return false; + } + if program.contains(['/', '\\']) { + // The launch is typed into a shell, which expands a leading `~/`. + let home = program + .strip_prefix("~/") + .and_then(|rest| Some(std::path::PathBuf::from(std::env::var_os("HOME")?).join(rest))); + return is_executable(home.as_deref().unwrap_or(std::path::Path::new(program))); + } + let exts: &[&str] = if cfg!(windows) { + &["", ".exe", ".cmd", ".bat", ".ps1"] + } else { + &[""] + }; + std::env::split_paths(path).any(|dir| { + exts.iter() + .any(|ext| is_executable(&dir.join(format!("{program}{ext}")))) + }) +} + +fn is_executable(path: &std::path::Path) -> bool { + let Ok(meta) = std::fs::metadata(path) else { + return false; + }; + if !meta.is_file() { + return false; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + meta.permissions().mode() & 0o111 != 0 + } + #[cfg(not(unix))] + { + true + } +} + pub const AGENT_EVENT_SENTINEL: &str = "tty7://cli-agent"; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -2253,4 +2421,160 @@ mod tests { "\"waiting\"" ); } + + fn overrides(pairs: &[(&str, &str)]) -> HashMap { + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() + } + + #[test] + fn a_launch_is_the_bare_binary_unless_overridden() { + let none = HashMap::new(); + assert_eq!(CLIAgent::Claude.launch_command(&none), "claude"); + assert_eq!(CLIAgent::Codex.launch_command(&none), "codex"); + assert_eq!(CLIAgent::Cursor.launch_command(&none), "cursor-agent"); + assert_eq!(CLIAgent::Antigravity.launch_command(&none), "agy"); + assert_eq!(CLIAgent::QoderCLI.launch_command(&none), "qoder"); + + let set = overrides(&[ + ("claude", " claude --dangerously-skip-permissions "), + // Keys are read the way `from_slug` reads them. + ("Codex", "codex --model o3"), + // A blank entry is no override at all. + ("gemini", " "), + ]); + assert_eq!( + CLIAgent::Claude.launch_command(&set), + "claude --dangerously-skip-permissions" + ); + assert_eq!(CLIAgent::Codex.launch_command(&set), "codex --model o3"); + assert_eq!(CLIAgent::Gemini.launch_command(&set), "gemini"); + assert_eq!(CLIAgent::Aider.launch_command(&set), "aider"); + } + + #[test] + fn every_agent_launches_as_a_name_it_is_detected_by() { + let none = HashMap::new(); + for agent in CLIAgent::ALL { + let argv = command_argv(&agent.launch_command(&none)); + assert_eq!( + CLIAgent::detect_from_argv(&argv), + Some(agent), + "{agent:?} would launch as something detection does not know" + ); + } + } + + #[test] + fn a_wrapper_named_by_a_launch_override_is_detected_as_its_agent() { + let aliases = launch_aliases(&overrides(&[("claude", "FOO=1 cc --fast")])); + // Run as a binary, and run as a script under its interpreter. + for run in [ + argv(&["cc", "--fast"]), + argv(&["/Users/me/bin/cc", "--fast"]), + argv(&["/bin/bash", "/Users/me/bin/cc", "--fast"]), + argv(&["node", "/Users/me/bin/cc.js", "--fast"]), + ] { + assert_eq!( + CLIAgent::detect_from_argv_with(&run, &aliases), + Some(CLIAgent::Claude), + "{run:?}" + ); + } + // Without the override the same wrapper is nobody. + assert_eq!( + CLIAgent::detect_from_argv_with(&argv(&["cc", "--fast"]), &HashMap::new()), + None + ); + } + + #[test] + fn a_launch_override_never_maps_an_interpreter_or_a_real_agent_name() { + let aliases = launch_aliases(&overrides(&[ + ("claude", "npx @anthropic-ai/claude-code"), + ("codex", "bash -lc codex"), + // Already detected as itself; mapping it would add nothing. + ("gemini", "gemini --yolo"), + ("not-an-agent", "cc"), + ])); + assert!(aliases.is_empty(), "{aliases:?}"); + // A shell running something else is still nobody. + assert_eq!( + CLIAgent::detect_from_argv_with( + &argv(&["bash", "./build.sh"]), + &launch_aliases(&overrides(&[("claude", "cc")])) + ), + None + ); + } + + #[test] + fn launch_args_drop_the_session_and_the_prompt_but_keep_the_flags() { + assert_eq!( + CLIAgent::Claude.launch_argv_for_default(&argv(&[ + "node", + "/opt/homebrew/lib/node_modules/@anthropic-ai/claude-code/cli.js", + "--model", + "opus", + "--resume", + "abc-123", + "--append-system-prompt", + "be terse", + "fix the bug", + ])), + Some(argv(&[ + "claude", + "--model", + "opus", + "--append-system-prompt", + "be terse", + ])) + ); + assert_eq!( + CLIAgent::Codex.launch_argv_for_default(&argv(&[ + "codex", + "resume", + "019a", + "--model=o3", + "prompt", + ])), + Some(argv(&["codex", "--model=o3"])) + ); + // A wrapper cannot be taken apart; what was typed is what launched it. + assert_eq!( + CLIAgent::Claude.launch_argv_for_default(&argv(&["cc", "--fast", "x"])), + Some(argv(&["cc", "--fast", "x"])) + ); + assert_eq!(CLIAgent::Claude.launch_argv_for_default(&[]), None); + } + + #[test] + fn program_on_path_finds_only_executables() { + let dir = tempfile::TempDir::new().unwrap(); + let bin = dir.path().join("claude"); + std::fs::write(&bin, "#!/bin/sh\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + std::fs::write(dir.path().join("codex"), "not executable").unwrap(); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert!(!program_on_path("codex", dir.path().as_os_str())); + } + std::fs::create_dir(dir.path().join("gemini")).unwrap(); + let path = + std::env::join_paths([std::path::Path::new("/nonexistent"), dir.path()]).unwrap(); + assert!(program_on_path("claude", &path)); + assert!( + !program_on_path("gemini", &path), + "a directory is not a program" + ); + assert!(!program_on_path("aider", &path)); + assert!(program_on_path( + bin.to_str().unwrap(), + std::ffi::OsStr::new("") + )); + assert!(!program_on_path("", &path)); + } } diff --git a/crates/tty7-core/src/core/config.rs b/crates/tty7-core/src/core/config.rs index 577b39ea..b277f52f 100644 --- a/crates/tty7-core/src/core/config.rs +++ b/crates/tty7-core/src/core/config.rs @@ -416,6 +416,17 @@ pub struct Config { #[serde(default)] pub agent_commands: HashMap, + /// The command line a quick launch types for an agent, keyed by its slug: + /// `{"claude": "claude --dangerously-skip-permissions"}`. An agent with no + /// entry is launched as its bare binary. A wrapper named here is detected + /// as that agent without an `agent_commands` entry of its own. + #[serde(default)] + pub agent_launch: HashMap, + /// How often and how recently each agent (by slug) was launched or seen + /// running — what orders the quick-launch commands and picks the agent + /// "New Agent Tab" opens. + #[serde(default)] + pub agent_frecency: HashMap, #[serde(default = "default_true")] pub restore_agent_sessions: bool, /// Give each pane its own shell history instead of one file every pane @@ -799,6 +810,8 @@ impl Default for Config { shell_frecency: HashMap::new(), command_frecency: HashMap::new(), agent_commands: HashMap::new(), + agent_launch: HashMap::new(), + agent_frecency: HashMap::new(), restore_agent_sessions: true, per_pane_history: false, quarantined: false, @@ -1419,15 +1432,44 @@ pub fn extra_env() -> HashMap { Config::load().env } -pub fn agent_commands_cached() -> &'static HashMap { - static CACHE: std::sync::OnceLock> = std::sync::OnceLock::new(); - CACHE.get_or_init(|| { - Config::load() - .agent_commands - .into_iter() - .map(|(k, v)| (k.to_ascii_lowercase(), v)) - .collect() - }) +/// Every wrapper name the daemon should read as an agent: `agent_commands`, +/// plus the programs `agent_launch` entries run (see +/// [`crate::core::cli_agent::launch_aliases`]). An explicit `agent_commands` +/// entry wins over an implied one. +/// +/// Read again whenever `config.json` changes rather than once per process: the +/// daemon outlives any number of edits, and a launch override added while it +/// runs has to be recognised by the very next launch that uses it. +pub fn agent_detection_aliases() -> std::sync::Arc> { + type Stamp = Option<(std::time::SystemTime, u64)>; + static CACHE: std::sync::Mutex>)>> = + std::sync::Mutex::new(None); + let stamp: Stamp = Config::path() + .and_then(|p| std::fs::metadata(p).ok()) + .and_then(|m| Some((m.modified().ok()?, m.len()))); + let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner()); + if let Some((seen, aliases)) = cache.as_ref() + && *seen == stamp + { + return aliases.clone(); + } + let cfg = Config::load(); + let aliases = std::sync::Arc::new(detection_aliases(&cfg.agent_commands, &cfg.agent_launch)); + *cache = Some((stamp, aliases.clone())); + aliases +} + +/// [`agent_detection_aliases`] over the two maps it reads, for a caller that +/// already holds them. +pub fn detection_aliases( + commands: &HashMap, + launch: &HashMap, +) -> HashMap { + let mut aliases = crate::core::cli_agent::launch_aliases(launch); + for (name, slug) in commands { + aliases.insert(name.to_ascii_lowercase(), slug.clone()); + } + aliases } fn default_preset() -> String { @@ -2782,6 +2824,33 @@ mod tests { assert_eq!(portable_data_dir(exe_dir.path()), None); } + #[test] + fn launch_overrides_teach_the_daemon_their_wrappers_as_the_file_changes() { + let _guard = lock_config_file(); + pin_config_dir(); + let mut cfg = Config::default(); + cfg.agent_launch + .insert("claude".to_string(), "cc --fast".to_string()); + cfg.save(); + let aliases = agent_detection_aliases(); + assert_eq!(aliases.get("cc").map(String::as_str), Some("claude")); + + // An edit made while the daemon runs is picked up on the next read, + // and an explicit `agent_commands` entry outranks an implied one. + cfg.agent_launch + .insert("codex".to_string(), "/opt/bin/cx".to_string()); + cfg.agent_commands + .insert("CC".to_string(), "gemini".to_string()); + cfg.save(); + let aliases = agent_detection_aliases(); + assert_eq!(aliases.get("cc").map(String::as_str), Some("gemini")); + assert_eq!(aliases.get("cx").map(String::as_str), Some("codex")); + + let back: Config = serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap(); + assert_eq!(back.agent_launch, cfg.agent_launch); + Config::default().save(); + } + fn host(name: &str, address: &str) -> crate::core::ssh_profile::SshProfile { let mut profile = crate::core::ssh_profile::SshProfile::new(name); profile.host = address.to_string(); diff --git a/crates/tty7-core/src/core/session.rs b/crates/tty7-core/src/core/session.rs index 1deeeca4..db36df48 100644 --- a/crates/tty7-core/src/core/session.rs +++ b/crates/tty7-core/src/core/session.rs @@ -384,6 +384,11 @@ pub struct WindowView { /// is another client's activity, not ours); opening one clears the mark. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub synced: bool, + /// Slugs of the coding agents seen running in this workspace, most recent + /// last. A remote workspace cannot be asked what is on its `PATH`, so + /// this is what its quick-launch list is made of. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub seen_agents: Vec, } impl Default for WindowView { @@ -397,6 +402,7 @@ impl Default for WindowView { label: None, subject: None, synced: false, + seen_agents: Vec::new(), } } } @@ -417,6 +423,17 @@ impl WindowView { self.host.is_some() } + /// Remember `slug` as running here, moving it to the end if it was + /// already known. Answers whether anything changed. + pub fn saw_agent(&mut self, slug: &str) -> bool { + if self.seen_agents.last().is_some_and(|s| s == slug) { + return false; + } + self.seen_agents.retain(|s| s != slug); + self.seen_agents.push(slug.to_string()); + true + } + pub fn host_id(&self) -> crate::host::HostId { match &self.host { Some(r) => r.host_id(), @@ -1172,4 +1189,29 @@ mod tests { views.views.retain(|w| w.id != b_id); assert_eq!(numbers(&views), vec![a_id, c_id]); } + + /// A remote workspace's quick-launch list is the agents seen running in + /// it, so the list has to be in `views.json` with the rest of the + /// workspace — an in-memory one would be empty after every restart. + #[test] + fn agents_seen_in_a_workspace_survive_a_restart() { + let _file = lock_session_file(); + pin_config_dir(); + let mut entry = remote_view("build-box"); + assert!(entry.saw_agent("claude")); + assert!(entry.saw_agent("codex")); + assert!(!entry.saw_agent("codex"), "already the most recent"); + assert!(entry.saw_agent("claude"), "moves to the end"); + let id = entry.id; + WindowViews { + active: Some(id), + views: vec![entry, view()], + } + .save(); + let loaded = WindowViews::load().expect("a saved views file should load back"); + assert_eq!(loaded.get(id).unwrap().seen_agents, vec!["codex", "claude"]); + // A workspace that has seen nothing writes nothing for it. + let text = std::fs::read_to_string(WindowViews::path().unwrap()).unwrap(); + assert_eq!(text.matches("seen_agents").count(), 1); + } } diff --git a/crates/tty7-core/src/daemon/pane.rs b/crates/tty7-core/src/daemon/pane.rs index 5aad6979..6492ca1f 100644 --- a/crates/tty7-core/src/daemon/pane.rs +++ b/crates/tty7-core/src/daemon/pane.rs @@ -839,6 +839,10 @@ enum PaneBackend { } struct ForegroundProbes { + /// The pty's foreground process group: one ioctl, cheap enough to ask on + /// every read, and a change in it is what says the probes below would now + /// answer differently. + group: Box Option + Send>, remote: Box Option + Send>, agent: Box Option)>> + Send>, cwd: Box Option + Send>, @@ -1680,6 +1684,7 @@ impl DaemonPane { ); let fg_master = master.clone(); + let group_master = master.clone(); let remote_master = master.clone(); let agent_master = master.clone(); let cwd_master = master.clone(); @@ -1691,6 +1696,7 @@ impl DaemonPane { writer.clone(), move || foreground_command_running(&fg_master, shell_pid), ForegroundProbes { + group: Box::new(move || pty_foreground_pgid(&group_master)), remote: Box::new(move || foreground_remote_context(&remote_master)), agent: Box::new(move || foreground_agent(&agent_master)), cwd: Box::new(move || foreground_cwd(&cwd_master, shell_pid)), @@ -1914,6 +1920,7 @@ impl DaemonPane { writer.clone(), || false, ForegroundProbes { + group: Box::new(|| None), remote: Box::new(|| None), agent: Box::new(|| None), cwd: Box::new(|| None), @@ -1963,6 +1970,7 @@ impl DaemonPane { death: Arc, ) -> JoinHandle<()> { let ForegroundProbes { + group: foreground_group, remote: foreground_remote, agent: foreground_agent_fn, cwd: foreground_cwd_fn, @@ -1996,6 +2004,7 @@ impl DaemonPane { let mut tr_read_t = std::time::Duration::ZERO; let mut tr_disp_t = std::time::Duration::ZERO; let mut next_remote_check = std::time::Instant::now(); + let mut last_group = None; loop { if trace && tr_last.elapsed() >= std::time::Duration::from_secs(1) { @@ -2073,8 +2082,21 @@ impl DaemonPane { // reads, most visibly) stayed on the far end until // some unrelated output arrived (#817). let back_at_prompt = signals.shell.iter().any(|s| s.at_prompt); - let poll_now = - back_at_prompt || std::time::Instant::now() >= next_remote_check; + // A new foreground group is a new program in front + // — probe for it on its first output. The interval + // alone missed an agent that drew its whole first + // screen within it of the prompt it was typed at + // and then sat waiting for a key: a launch that + // types the command the moment the shell is up + // did that every time, and the pane never learned + // it was running an agent until something else + // printed. + let group = foreground_group(); + let group_changed = group != last_group; + last_group = group; + let poll_now = back_at_prompt + || group_changed + || std::time::Instant::now() >= next_remote_check; if poll_now { next_remote_check = std::time::Instant::now() + REMOTE_CONTEXT_POLL_INTERVAL; @@ -3014,7 +3036,7 @@ fn apply_signals(st: &mut PaneState, signals: SniffSignals) { if shell_mark_capture_changed(&st.shell, &shell) { apply_agent( st, - agent_from_shell_mark(&shell, crate::core::config::agent_commands_cached()), + agent_from_shell_mark(&shell, &crate::core::config::agent_detection_aliases()), ); } st.shell = shell.clone(); @@ -3346,7 +3368,7 @@ fn foreground_agent( let argv = crate::daemon::remote::foreground_argv(pid)?; let agent = crate::core::cli_agent::CLIAgent::detect_from_argv_with( &argv, - crate::core::config::agent_commands_cached(), + &crate::core::config::agent_detection_aliases(), )?; Some((agent, argv)) }; @@ -5249,6 +5271,7 @@ mod tests { null_writer(), || false, ForegroundProbes { + group: Box::new(|| None), remote: Box::new(|| None), agent: Box::new(|| Some(None)), cwd: Box::new(|| None), @@ -5310,6 +5333,7 @@ mod tests { null_writer(), || false, ForegroundProbes { + group: Box::new(|| None), remote: Box::new(|| None), agent: Box::new(|| Some(None)), cwd: Box::new(|| None), @@ -6104,6 +6128,7 @@ mod tests { null_writer(), || false, ForegroundProbes { + group: Box::new(|| None), remote, agent: Box::new(|| None), cwd: Box::new(|| None), @@ -6151,6 +6176,7 @@ mod tests { null_writer(), || false, ForegroundProbes { + group: Box::new(|| None), remote: Box::new(|| None), agent: Box::new(|| None), cwd: Box::new(|| None), @@ -6255,6 +6281,7 @@ mod tests { null_writer(), || false, ForegroundProbes { + group: Box::new(|| None), remote: Box::new(|| None), agent: Box::new(|| None), cwd: Box::new(|| None), @@ -6352,6 +6379,7 @@ mod tests { null_writer(), || false, ForegroundProbes { + group: Box::new(|| None), remote: Box::new(|| None), agent: Box::new(|| None), cwd: Box::new(|| None), @@ -6389,6 +6417,7 @@ mod tests { null_writer(), || false, ForegroundProbes { + group: Box::new(|| None), remote: Box::new(|| None), agent: Box::new(|| None), cwd: Box::new(|| None), @@ -6459,6 +6488,7 @@ mod tests { writer, || false, ForegroundProbes { + group: Box::new(|| None), remote: Box::new(|| None), agent: Box::new(|| None), cwd: Box::new(|| None), @@ -6552,6 +6582,7 @@ mod tests { null_writer(), || false, ForegroundProbes { + group: Box::new(|| None), remote: Box::new(|| None), agent: Box::new(|| None), cwd: Box::new(|| None), @@ -6633,6 +6664,7 @@ mod tests { writer, || false, ForegroundProbes { + group: Box::new(|| None), remote: Box::new(|| None), agent: Box::new(|| None), cwd: Box::new(|| None), @@ -6670,6 +6702,7 @@ mod tests { null_writer(), || false, ForegroundProbes { + group: Box::new(|| None), remote: Box::new(|| None), agent: Box::new(|| None), cwd: Box::new(|| None), @@ -6721,6 +6754,7 @@ mod tests { null_writer(), || false, ForegroundProbes { + group: Box::new(|| None), remote: Box::new(|| None), agent: Box::new(|| None), cwd: Box::new(|| Some(PathBuf::from("/Users/alice/dev/tty7"))), @@ -6752,6 +6786,7 @@ mod tests { null_writer(), || false, ForegroundProbes { + group: Box::new(|| None), remote: Box::new(|| None), agent: Box::new(|| None), cwd: Box::new(|| None), @@ -6778,6 +6813,7 @@ mod tests { null_writer(), || false, ForegroundProbes { + group: Box::new(|| None), remote: Box::new(|| None), agent: Box::new(|| None), cwd: Box::new(|| None), @@ -7341,4 +7377,70 @@ mod tests { apply_signals(&mut st, SniffSignals::default()); assert_eq!(st.cwd, Some(PathBuf::from("/tmp/x"))); } + + /// A quick launch types the agent's command the moment the shell is up, so + /// the agent's whole first screen can land inside the poll interval of the + /// prompt it was typed at — and then it waits for a key, printing nothing + /// more. Probing only on the interval, the pane never found out an agent + /// was running. A change of foreground process group now probes at once. + #[test] + fn a_new_foreground_group_is_probed_inside_the_poll_interval() { + struct Chunks(std::collections::VecDeque>); + + impl Read for Chunks { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + match self.0.pop_front() { + Some(chunk) => { + buf[..chunk.len()].copy_from_slice(&chunk); + Ok(chunk.len()) + } + None => Ok(0), + } + } + } + + let probes_for = |groups: Vec| { + let state = Arc::new(Mutex::new(test_state(true))); + let probes = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let counted = probes.clone(); + let groups = Mutex::new(groups.into_iter()); + let handle = DaemonPane::spawn_reader( + state, + Arc::new(AtomicBool::new(false)), + Arc::new(OutputGate::new()), + // Plain output, no prompt marks: only the interval or the + // group can trigger a probe. + Box::new(Chunks( + [b"$ ".to_vec(), b"agent screen".to_vec()] + .into_iter() + .collect(), + )), + null_writer(), + || false, + ForegroundProbes { + group: Box::new(move || groups.lock().unwrap().next()), + remote: Box::new(|| None), + agent: Box::new(move || { + counted.fetch_add(1, Ordering::SeqCst); + None + }), + cwd: Box::new(|| None), + }, + Arc::new(DeathReporter::new(|| {})), + ); + handle.join().unwrap(); + probes.load(Ordering::SeqCst) + }; + + assert_eq!( + probes_for(vec![10, 10]), + 1, + "same program: the interval rules" + ); + assert_eq!( + probes_for(vec![10, 11]), + 2, + "a new program is probed at once" + ); + } } diff --git a/docs/agents/overview.mdx b/docs/agents/overview.mdx index a3b446f7..649e7648 100644 --- a/docs/agents/overview.mdx +++ b/docs/agents/overview.mdx @@ -66,6 +66,41 @@ The key is your command's name; the value is one of the slugs above (`claude`, `droid`, `pi`, `auggie`, `hermes`, `vibe`, `antigravity`, `grok`, `qwen`, `omp`, `kimi`, `qodercli`, `crush`, `codebuddy`). +## Quick launch + +Every agent tty7 finds on your `PATH` is one palette command away: **Agent: +Claude Code**, **Agent: Codex**, and so on, most-used first. The New Tab menu's +**Launch Agent…** row opens the palette on that list, and **New Agent Tab** +(⌘ ⇧ A on macOS, unbound elsewhere) starts whichever agent you used +last. + +A launch always opens a new tab in the current tab's directory and types the +agent's command into its shell, so the agent is detected, tracked and resumed +exactly as if you had typed it — and quitting it leaves you at the prompt. Each +`Agent: …` command is bindable too, as `LaunchAgent:`. + +By default an agent launches as its bare binary (`claude`, `codex`, +`cursor-agent`, …). Give it a command line of its own with `agent_launch`: + +```json +{ + "agent_launch": { + "claude": "claude --dangerously-skip-permissions", + "codex": "cx --profile work" + } +} +``` + +A wrapper named there (`cx` above) is recognised as that agent without an +`agent_commands` entry. To keep the flags a running agent was started with, +right-click its pane and choose **Set Current Launch Args as Default**; tty7 +writes them into `agent_launch`, minus the session they resumed and any prompt. + +In a remote workspace tty7 cannot read the far machine's `PATH`, so it offers +the agents it has already seen running there: start one by hand once and it +joins the list. A wrapper named in your local `agent_launch` is only recognised +over there if the remote `config.json` names it too. + ## What you get for free Just by running an agent in a pane: diff --git a/docs/reference/configuration.mdx b/docs/reference/configuration.mdx index 7df8203e..12e49c9b 100644 --- a/docs/reference/configuration.mdx +++ b/docs/reference/configuration.mdx @@ -178,6 +178,8 @@ angle brackets, and a tab: | Key | Type | Default | | |---|---|---|---| | `agent_commands` | object | `{}` | Map a wrapper command to an agent slug: `{"cc": "claude"}`. | +| `agent_launch` | object | `{}` | The command line quick launch types for an agent, by slug: `{"claude": "claude --dangerously-skip-permissions"}`. Unset agents launch as their bare binary. [More →](/agents/overview#quick-launch) | +| `agent_frecency` | object | `{}` | Written by tty7: how often and how recently each agent ran, to order quick launch. | | `restore_agent_sessions` | bool | `true` | Relaunch an agent conversation when a lost pane is restored. | | `install_cli_on_path` | bool | `true` | Put the bundled `tty7` command on PATH at launch. | diff --git a/docs/reference/keyboard-shortcuts.mdx b/docs/reference/keyboard-shortcuts.mdx index c9ea38c2..c0509050 100644 --- a/docs/reference/keyboard-shortcuts.mdx +++ b/docs/reference/keyboard-shortcuts.mdx @@ -19,6 +19,7 @@ it shows what *your* copy is bound to. This is the shipped default. | New Workspace | ⌘ ⇧ N | Ctrl ⇧ N | | Switch Workspace | ⌘ ⇧ O | Ctrl ⇧ O | | New Window | ⌘ N | — | +| New Agent Tab | ⌘ ⇧ A | — | ## Panes @@ -110,7 +111,7 @@ Keybindings**: | Tabs | `RenameTab` · `NewWorktreeTab` · `CloseOtherTabs` · `CloseTabsToTheRight` · `CopyWorkingDirectory` · `MarkTabUnread` · `HibernateTab` · `ToggleTabSidebar` | | Panes | `ResizePaneLeft/Right/Up/Down` · `SwapPaneNext` · `SwapPanePrev` | | Workspaces | `SelectWorkspace1`…`SelectWorkspace9` · `RenameWorkspace` · `StopWorkspace` · `DeleteWorkspace` | -| Agents | `ForkAgentSession` (+ `Right` / `Left` / `Down` / `Up`) · `CopyAgentSessionId` | +| Agents | `ForkAgentSession` (+ `Right` / `Left` / `Down` / `Up`) · `CopyAgentSessionId` · `LaunchAgent:` (one per agent, e.g. `LaunchAgent:claude`) | | Git | `ScmStageAll` · `ScmUnstageAll` · `ScmDiscardAll` · `ScmCommitAmend` · `ScmRefresh` · `ScmSync` · `ScmPush` · `ScmPull` · `ScmFetch` · `ScmCheckoutBranch` · `ScmCreateBranch` · `ScmToggleGraph` · `ToggleDiffViewMode` | | Panels | `ShowRightPanelInfo` · `ShowRightPanelChanges` · `ShowRightPanelFiles` | | Documents | `ToggleDocumentFill` · `DocumentWidthThird` · `DocumentWidthHalf` · `DocumentWidthTwoThirds` · `ToggleDocumentPreview` · `ToggleDocumentWrap` | diff --git a/src/core/actions.rs b/src/core/actions.rs index c8e81210..bf9fff64 100644 --- a/src/core/actions.rs +++ b/src/core/actions.rs @@ -37,6 +37,8 @@ actions!( ForkAgentSessionDown, ForkAgentSessionUp, CopyAgentSessionId, + NewAgentTab, + SaveAgentLaunchArgs, SplitRight, SplitDown, FocusNextPane, @@ -126,3 +128,11 @@ actions!( Quit ] ); + +/// Open a new tab running one particular coding agent. The keymap names one of +/// these per agent (`LaunchAgent:claude`, …); see `ui::agent_launch`. +#[derive(Clone, Debug, PartialEq, gpui::Action)] +#[action(namespace = tty7, no_json)] +pub struct LaunchAgent { + pub agent: tty7_core::core::cli_agent::CLIAgent, +} diff --git a/src/core/session.rs b/src/core/session.rs index 4cf3af92..ac897bbe 100644 --- a/src/core/session.rs +++ b/src/core/session.rs @@ -161,6 +161,24 @@ impl WorkspaceStore { store.views.save(); } + /// Note that `agent` ran in workspace `id` (see `WindowView::seen_agents`). + pub fn record_agent_seen( + cx: &mut gpui::App, + id: WorkspaceId, + agent: tty7_core::core::cli_agent::CLIAgent, + ) { + let Some(store) = Self::try_store(cx) else { + return; + }; + if store + .views + .get_mut(id) + .is_some_and(|view| view.saw_agent(agent.slug())) + { + store.views.save(); + } + } + pub fn host_of(cx: &gpui::App, id: WorkspaceId) -> HostId { host_for(Self::all(cx), id) } diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index 340596d7..9673e5d2 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -571,6 +571,10 @@ pub struct RemoteTerminal { pub exited: bool, size: TermSize, synced_size: bool, + /// This link reattached to a pane that was already running rather than + /// spawning a fresh one, so whatever it reports first — an agent in the + /// foreground included — predates this client. + reattached: bool, /// The `(cell_w, cell_h)` last sent to the daemon, in device pixels. Tracked /// alongside `size` so a display-scale change still reaches the child even /// when the grid dimensions are unchanged. @@ -1087,6 +1091,7 @@ impl RemoteTerminal { exited: false, size, synced_size: false, + reattached: awaiting_replay, synced_cell: (0, 0), resize_sent_at: None, link, @@ -1878,6 +1883,11 @@ impl RemoteTerminal { self.remote_context.lock().ok().and_then(|g| g.clone()) } + /// See the field: an attach to a running pane, not a fresh spawn. + pub fn reattached(&self) -> bool { + self.reattached + } + pub fn at_prompt(&self) -> bool { self.shell_state .lock() diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 5c810f08..fa3e03c7 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -27,8 +27,8 @@ use super::typeahead::{RawInput, Typeahead}; use crate::core::actions::{ CloseActiveTab, CopyLinkPathUnderPointer, DecreaseFontSize, ForkAgentSessionDown, ForkAgentSessionLeft, ForkAgentSessionRight, ForkAgentSessionUp, IncreaseFontSize, NewTab, - OpenLinkUnderPointer, RevealLinkUnderPointer, SendBackTab, SendTab, SplitDown, SplitRight, - ToggleMaximizePane, + OpenLinkUnderPointer, RevealLinkUnderPointer, SaveAgentLaunchArgs, SendBackTab, SendTab, + SplitDown, SplitRight, ToggleMaximizePane, }; use crate::core::config::{BellMode, Config, LinkFileOpen, MouseZoomModifier, NotifyMode}; use crate::core::shell_quote::quote_for_shell; @@ -140,6 +140,12 @@ pub struct AgentSessionChanged; impl gpui::EventEmitter for TerminalView {} +/// A coding agent started running in this pane: its foreground went from no +/// agent to this one. What quick launch counts as the agent being used. +pub struct AgentDetected(pub crate::core::cli_agent::CLIAgent); + +impl gpui::EventEmitter for TerminalView {} + /// A file link the user clicked, on its way to whoever can show it. The /// terminal resolves the path — it is the only thing that knows the pane's /// directory and host — and the app opens it, because the editor and the file @@ -436,6 +442,15 @@ pub struct TerminalView { running_since: Option, running_title: String, running_agent: Option, + /// The foreground agent as of the last poll, so a new one is reported + /// once ([`AgentDetected`]) rather than on every frame it keeps running. + seen_agent: Option, + /// Whether an agent appearing here is news. A pane this view spawned is + /// armed from the start; one it reattached to — an app restart, a + /// workspace switched back to — may already be running an agent that was + /// counted when it started, so it arms only once its shell is seen back + /// at the prompt with no agent in front. + agent_detection_armed: bool, last_agent_status: Option, last_agent_session: (Option, Option>), agent_turn_started: Option, @@ -1444,6 +1459,7 @@ impl TerminalView { .map(crate::core::config::gpui_font_features); let report_mouse = config.mouse_reporting; let prompt_editor = config.prompt_editor; + let agent_detection_armed = !terminal.reattached(); let mut font = gpui::font(font_family); font.fallbacks = Some(gpui::FontFallbacks::from_fonts(fallbacks.clone())); if let Some(features) = &font_features { @@ -1658,6 +1674,8 @@ impl TerminalView { running_since: None, running_title: String::new(), running_agent: None, + seen_agent: None, + agent_detection_armed, last_agent_status: None, last_agent_session: (None, None), agent_turn_started: None, @@ -3785,6 +3803,8 @@ impl TerminalView { _ => {} } + self.poll_agent_detection(at_prompt, cx); + let turn_finished = self.poll_agent_status(notify_allowed, window, cx); let session = self.terminal.agent_session(); @@ -4045,6 +4065,24 @@ impl TerminalView { ); } + /// Report an agent that has just started in this pane ([`AgentDetected`]), + /// once per start, and never one that was already running when this view + /// reattached to the pane. + fn poll_agent_detection(&mut self, at_prompt: bool, cx: &mut Context) { + let agent = self.terminal.foreground_agent(); + if agent != self.seen_agent { + self.seen_agent = agent; + if let Some(agent) = agent + && self.agent_detection_armed + { + cx.emit(AgentDetected(agent)); + } + } + if agent.is_none() && at_prompt { + self.agent_detection_armed = true; + } + } + fn poll_agent_status( &mut self, notify_allowed: bool, @@ -7339,6 +7377,12 @@ impl Render for TerminalView { let fork_ready = can_fork && view.remote_context().is_none() && view.agent_session().is_some_and(|s| s.session_id.is_some()); + // `None` over a pane with no agent; otherwise whether it knows + // the command line it was started with. + let launch_argv_known = view.agent().map(|_| { + view.agent_session() + .is_some_and(|s| s.launch_argv.is_some()) + }); let menu = match (can_fork, fork_ready) { (true, true) => { @@ -7372,6 +7416,21 @@ impl Render for TerminalView { (false, _) => menu, }; + // Only over a running agent, and only live when the pane knows + // the command line it was started with — there is nothing to + // save otherwise. Beside Fork when both are there. + let menu = match launch_argv_known { + Some(known) => { + let menu = if can_fork { menu } else { menu.separator() }; + menu.menu_with_disabled( + t(L10nKey::AppMenuSaveAgentLaunchArgs), + Box::new(SaveAgentLaunchArgs), + !known, + ) + } + None => menu, + }; + menu.separator() .menu(t(L10nKey::AppMenuSplitRight), Box::new(SplitRight)) .menu(t(L10nKey::AppMenuSplitDown), Box::new(SplitDown)) @@ -16273,6 +16332,96 @@ mod gpui_tests { }) .unwrap(); } + + /// Quick launch counts an agent each time one starts. Reattaching to a pane + /// whose agent was already running — every agent tab after an app restart + /// — is not a start, and counting it bumped every agent once per launch of + /// the app. Once that pane's agent exits, the next one is a start again. + #[gpui::test] + fn only_an_agent_that_starts_under_this_view_counts_as_detected(cx: &mut TestAppContext) { + use crate::core::cli_agent::CLIAgent; + + crate::core::config::pin_test_config_dir(); + let (window, _root_daemon) = harness(cx); + let detected = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let pane = |reattached: bool, cx: &mut TestAppContext| { + let (pane, daemon) = window + .update(cx, |_, window, cx| { + if reattached { + super::quiet_reattached_test_pane(2, window, cx) + } else { + super::quiet_test_pane(3, window, cx) + } + }) + .unwrap(); + let seen = detected.clone(); + cx.update(|cx| { + cx.subscribe(&pane, move |_, ev: &AgentDetected, _| { + seen.borrow_mut().push(ev.0) + }) + .detach() + }); + (pane, daemon) + }; + let report = |agent: Option, + at_prompt: bool, + pane: &Entity, + daemon: &mut Stream, + cx: &mut TestAppContext| { + DaemonMsg::Agent(agent).encode(daemon).unwrap(); + for _ in 0..200 { + if pane.read_with(cx, |p, _| p.terminal.foreground_agent()) == agent { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + pane.update(cx, |p, cx| p.poll_agent_detection(at_prompt, cx)); + cx.run_until_parked(); + }; + + let (restored, mut restored_daemon) = pane(true, cx); + report( + Some(CLIAgent::Claude), + false, + &restored, + &mut restored_daemon, + cx, + ); + report( + Some(CLIAgent::Claude), + false, + &restored, + &mut restored_daemon, + cx, + ); + assert_eq!( + *detected.borrow(), + vec![], + "already running when reattached" + ); + report(None, true, &restored, &mut restored_daemon, cx); + report( + Some(CLIAgent::Codex), + false, + &restored, + &mut restored_daemon, + cx, + ); + assert_eq!( + *detected.borrow(), + vec![CLIAgent::Codex], + "started after the reattach" + ); + + let (fresh, mut fresh_daemon) = pane(false, cx); + report(Some(CLIAgent::Claude), false, &fresh, &mut fresh_daemon, cx); + report(Some(CLIAgent::Claude), false, &fresh, &mut fresh_daemon, cx); + assert_eq!( + *detected.borrow(), + vec![CLIAgent::Codex, CLIAgent::Claude], + "a spawned pane counts its first agent, once" + ); + } } /// The window between the shell reporting a prompt and its line editor diff --git a/src/ui/agent_launch.rs b/src/ui/agent_launch.rs new file mode 100644 index 00000000..c7fd6210 --- /dev/null +++ b/src/ui/agent_launch.rs @@ -0,0 +1,449 @@ +//! Quick launch for the coding agents this machine can run (#955). +//! +//! Launching an agent never types into a pane that already exists. It opens a +//! new shell — a tab in the active tab's directory, or a split when the entry +//! point was asked for one — and types the agent's command line into *that* +//! shell once it is there. The agent then runs the way a hand-typed one does: +//! the daemon detects it from the pane's foreground, status and resume work as +//! usual, and quitting it leaves the shell behind. +//! +//! Which agents are offered: on this computer, those whose launch program is +//! on `PATH`. A remote workspace's `PATH` is not something the `Host` trait can +//! answer — it has no way to run a command or read the far shell's +//! environment — so there the list is the agents seen running in that +//! workspace before (`WindowView::seen_agents`). Either way the order is +//! frecency, recorded the way saved SSH hosts' is. + +use std::collections::HashMap; +use std::sync::LazyLock; + +use gpui::{App, Axis, Context, Window}; +use gpui_component::WindowExt as _; + +use crate::core::cli_agent::{CLIAgent, launch_program, program_on_path}; +use crate::core::config::{Config, ProfileUsage, unix_now}; +use crate::core::session::WorkspaceStore; +use crate::ui::app::{SpawnAs, SpawnWhere, Tty7App, join_shell_args}; +use crate::ui::i18n::{L10nKey, t_fmt}; +use crate::ui::pane::PaneSlot; + +/// What the New Tab menu's "Launch Agent…" row types into the palette: every +/// quick-launch command is titled `Agent: {name}` (the same word in every +/// language we ship), so this lands on the list with the cursor ready to +/// narrow it — the same seam `PALETTE_SSH_QUERY` is for hosts. +pub(crate) const PALETTE_AGENT_QUERY: &str = "agent"; + +/// How the keymap spells "launch this agent": `LaunchAgent:claude`. +const LAUNCH_ACTION_PREFIX: &str = "LaunchAgent:"; + +/// One keymap action name per agent, in [`CLIAgent::ALL`] order. +pub(crate) fn launch_action_names() -> &'static [(CLIAgent, String)] { + static NAMES: LazyLock> = LazyLock::new(|| { + CLIAgent::ALL + .into_iter() + .map(|agent| (agent, format!("{LAUNCH_ACTION_PREFIX}{}", agent.slug()))) + .collect() + }); + &NAMES +} + +pub(crate) fn launch_action_name(agent: CLIAgent) -> &'static str { + launch_action_names() + .iter() + .find(|(a, _)| *a == agent) + .map(|(_, name)| name.as_str()) + .expect("every agent has a launch action") +} + +pub(crate) fn agent_for_launch_action(action: &str) -> Option { + action + .strip_prefix(LAUNCH_ACTION_PREFIX) + .and_then(CLIAgent::from_slug) +} + +/// The agents whose launch program is on `path`, in [`CLIAgent::ALL`] order. +/// An agent with an `agent_launch` override is looked up by the override's +/// program, since that is what the launch will run. +pub(crate) fn installed_on( + path: &std::ffi::OsStr, + overrides: &HashMap, +) -> Vec { + CLIAgent::ALL + .into_iter() + .filter(|agent| { + launch_program(&agent.launch_command(overrides)) + .is_some_and(|program| program_on_path(&program, path)) + }) + .collect() +} + +/// `agents` most-used-first by frecency score; agents with the same score +/// keep the order they came in. +pub(crate) fn by_frecency( + agents: impl IntoIterator, + usage: &HashMap, + now: u64, +) -> Vec { + let score = |agent: &CLIAgent| usage.get(agent.slug()).map(|u| u.score(now)).unwrap_or(0.0); + let mut agents: Vec = agents.into_iter().collect(); + agents.sort_by(|a, b| { + score(b) + .partial_cmp(&score(a)) + .unwrap_or(std::cmp::Ordering::Equal) + }); + agents +} + +/// The agent "New Agent Tab" opens: of `offered`, the one launched or seen +/// most recently; the first offered when none has been used yet. +pub(crate) fn most_recent( + offered: &[CLIAgent], + usage: &HashMap, +) -> Option { + let last_used = |agent: &CLIAgent| usage.get(agent.slug()).map_or(0, |u| u.last_used); + offered + .iter() + .copied() + .filter(|agent| last_used(agent) > 0) + // `max_by_key` keeps the last of equals; reversed, that is the first. + .rev() + .max_by_key(last_used) + .or_else(|| offered.first().copied()) +} + +/// The `agent_launch` line "Set Current Launch Args as Default" writes for a +/// pane that `agent` is running in with `argv`. +pub(crate) fn launch_line_to_save(agent: CLIAgent, argv: &[String]) -> Option { + agent + .launch_argv_for_default(argv) + .map(|argv| join_shell_args(&argv)) +} + +/// Make `line` the command `agent` is launched with from now on. +pub(crate) fn remember_launch_line(cfg: &mut Config, agent: CLIAgent, line: String) { + // One entry per agent, whatever case an existing key was written in. + cfg.agent_launch + .retain(|slug, _| CLIAgent::from_slug(slug) != Some(agent)); + cfg.agent_launch.insert(agent.slug().to_string(), line); +} + +/// Type `command` into the shell `slot` holds — now if it is up, or the +/// moment it lands if it is still connecting. +fn run_when_ready(slot: &PaneSlot, command: String, cx: &mut App) { + match slot { + PaneSlot::Ready(view) => view.read(cx).run_command_line(&command), + PaneSlot::Connecting(pending) => { + pending.update(cx, |pending, _| pending.spawn.run_on_land = Some(command)); + } + } +} + +impl Tty7App { + /// The agents this window's quick launch offers, most likely first. + pub(crate) fn offered_agents(&self, cx: &App) -> Vec { + let cfg = cx.global::(); + let candidates = match WorkspaceStore::all(cx) + .get(self.workspace) + .filter(|view| view.is_remote()) + { + Some(view) => view + .seen_agents + .iter() + .filter_map(|slug| CLIAgent::from_slug(slug)) + .collect(), + None => installed_on( + &std::env::var_os("PATH").unwrap_or_default(), + &cfg.agent_launch, + ), + }; + by_frecency(candidates, &cfg.agent_frecency, unix_now()) + } + + /// Open a new shell for `agent` and start it there. + pub(crate) fn launch_agent( + &mut self, + agent: CLIAgent, + at: SpawnWhere, + window: &mut Window, + cx: &mut Context, + ) { + let command = agent.launch_command(&cx.global::().agent_launch); + let slot = match at { + SpawnWhere::NewTab => { + let cwd = self.tabs.get(self.active).and_then(|t| { + t.pane + .focused_or_first(window, cx) + .and_then(|leaf| leaf.read(cx).spawnable_cwd()) + }); + self.new_tab_slot(cwd, None, window, cx) + } + SpawnWhere::Split => { + self.split_slot(Axis::Horizontal, Some(SpawnAs::Shell(None)), window, cx) + } + }; + // Nothing opened (the spawn failed, or this workspace cannot host a + // shell right now), and the reason is already on screen. The command + // goes nowhere rather than into whatever pane was focused before. + let Some(slot) = slot else { + log::warn!("no pane opened for {}; not launching it", agent.slug()); + return; + }; + run_when_ready(&slot, command, cx); + // Recency only: the run is counted when the pane reports the agent + // actually running, so a launch and its detection are one use, not two. + self.update_config(cx, |cfg| { + cfg.agent_frecency + .entry(agent.slug().to_string()) + .or_default() + .last_used = unix_now(); + }); + } + + /// "New Agent Tab": launch the agent used most recently. + pub(crate) fn new_agent_tab(&mut self, window: &mut Window, cx: &mut Context) { + let offered = self.offered_agents(cx); + match most_recent(&offered, &cx.global::().agent_frecency) { + Some(agent) => self.launch_agent(agent, SpawnWhere::NewTab, window, cx), + None => { + let remote = WorkspaceStore::remote_ref(cx, self.workspace).is_some(); + window.push_notification( + crate::ui::i18n::t(if remote { + L10nKey::AppNoAgentSeenHere + } else { + L10nKey::AppNoAgentOnPath + }), + cx, + ); + } + } + } + + /// A pane in this window started running `agent`. + pub(crate) fn note_agent_detected(&mut self, agent: CLIAgent, cx: &mut Context) { + self.update_config(cx, |cfg| { + let entry = cfg + .agent_frecency + .entry(agent.slug().to_string()) + .or_default(); + entry.count = entry.count.saturating_add(1); + entry.last_used = unix_now(); + }); + if WorkspaceStore::remote_ref(cx, self.workspace).is_some() { + WorkspaceStore::record_agent_seen(cx, self.workspace, agent); + } + } + + /// "Set Current Launch Args as Default": make the focused pane's agent + /// launch the way this one was started. + pub(crate) fn save_agent_launch_args(&mut self, window: &mut Window, cx: &mut Context) { + let Some(view) = self + .tabs + .get(self.active) + .and_then(|t| t.pane.focused_or_first(window, cx)) + else { + return; + }; + let view = view.read(cx); + let Some(agent) = view.agent() else { + window.push_notification(crate::ui::i18n::t(L10nKey::AppPaneNoCodingAgent), cx); + return; + }; + let name = agent.display_name(); + let Some(line) = view + .agent_session() + .and_then(|s| s.launch_argv) + .and_then(|argv| launch_line_to_save(agent, &argv)) + else { + window.push_notification( + t_fmt(L10nKey::AppAgentLaunchArgsUnknown, &[("name", name)]), + cx, + ); + return; + }; + let saved = line.clone(); + self.update_config(cx, |cfg| remember_launch_line(cfg, agent, saved)); + window.push_notification( + t_fmt( + L10nKey::AppAgentLaunchSaved, + &[("name", name), ("command", &line)], + ), + cx, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn usage(pairs: &[(&str, u32, u64)]) -> HashMap { + pairs + .iter() + .map(|(slug, count, last_used)| { + ( + slug.to_string(), + ProfileUsage { + count: *count, + last_used: *last_used, + }, + ) + }) + .collect() + } + + fn argv(parts: &[&str]) -> Vec { + parts.iter().map(|s| s.to_string()).collect() + } + + const NOW: u64 = 1_800_000_000; + const DAY: u64 = 86_400; + + #[test] + fn agents_are_offered_most_used_first() { + let used = usage(&[ + ("codex", 3, NOW - DAY), + ("claude", 12, NOW - 2 * DAY), + // Used a lot, but so long ago that it has decayed below Codex. + ("gemini", 20, NOW - 200 * DAY), + ]); + let offered = by_frecency( + [ + CLIAgent::Aider, + CLIAgent::Gemini, + CLIAgent::Codex, + CLIAgent::Claude, + CLIAgent::Amp, + ], + &used, + NOW, + ); + assert_eq!( + offered, + vec![ + CLIAgent::Claude, + CLIAgent::Codex, + CLIAgent::Gemini, + // Never used: they keep the order they were found in. + CLIAgent::Aider, + CLIAgent::Amp, + ] + ); + } + + #[test] + fn new_agent_tab_opens_the_most_recently_used_agent() { + let offered = [CLIAgent::Claude, CLIAgent::Codex, CLIAgent::Gemini]; + // Claude is used more, but Codex was the last one run or launched. + let used = usage(&[ + ("claude", 40, NOW - DAY), + ("codex", 1, NOW - 60), + ("aider", 5, NOW), + ]); + assert_eq!(most_recent(&offered, &used), Some(CLIAgent::Codex)); + // Aider was more recent still, but it is not offered here. + // Nothing used yet: the first one offered. + assert_eq!( + most_recent(&offered, &HashMap::new()), + Some(CLIAgent::Claude) + ); + assert_eq!(most_recent(&[], &used), None); + // A launch that only stamped recency counts as the most recent. + let launched = usage(&[("claude", 40, NOW - DAY), ("gemini", 0, NOW)]); + assert_eq!(most_recent(&offered, &launched), Some(CLIAgent::Gemini)); + } + + #[test] + fn saved_launch_args_survive_a_round_trip_through_the_command_line() { + let run = argv(&[ + "/opt/homebrew/bin/claude", + "--append-system-prompt", + "be terse, don't guess", + "--add-dir", + "/Users/me/My Projects", + "--model", + "opus", + "--resume", + "abc-123", + ]); + let line = launch_line_to_save(CLIAgent::Claude, &run).unwrap(); + assert_eq!( + crate::ui::app::split_shell_args(&line).unwrap(), + argv(&[ + "claude", + "--append-system-prompt", + "be terse, don't guess", + "--add-dir", + "/Users/me/My Projects", + "--model", + "opus", + ]) + ); + // And the saved line is recognised as the agent it launches. + assert_eq!( + CLIAgent::detect_from_command_with(&line, &HashMap::new()), + Some(CLIAgent::Claude) + ); + } + + #[test] + fn set_current_launch_args_as_default_writes_the_agent_launch_entry() { + let mut cfg = Config::default(); + cfg.agent_launch + .insert("Claude".to_string(), "claude --old".to_string()); + cfg.agent_launch + .insert("codex".to_string(), "codex --model o3".to_string()); + let line = launch_line_to_save( + CLIAgent::Claude, + &argv(&["claude", "--dangerously-skip-permissions", "--continue"]), + ) + .unwrap(); + remember_launch_line(&mut cfg, CLIAgent::Claude, line); + + let json: serde_json::Value = serde_json::to_value(&cfg.0).unwrap(); + assert_eq!( + json["agent_launch"], + serde_json::json!({ + "claude": "claude --dangerously-skip-permissions", + "codex": "codex --model o3", + }) + ); + assert_eq!( + CLIAgent::Claude.launch_command(&cfg.agent_launch), + "claude --dangerously-skip-permissions" + ); + } + + #[test] + fn every_agent_has_a_bindable_launch_action() { + for agent in CLIAgent::ALL { + let name = launch_action_name(agent); + assert_eq!(agent_for_launch_action(name), Some(agent), "{name}"); + } + assert_eq!(agent_for_launch_action("LaunchAgent:nobody"), None); + assert_eq!(agent_for_launch_action("NewAgentTab"), None); + } + + #[test] + fn only_agents_whose_launch_program_is_on_path_are_offered() { + let dir = tempfile::TempDir::new().unwrap(); + for name in ["codex", "cc"] { + let path = dir.path().join(name); + std::fs::write(&path, "#!/bin/sh\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + } + let path = dir.path().as_os_str(); + assert_eq!(installed_on(path, &HashMap::new()), vec![CLIAgent::Codex]); + // With an override, the override's program is what has to be there. + let overrides: HashMap = [ + ("claude".to_string(), "cc --fast".to_string()), + ("codex".to_string(), "codex-nightly".to_string()), + ] + .into_iter() + .collect(); + assert_eq!(installed_on(path, &overrides), vec![CLIAgent::Claude]); + } +} diff --git a/src/ui/app.rs b/src/ui/app.rs index f09045f1..b09a0f9a 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -3917,6 +3917,7 @@ impl Tty7App { spawn.agent_launch_argv.as_deref(), cx, ) + .or_else(|| spawn.run_on_land.clone()) }) .flatten(); let view = build_terminal_view(parts, font_size, window, cx); @@ -4031,8 +4032,21 @@ impl Tty7App { window: &mut Window, cx: &mut Context, ) { + self.new_tab_slot(cwd, shell, window, cx); + } + + /// [`Self::new_tab_with_cwd`], answering with the pane it opened — `None` + /// when nothing opened, so a caller about to type into the new pane has + /// nothing to type into rather than whatever was focused before. + pub(crate) fn new_tab_slot( + &mut self, + cwd: Option, + shell: Option, + window: &mut Window, + cx: &mut Context, + ) -> Option { if !self.guard_local_spawn(window, cx) { - return; + return None; } let group = self.spawn_group(cwd.as_deref(), cx); let tab = match new_terminal( @@ -4054,7 +4068,7 @@ impl Tty7App { self.startup_error = Some(gpui::SharedString::from(text.clone())); window.push_notification(text, cx); cx.notify(); - return; + return None; } }; // Something opened, so whatever the last failure was is stale. @@ -4062,7 +4076,7 @@ impl Tty7App { self.remember_active_pane(window, cx); self.maximized = None; let insert_at = self.new_tab_insert_at(cx); - let new_tab = Tab::new(Pane::leaf(tab)); + let new_tab = Tab::new(Pane::leaf(tab.clone())); if let Some(group) = group { *new_tab.sidebar_group.borrow_mut() = group; } @@ -4071,6 +4085,7 @@ impl Tty7App { self.focus_active(window, cx); self.save_session(cx); cx.notify(); + Some(tab) } pub(crate) fn open_native_ssh_tab( @@ -4154,15 +4169,24 @@ impl Tty7App { window: &mut Window, cx: &mut Context, ) { - let Some(target) = self + self.split_slot(axis, spawn, window, cx); + } + + /// [`Self::split_into`], answering with the pane it placed, or `None` + /// when nothing was placed. + pub(crate) fn split_slot( + &mut self, + axis: Axis, + spawn: Option, + window: &mut Window, + cx: &mut Context, + ) -> Option { + let target = self .tabs .get(self.active) - .and_then(|t| t.pane.focused_or_first(window, cx)) - else { - return; - }; + .and_then(|t| t.pane.focused_or_first(window, cx))?; if !self.guard_local_spawn(window, cx) { - return; + return None; } let cwd = target.read(cx).spawnable_cwd(); let spawn = match spawn { @@ -4190,7 +4214,7 @@ impl Tty7App { ), cx, ); - return; + return None; } } } @@ -4212,22 +4236,23 @@ impl Tty7App { t_fmt(L10nKey::AppSplitPaneFailed, &[("error", &e.to_string())]), cx, ); - return; + return None; } } } }; - if let Some(tab) = self.tabs.get_mut(self.active) { - if tab - .pane - .split_leaf(target.entity_id(), axis, false, new.clone()) - { - self.maximized = None; - self.focus_leaf(&new, window, cx); - self.save_session(cx); - cx.notify(); - } + let tab = self.tabs.get_mut(self.active)?; + if !tab + .pane + .split_leaf(target.entity_id(), axis, false, new.clone()) + { + return None; } + self.maximized = None; + self.focus_leaf(&new, window, cx); + self.save_session(cx); + cx.notify(); + Some(new) } fn close_pane(&mut self, window: &mut Window, cx: &mut Context) { @@ -5768,6 +5793,20 @@ impl Tty7App { ); } + for agent in self.offered_agents(cx) { + commands.push( + Command::new( + t_fmt( + L10nKey::AppCmdAgentLaunchTitle, + &[("name", agent.display_name())], + ), + CommandKind::LaunchAgent(agent), + ) + .with_subtitle(agent.launch_command(&cx.global::().agent_launch)) + .in_group(CommandGroup::Agents), + ); + } + for (i, tab) in self.tabs.iter().enumerate() { if i == self.active { continue; @@ -5951,6 +5990,13 @@ impl Tty7App { MarkTabUnread => self.mark_tab_unread(self.active, cx), HibernateTab => self.hibernate_tab(self.active, window, cx), ForkAgentSession => self.fork_active_pane_session(ForkPlacement::NewTab, window, cx), + NewAgentTab => self.new_agent_tab(window, cx), + // Picked from the palette with ⌥ held, the way a New Tab menu row + // is: a split beside the focused pane instead of a tab. + LaunchAgent(agent) => { + let at = SpawnWhere::from_modifiers(window.modifiers()); + self.launch_agent(agent, at, window, cx) + } CopyAgentSessionId => self.copy_agent_session_id(self.active, window, cx), RenameWorkspace => self.start_workspace_rename(window, cx), OpenSettings => self.toggle_settings(window, cx), @@ -9288,6 +9334,15 @@ impl Render for Tty7App { .on_action(cx.listener(|this, _: &CopyAgentSessionId, window, cx| { this.copy_agent_session_id(this.active, window, cx) })) + .on_action( + cx.listener(|this, _: &NewAgentTab, window, cx| this.new_agent_tab(window, cx)), + ) + .on_action(cx.listener(|this, action: &LaunchAgent, window, cx| { + this.launch_agent(action.agent, SpawnWhere::NewTab, window, cx) + })) + .on_action(cx.listener(|this, _: &SaveAgentLaunchArgs, window, cx| { + this.save_agent_launch_args(window, cx) + })) .on_action(cx.listener(|this, _: &ShowKeyboardShortcuts, window, cx| { this.open_settings_section(SettingsSection::Keybindings, window, cx) })) @@ -9824,6 +9879,7 @@ pub(crate) fn new_terminal( agent: None, agent_session_id: None, agent_launch_argv: None, + run_on_land: None, owner, font_size, }; @@ -9903,6 +9959,14 @@ fn build_terminal_view( }, ) .detach(); + cx.subscribe_in( + &view, + window, + |app, _view, ev: &crate::terminal::view::AgentDetected, _window, cx| { + app.note_agent_detected(ev.0, cx); + }, + ) + .detach(); cx.subscribe_in( &view, window, @@ -11033,6 +11097,7 @@ mod tests { agent: Some(CLIAgent::Claude), agent_session_id: Some("sid-abc".to_string()), agent_launch_argv: Some(vec!["claude".to_string()]), + run_on_land: None, owner: None, font_size: 14.0, }, @@ -12650,6 +12715,7 @@ mod tab_focus_memory_tests { agent: None, agent_session_id: None, agent_launch_argv: None, + run_on_land: None, owner: None, font_size: 14., }, diff --git a/src/ui/diff_overlay.rs b/src/ui/diff_overlay.rs index 61e746e7..8f394962 100644 --- a/src/ui/diff_overlay.rs +++ b/src/ui/diff_overlay.rs @@ -3363,6 +3363,7 @@ mod selection_gpui_tests { agent: None, agent_session_id: None, agent_launch_argv: None, + run_on_land: None, owner: None, font_size: 14.0, }, diff --git a/src/ui/document_column.rs b/src/ui/document_column.rs index 447f7389..23738f23 100644 --- a/src/ui/document_column.rs +++ b/src/ui/document_column.rs @@ -569,6 +569,7 @@ mod gpui_tests { agent: None, agent_session_id: None, agent_launch_argv: None, + run_on_land: None, owner: None, font_size: 14.0, }, diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 2f275720..e5efd553 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -1525,6 +1525,8 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::CmdCopyWorkingDirectory => "Copy Working Directory", L10nKey::CmdCopySessionId => "Copy Session ID", L10nKey::CmdCopySessionIdSubtitle => "the coding agent's own session id", + L10nKey::CmdNewAgentTab => "New Agent Tab", + L10nKey::CmdNewAgentTabSubtitle => "open the coding agent you used last in a new tab", L10nKey::CmdForkSession => "Fork Session", L10nKey::CmdForkSessionSubtitle => "branch this agent session into a new tab", L10nKey::CmdMarkTabAsUnread => "Mark Tab as Unread", @@ -1690,6 +1692,15 @@ pub fn translate_en(key: L10nKey) -> &'static str { "No uncommitted changes in {cwd} (or not a git repository)." } L10nKey::AppCmdSshProfileTitle => "SSH: {title}", + L10nKey::AppCmdAgentLaunchTitle => "Agent: {name}", + L10nKey::AppNoAgentOnPath => "No coding agent was found on this machine's PATH", + L10nKey::AppNoAgentSeenHere => { + "No coding agent has run in this workspace yet — start one by hand once and it will be offered here" + } + L10nKey::AppAgentLaunchSaved => "{name} will now launch as: {command}", + L10nKey::AppAgentLaunchArgsUnknown => { + "{name} did not report the arguments it was started with" + } L10nKey::AppCmdShellTitle => "Shell: {title}", L10nKey::AppCmdSwitchToTab => "Switch to Tab: {label}", L10nKey::AppPlaceholderDescription => "description", @@ -1849,6 +1860,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::AppMenuCopyWorkingDirectory => "Copy Working Directory", L10nKey::AppMenuCopySessionId => "Copy Session ID", L10nKey::AppMenuForkSession => "Fork Session", + L10nKey::AppMenuSaveAgentLaunchArgs => "Set Current Launch Args as Default", L10nKey::AppMenuClosePaneTab => "Close", L10nKey::AppMenuCloseOtherTabs => "Close Other Tabs", L10nKey::AppMenuCloseTabsRight => "Close Tabs to the Right", @@ -1907,6 +1919,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::TabMenuLocalShells => "Local", L10nKey::TabMenuAddHost => "Add SSH Host…", L10nKey::TabMenuAllHosts => "All SSH Hosts…", + L10nKey::TabMenuLaunchAgent => "Launch Agent…", L10nKey::TabMenuOtherShells => "Other Shells…", L10nKey::TabMenuSplitHint => "Hold {key} to split", L10nKey::TabUnnamedShell => "Shell {n}", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 33304b5f..74e7a74a 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -1589,6 +1589,8 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::CmdCopyWorkingDirectory => "作業ディレクトリをコピー", L10nKey::CmdCopySessionId => "セッション ID をコピー", L10nKey::CmdCopySessionIdSubtitle => "コーディングエージェント自身のセッション ID", + L10nKey::CmdNewAgentTab => "新しいエージェントタブ", + L10nKey::CmdNewAgentTabSubtitle => "最後に使ったコーディングエージェントを新しいタブで開く", L10nKey::CmdForkSession => "セッションをフォーク", L10nKey::CmdForkSessionSubtitle => "このエージェントのセッションを新しいタブにフォーク", L10nKey::CmdMarkTabAsUnread => "タブを未読としてマーク", @@ -1760,6 +1762,13 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { "{cwd} に未コミットの変更はありません(または git リポジトリではありません)" } L10nKey::AppCmdSshProfileTitle => "SSH: {title}", + L10nKey::AppCmdAgentLaunchTitle => "Agent: {name}", + L10nKey::AppNoAgentOnPath => "このマシンの PATH にコーディングエージェントが見つかりません", + L10nKey::AppNoAgentSeenHere => { + "このワークスペースではまだコーディングエージェントが実行されていません — 一度手動で起動すると、ここに表示されます" + } + L10nKey::AppAgentLaunchSaved => "{name} は今後このコマンドで起動します: {command}", + L10nKey::AppAgentLaunchArgsUnknown => "{name} は起動時の引数を報告していません", L10nKey::AppCmdShellTitle => "Shell: {title}", L10nKey::AppCmdSwitchToTab => "タブに切り替え: {label}", L10nKey::AppPlaceholderDescription => "説明", @@ -1923,6 +1932,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::AppMenuCopyWorkingDirectory => "作業ディレクトリをコピー", L10nKey::AppMenuCopySessionId => "セッション ID をコピー", L10nKey::AppMenuForkSession => "セッションをフォーク", + L10nKey::AppMenuSaveAgentLaunchArgs => "現在の起動引数をデフォルトに設定", L10nKey::AppMenuClosePaneTab => "閉じる", L10nKey::AppMenuCloseOtherTabs => "他のタブを閉じる", L10nKey::AppMenuCloseTabsRight => "右側のタブを閉じる", @@ -1981,6 +1991,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::TabMenuLocalShells => "ローカル", L10nKey::TabMenuAddHost => "SSH ホストを追加…", L10nKey::TabMenuAllHosts => "すべての SSH ホスト…", + L10nKey::TabMenuLaunchAgent => "エージェントを起動…", L10nKey::TabMenuOtherShells => "その他のシェル…", L10nKey::TabMenuSplitHint => "{key} を押しながら選ぶと分割", L10nKey::TabUnnamedShell => "シェル {n}", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index ff18fcca..43f94f69 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -1014,6 +1014,7 @@ l10n_keys! { AppMenuCopyWorkingDirectory, AppMenuCopySessionId, AppMenuForkSession, + AppMenuSaveAgentLaunchArgs, AppMenuClosePaneTab, AppMenuCloseOtherTabs, AppMenuCloseTabsRight, @@ -1073,6 +1074,7 @@ l10n_keys! { TabMenuLocalShells, TabMenuAddHost, TabMenuAllHosts, + TabMenuLaunchAgent, TabMenuOtherShells, TabMenuSplitHint, TabUnnamedShell, @@ -1232,6 +1234,8 @@ l10n_keys! { CmdCopyWorkingDirectory, CmdCopySessionId, CmdCopySessionIdSubtitle, + CmdNewAgentTab, + CmdNewAgentTabSubtitle, CmdForkSession, CmdForkSessionSubtitle, CmdMarkTabAsUnread, @@ -1363,6 +1367,11 @@ l10n_keys! { AppPaneNoKnownDirectory, AppNoUncommittedChanges, AppCmdSshProfileTitle, + AppCmdAgentLaunchTitle, + AppNoAgentOnPath, + AppNoAgentSeenHere, + AppAgentLaunchSaved, + AppAgentLaunchArgsUnknown, AppCmdShellTitle, AppCmdSwitchToTab, AppPlaceholderDescription, @@ -1666,6 +1675,9 @@ mod tests { // translation here would be less clear, not more. L10nKey::SettingsShell, L10nKey::CmdGroupAgents, + // The quick-launch rows, which the New Tab menu finds by typing + // this one word into the palette in every locale. + L10nKey::AppCmdAgentLaunchTitle, ]; for &key in KEPT_IN_ENGLISH { diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index d19289cb..7e5dc6c3 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -1432,6 +1432,8 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::CmdCopyWorkingDirectory => "复制工作目录", L10nKey::CmdCopySessionId => "复制会话 ID", L10nKey::CmdCopySessionIdSubtitle => "编码 agent 自身的会话 ID", + L10nKey::CmdNewAgentTab => "新建 Agent 标签页", + L10nKey::CmdNewAgentTabSubtitle => "在新标签页中打开上次使用的编码 agent", L10nKey::CmdForkSession => "Fork 会话", L10nKey::CmdForkSessionSubtitle => "将此 agent 会话 fork 到新标签页", L10nKey::CmdMarkTabAsUnread => "将标签页标记为未读", @@ -1589,6 +1591,13 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::AppPaneNoKnownDirectory => "此窗格没有已知的目录。", L10nKey::AppNoUncommittedChanges => "{cwd} 中没有未提交的更改(或不是 git 仓库)。", L10nKey::AppCmdSshProfileTitle => "SSH:{title}", + L10nKey::AppCmdAgentLaunchTitle => "Agent:{name}", + L10nKey::AppNoAgentOnPath => "在本机 PATH 中未找到编码 agent", + L10nKey::AppNoAgentSeenHere => { + "此工作区还没有运行过编码 agent——手动启动一次后,这里就会列出它" + } + L10nKey::AppAgentLaunchSaved => "{name} 以后将以此命令启动:{command}", + L10nKey::AppAgentLaunchArgsUnknown => "{name} 未报告其启动参数", L10nKey::AppCmdShellTitle => "Shell:{title}", L10nKey::AppCmdSwitchToTab => "切换到标签页:{label}", L10nKey::AppPlaceholderDescription => "描述", @@ -1742,6 +1751,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::AppMenuCopyWorkingDirectory => "复制工作目录", L10nKey::AppMenuCopySessionId => "复制会话 ID", L10nKey::AppMenuForkSession => "Fork 会话", + L10nKey::AppMenuSaveAgentLaunchArgs => "将当前启动参数设为默认", L10nKey::AppMenuClosePaneTab => "关闭", L10nKey::AppMenuCloseOtherTabs => "关闭其他标签页", L10nKey::AppMenuCloseTabsRight => "关闭右侧标签页", @@ -1800,6 +1810,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::TabMenuLocalShells => "本地", L10nKey::TabMenuAddHost => "添加 SSH 主机…", L10nKey::TabMenuAllHosts => "所有 SSH 主机…", + L10nKey::TabMenuLaunchAgent => "启动 Agent…", L10nKey::TabMenuOtherShells => "其他 Shell…", L10nKey::TabMenuSplitHint => "按住 {key} 可分屏打开", L10nKey::TabUnnamedShell => "终端 {n}", diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs index e9c93fe8..08296cd1 100644 --- a/src/ui/keymap.rs +++ b/src/ui/keymap.rs @@ -301,6 +301,24 @@ fn per_platform(mac: &'static str, other: &'static str) -> &'static str { } pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> { + let mut bindings = shipped_bindings(); + // One slot per agent, unbound: the palette's `Agent: …` rows are what these + // run, and each takes a key the same way any other action does. Right + // after the other agent actions, so the Keybindings page keeps them together. + let at = bindings + .iter() + .position(|(action, _)| *action == "NewAgentTab") + .map_or(bindings.len(), |i| i + 1); + bindings.splice( + at..at, + crate::ui::agent_launch::launch_action_names() + .iter() + .map(|(_, name)| (name.as_str(), "")), + ); + bindings +} + +fn shipped_bindings() -> Vec<(&'static str, &'static str)> { vec![ ("NewTab", per_platform("secondary-t", "secondary-shift-t")), ("NewWorkspace", "secondary-shift-n"), @@ -332,6 +350,11 @@ pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> { ("ForkAgentSessionDown", ""), ("ForkAgentSessionUp", ""), ("CopyAgentSessionId", ""), + // ⌘⇧A for "agent" on macOS, where nothing else in the table or the + // terminal holds it. Off macOS Ctrl+Shift+A is select-all in every + // Linux terminal and in this prompt editor's Ctrl+A family, so it ships + // unbound there rather than on a chord nobody would guess. + ("NewAgentTab", per_platform("secondary-shift-a", "")), ("StopWorkspace", ""), ("DeleteWorkspace", ""), ("RenameWorkspace", ""), @@ -579,6 +602,15 @@ fn authored_entry(action: &str) -> Option<(CommandGroup, String)> { t_fmt(L10nKey::KeybindGoToTab, &[("n", n)]), )); } + if let Some(agent) = crate::ui::agent_launch::agent_for_launch_action(action) { + return Some(( + CommandGroup::Agents, + t_fmt( + L10nKey::AppCmdAgentLaunchTitle, + &[("name", agent.display_name())], + ), + )); + } if let Some(n) = action.strip_prefix("SelectWorkspace") { return Some(( CommandGroup::Workspaces, @@ -831,6 +863,7 @@ fn authored_entry(action: &str) -> Option<(CommandGroup, String)> { CommandGroup::Agents, t(L10nKey::CmdCopySessionId).to_string(), ), + "NewAgentTab" => (CommandGroup::Agents, t(L10nKey::CmdNewAgentTab).to_string()), "TogglePalette" => ( CommandGroup::Application, t(L10nKey::AppMenuCommandPalette).to_string(), @@ -1348,6 +1381,9 @@ fn action_context(action: &str) -> Option<&'static str> { } fn make_binding(action: &str, keystroke: &str) -> Option { + if let Some(agent) = crate::ui::agent_launch::agent_for_launch_action(action) { + return Some(KeyBinding::new(keystroke, LaunchAgent { agent }, None)); + } Some(match action { "NewTab" => KeyBinding::new(keystroke, NewTab, None), "NewWorkspace" => KeyBinding::new(keystroke, NewWorkspace, None), @@ -1371,6 +1407,7 @@ fn make_binding(action: &str, keystroke: &str) -> Option { "ForkAgentSessionDown" => KeyBinding::new(keystroke, ForkAgentSessionDown, None), "ForkAgentSessionUp" => KeyBinding::new(keystroke, ForkAgentSessionUp, None), "CopyAgentSessionId" => KeyBinding::new(keystroke, CopyAgentSessionId, None), + "NewAgentTab" => KeyBinding::new(keystroke, NewAgentTab, None), "SplitRight" => KeyBinding::new(keystroke, SplitRight, None), "SplitDown" => KeyBinding::new(keystroke, SplitDown, None), "FocusNextPane" => KeyBinding::new(keystroke, FocusNextPane, None), @@ -2321,6 +2358,50 @@ mod tests { let ks = Keystroke::parse("secondary").unwrap(); assert_eq!(spec_from_keystroke(&ks), None); } + + #[test] + fn new_agent_tab_ships_a_chord_only_where_one_is_free() { + let mut effective: Vec<(String, String)> = default_bindings() + .into_iter() + .map(|(a, k)| (a.to_string(), k.to_string())) + .collect(); + let default = effective + .iter() + .find(|(action, _)| action == "NewAgentTab") + .map(|(_, key)| key.clone()) + .expect("NewAgentTab has to be listed or it cannot be bound"); + if cfg!(target_os = "macos") { + assert_eq!(default, "secondary-shift-a"); + assert_eq!( + dispatched(&effective, &default, "Terminal"), + vec![NewAgentTab::name_for_type()], + "{default} must reach NewAgentTab, and nothing else may answer it" + ); + } else { + assert_eq!(default, "", "Ctrl+Shift+A is select-all off macOS"); + } + + // Each agent's own launch is a slot of its own, and a key bound to it + // dispatches that agent. + let name = crate::ui::agent_launch::launch_action_name( + tty7_core::core::cli_agent::CLIAgent::Codex, + ); + effective + .iter_mut() + .find(|(action, _)| action == name) + .expect("every agent has a keymap slot") + .1 = "ctrl-alt-shift-c".to_string(); + let mut keymap = gpui::Keymap::default(); + keymap.add_bindings(action_bindings(&effective)); + let typed = [Keystroke::parse("ctrl-alt-shift-c").unwrap()]; + let context = [gpui::KeyContext::parse("Terminal").unwrap()]; + let hits = keymap.bindings_for_input(&typed, &context).0; + assert_eq!(hits.len(), 1); + assert!(hits[0].action().partial_eq(&LaunchAgent { + agent: tty7_core::core::cli_agent::CLIAgent::Codex, + })); + assert_eq!(action_entry(name).0, CommandGroup::Agents); + } } #[cfg(test)] diff --git a/src/ui/mod.rs b/src/ui/mod.rs index df167e08..a286cb19 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,3 +1,4 @@ +pub mod agent_launch; pub mod app; pub mod assets; pub mod code_editor; diff --git a/src/ui/palette.rs b/src/ui/palette.rs index d6865b2f..50bde72e 100644 --- a/src/ui/palette.rs +++ b/src/ui/palette.rs @@ -36,6 +36,8 @@ pub enum CommandKind { HibernateTab, ForkAgentSession, CopyAgentSessionId, + NewAgentTab, + LaunchAgent(crate::core::cli_agent::CLIAgent), ResetFontSize, NextPane, PrevPane, @@ -148,6 +150,7 @@ impl CommandKind { HibernateTab => "hibernate-tab", ForkAgentSession => "fork-agent-session", CopyAgentSessionId => "copy-agent-session-id", + NewAgentTab => "new-agent-tab", ResetFontSize => "reset-font-size", NextPane => "next-pane", PrevPane => "prev-pane", @@ -227,7 +230,8 @@ impl CommandKind { | EditSavedProfile(_) | OpenShell(_) | QuickConnect(_) - | SaveQuickConnect(_) => return None, + | SaveQuickConnect(_) + | LaunchAgent(_) => return None, }) } @@ -240,6 +244,12 @@ impl CommandKind { CutText => return inline("secondary-x"), PasteText => return inline("secondary-v"), SelectAllText => return inline("secondary-a"), + LaunchAgent(agent) => { + return crate::ui::keymap::effective_key( + crate::ui::agent_launch::launch_action_name(*agent), + cx, + ); + } _ => {} } let action = match self { @@ -262,6 +272,7 @@ impl CommandKind { HibernateTab => "HibernateTab", ForkAgentSession => "ForkAgentSession", CopyAgentSessionId => "CopyAgentSessionId", + NewAgentTab => "NewAgentTab", ResetFontSize => "ResetFontSize", NextPane => "FocusNextPane", PrevPane => "FocusPrevPane", @@ -344,7 +355,8 @@ impl CommandKind { | OpenShell(_) | SaveSshSessionAsHost | QuickConnect(_) - | SaveQuickConnect(_) => return None, + | SaveQuickConnect(_) + | LaunchAgent(_) => return None, }; crate::ui::keymap::effective_key(action, cx) } @@ -594,6 +606,8 @@ impl Command { ]; let agents = [ + Command::localized(L10nKey::CmdNewAgentTab, NewAgentTab) + .with_subtitle(t(L10nKey::CmdNewAgentTabSubtitle)), Command::localized(L10nKey::CmdAgentSendSelection, SendSelectionToAgent) .with_subtitle(t(L10nKey::CmdAgentSendSelectionSubtitle)), Command::localized(L10nKey::CmdAgentSendGitDiffForReview, SendGitDiffToAgent) diff --git a/src/ui/pending_pane.rs b/src/ui/pending_pane.rs index 93ed11c2..0428c1d5 100644 --- a/src/ui/pending_pane.rs +++ b/src/ui/pending_pane.rs @@ -20,6 +20,10 @@ pub struct PendingSpawn { pub agent: Option, pub agent_session_id: Option, pub agent_launch_argv: Option>, + /// A command line to type into the shell once the pane exists, for a pane + /// opened to run something (a quick-launched agent). Typed only into this + /// pane, and only when it is a fresh shell rather than a restored one. + pub run_on_land: Option, pub owner: Option, pub font_size: f32, } diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs index 6a06992e..ddb712f7 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -884,6 +884,19 @@ impl NewTabMenu { }); } })); + // The agents' seam into the palette, the same way the row above is the + // hosts': the menu names none of them, the palette holds them all. + let app = self.app.clone(); + menu = menu.item(PopupMenuItem::new(t(L10nKey::TabMenuLaunchAgent)).on_click( + move |_, window, cx| { + if let Some(app) = app.upgrade() { + app.update(cx, |this, cx| { + let query = crate::ui::agent_launch::PALETTE_AGENT_QUERY; + this.open_palette(query, window, cx); + }); + } + }, + )); // The one place ⌥ is spelled out. Nothing else in the app teaches it, // and a modifier nobody is told about is a feature nobody has. No rule