feat(agents): quick launch for detected CLI agents (#961)

* feat(agents): quick launch for detected CLI agents (#955)

Every agent whose launch program is on PATH becomes a palette command,
"Agent: <name>", ordered by frecency and bindable as LaunchAgent:<slug>.
"New Agent Tab" (Cmd+Shift+A on macOS; unbound elsewhere, where
Ctrl+Shift+A is select-all) launches the most recently used one, and the
New Tab menu gains a single "Launch Agent..." row that opens the palette
pre-filtered to them.

A launch always opens a new pane (a tab in the active tab's cwd, or a
split when picked from the palette with Alt held) and types the command
into that pane's shell once it exists - immediately for a local pane, on
landing for a remote one via PendingSpawn::run_on_land - never into a
pane that was already there. Detection, status and resume then work as
for a hand-typed agent.

The command is the agent's bare binary unless the new `agent_launch`
config map overrides it. A wrapper named there is detected as its agent
without an `agent_commands` entry: the daemon folds the programs
`agent_launch` runs into its alias map (interpreters, shells and real
agent names excepted), reloaded when config.json changes instead of
once per process, and a mapped script run under its interpreter
(`bash ~/bin/cc`, `node cc.js`) is now recognised too.

A running agent's pane menu gets "Set Current Launch Args as Default",
which writes its launch argv - minus session flags and positional
prompts, joined with the settings' quoting - into `agent_launch`.

Remote workspaces cannot be asked for their PATH through the Host
trait, so they offer the agents previously seen running in that
workspace (WindowView::seen_agents).

* fix(agents): count only agents that start under a view, not ones reattached to

A view rebuilt over a running pane - every agent tab after an app
restart, or a workspace switched back to - saw its agent appear from
nothing and reported it as detected, bumping that agent's frecency once
per launch of the app. The terminal now remembers whether its link was
an attach, and such a view only reports agents once its shell has been
seen back at the prompt with no agent in front.

Also pins down that the agents seen in a remote workspace, its quick
launch list, persist in views.json with the rest of the workspace.

* fix(daemon): probe the foreground as soon as a new program takes it

The foreground probes ran on output only, at most once per 500ms
interval. A quick launch types the agent's command the moment the shell
is up, so the agent drew its whole first screen inside the interval of
the prompt it was typed at and then waited for a key: the pane never
learned it was running an agent until something else printed. Seen in
a dev instance, where a launched Claude Code stayed undetected at its
trust prompt.

The reader now asks the pty for its foreground process group on every
read (one ioctl) and probes immediately when it changes.
This commit is contained in:
l0ng-ai
2026-09-25 16:47:59 +08:00
committed by GitHub
parent 27483e893d
commit afcb8aa2dd
27 changed files with 1519 additions and 57 deletions
+20
View File
@@ -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:<slug>`. "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
+2
View File
@@ -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
+1
View File
@@ -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` 可以设置它的启动命令行。
## 文档
+342 -18
View File
@@ -304,6 +304,32 @@ impl CLIAgent {
}
fn replay_flags(self, argv: &[String]) -> Option<Vec<String>> {
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<Vec<&str>> {
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, String>) -> 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<Vec<String>> {
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<String> = 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<CLIAgent> {
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<String> {
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<String, String>) -> HashMap<String, String> {
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<String, String> {
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));
}
}
+78 -9
View File
@@ -416,6 +416,17 @@ pub struct Config {
#[serde(default)]
pub agent_commands: HashMap<String, String>,
/// 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<String, String>,
/// 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<String, ProfileUsage>,
#[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<String, String> {
Config::load().env
}
pub fn agent_commands_cached() -> &'static HashMap<String, String> {
static CACHE: std::sync::OnceLock<HashMap<String, String>> = 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<HashMap<String, String>> {
type Stamp = Option<(std::time::SystemTime, u64)>;
static CACHE: std::sync::Mutex<Option<(Stamp, std::sync::Arc<HashMap<String, String>>)>> =
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<String, String>,
launch: &HashMap<String, String>,
) -> HashMap<String, String> {
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();
+42
View File
@@ -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<String>,
}
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);
}
}
+106 -4
View File
@@ -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<dyn Fn() -> Option<i32> + Send>,
remote: Box<dyn Fn() -> Option<RemoteContext> + Send>,
agent: Box<dyn Fn() -> Option<Option<(crate::core::cli_agent::CLIAgent, Vec<String>)>> + Send>,
cwd: Box<dyn Fn() -> Option<PathBuf> + 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<DeathReporter>,
) -> 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<Vec<u8>>);
impl Read for Chunks {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
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<i32>| {
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"
);
}
}
+35
View File
@@ -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**
(<kbd>⌘ ⇧ A</kbd> 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:<slug>`.
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:
+2
View File
@@ -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. |
+2 -1
View File
@@ -19,6 +19,7 @@ it shows what *your* copy is bound to. This is the shipped default.
| New Workspace | <kbd>⌘ ⇧ N</kbd> | <kbd>Ctrl ⇧ N</kbd> |
| Switch Workspace | <kbd>⌘ ⇧ O</kbd> | <kbd>Ctrl ⇧ O</kbd> |
| New Window | <kbd>⌘ N</kbd> | — |
| New Agent Tab | <kbd>⌘ ⇧ A</kbd> | — |
## 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:<slug>` (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` |
+10
View File
@@ -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,
}
+18
View File
@@ -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)
}
+10
View File
@@ -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()
+151 -2
View File
@@ -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<AgentSessionChanged> 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<AgentDetected> 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<std::time::Instant>,
running_title: String,
running_agent: Option<crate::core::cli_agent::CLIAgent>,
/// 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<crate::core::cli_agent::CLIAgent>,
/// 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<crate::core::cli_agent::AgentStatus>,
last_agent_session: (Option<String>, Option<Vec<String>>),
agent_turn_started: Option<std::time::Instant>,
@@ -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<Self>) {
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<CLIAgent>,
at_prompt: bool,
pane: &Entity<TerminalView>,
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
+449
View File
@@ -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<Vec<(CLIAgent, String)>> = 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<CLIAgent> {
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<String, String>,
) -> Vec<CLIAgent> {
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<Item = CLIAgent>,
usage: &HashMap<String, ProfileUsage>,
now: u64,
) -> Vec<CLIAgent> {
let score = |agent: &CLIAgent| usage.get(agent.slug()).map(|u| u.score(now)).unwrap_or(0.0);
let mut agents: Vec<CLIAgent> = 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<String, ProfileUsage>,
) -> Option<CLIAgent> {
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<String> {
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<CLIAgent> {
let cfg = cx.global::<Config>();
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<Self>,
) {
let command = agent.launch_command(&cx.global::<Config>().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<Self>) {
let offered = self.offered_agents(cx);
match most_recent(&offered, &cx.global::<Config>().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>) {
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<Self>) {
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<String, ProfileUsage> {
pairs
.iter()
.map(|(slug, count, last_used)| {
(
slug.to_string(),
ProfileUsage {
count: *count,
last_used: *last_used,
},
)
})
.collect()
}
fn argv(parts: &[&str]) -> Vec<String> {
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<String, String> = [
("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]);
}
}
+87 -21
View File
@@ -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>,
) {
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<std::path::PathBuf>,
shell: Option<ShellSpec>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<PaneSlot> {
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<Self>,
) {
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<SpawnAs>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<PaneSlot> {
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<Self>) {
@@ -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::<Config>().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.,
},
+1
View File
@@ -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,
},
+1
View File
@@ -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,
},
+13
View File
@@ -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}",
+11
View File
@@ -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}",
+12
View File
@@ -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 {
+11
View File
@@ -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}",
+81
View File
@@ -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<KeyBinding> {
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<KeyBinding> {
"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)]
+1
View File
@@ -1,3 +1,4 @@
pub mod agent_launch;
pub mod app;
pub mod assets;
pub mod code_editor;
+16 -2
View File
@@ -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)
+4
View File
@@ -20,6 +20,10 @@ pub struct PendingSpawn {
pub agent: Option<crate::core::cli_agent::CLIAgent>,
pub agent_session_id: Option<String>,
pub agent_launch_argv: Option<Vec<String>>,
/// 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<String>,
pub owner: Option<crate::core::session::WorkspaceId>,
pub font_size: f32,
}
+13
View File
@@ -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