diff --git a/CHANGELOG.md b/CHANGELOG.md index ce0e406f..0b340858 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **The New Tab menu names the agents you actually run** (#955). Beside Local + and SSH, an Agents section lists up to three agents that have been launched + or seen running, most-used first — the same short list the shells get. A row + opens that agent in a new tab (hold ⌥ to split) and types its `agent_launch` + command, or the bare binary when none is set. Agents that are installed but + not in those rows are one click away: **Other Agents…** opens Search + Everywhere already filtered to `agent`. The section is absent when this + machine has no agent to offer. **New Agent Tab** (⌘⇧A) is unchanged: it + still starts the one used last. + - **Find in files, in the right panel's new Search tab.** Type and the active tab's project — the same roots the Files tab shows — is searched as you go, with hits grouped by file, a count per file, and each match highlighted in diff --git a/README.md b/README.md index ef117f6e..c888a354 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,8 @@ 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 row in Search Everywhere (**Agent: Claude Code**, …) -that opens it in a new tab; `agent_launch` sets the command line it starts with. +that opens it in a new tab, and the New Tab menu names the ones you have actually +run. `agent_launch` sets the command line it starts with. ## Documentation diff --git a/README.zh-CN.md b/README.zh-CN.md index 456312e6..1fb3b68d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -103,7 +103,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` 可以设置它的启动命令行。 +PATH 上的每个 agent 也都能在随处搜索里找到(**Agent:Claude Code** 等),会在新标签页中启动它;新建标签页菜单会列出真正用过的那几个。`agent_launch` 可以设置它的启动命令行。 ## 文档 diff --git a/docs/agents/overview.mdx b/docs/agents/overview.mdx index ed3b5aae..8f6e99a0 100644 --- a/docs/agents/overview.mdx +++ b/docs/agents/overview.mdx @@ -73,7 +73,9 @@ The key is your command's name; the value is one of the slugs above (`claude`, Every agent tty7 finds on your `PATH` is one row away in Search Everywhere: **Agent: Claude Code**, **Agent: Codex**, and so on, most-used first — type `agent` in -its Terminals tab to list them. **New Agent Tab** +its Terminals tab to list them. The New Tab menu names the ones you have +actually run, three at most, and **Other Agents…** opens that same list for the +rest. **New Agent Tab** (⌘ ⇧ A on macOS, unbound elsewhere) starts whichever agent you used last. diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 7f510f45..22f913e7 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -2032,6 +2032,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::TabMenuAddHost => "Add SSH Host…", L10nKey::TabMenuAllHosts => "All SSH Hosts…", L10nKey::TabMenuOtherShells => "Other Shells…", + L10nKey::TabMenuOtherAgents => "Other Agents…", L10nKey::TabMenuSplitHint => "Hold {key} to split", L10nKey::TabUnnamedShell => "Shell {n}", L10nKey::ShellDefault => "default", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index dc03ce45..dfebc354 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -2085,6 +2085,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::TabMenuAddHost => "SSH ホストを追加…", L10nKey::TabMenuAllHosts => "すべての SSH ホスト…", L10nKey::TabMenuOtherShells => "その他のシェル…", + L10nKey::TabMenuOtherAgents => "その他のエージェント…", L10nKey::TabMenuSplitHint => "{key} を押しながら選ぶと分割", L10nKey::TabUnnamedShell => "シェル {n}", L10nKey::ShellDefault => "デフォルト", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 1c39c3ff..746dbdb1 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -1208,6 +1208,7 @@ l10n_keys! { TabMenuAddHost, TabMenuAllHosts, TabMenuOtherShells, + TabMenuOtherAgents, TabMenuSplitHint, TabUnnamedShell, ShellDefault, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index b1e5fe85..7c1fc618 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -1893,6 +1893,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::TabMenuAddHost => "添加 SSH 主机…", L10nKey::TabMenuAllHosts => "所有 SSH 主机…", L10nKey::TabMenuOtherShells => "其他 Shell…", + L10nKey::TabMenuOtherAgents => "其他 Agent…", L10nKey::TabMenuSplitHint => "按住 {key} 可分屏打开", L10nKey::TabUnnamedShell => "终端 {n}", L10nKey::ShellDefault => "默认", diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs index c494773d..a3082904 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -17,6 +17,7 @@ use crate::core::actions::{ SelectWorkspace2, SelectWorkspace3, SelectWorkspace4, SelectWorkspace5, SelectWorkspace6, SelectWorkspace7, SelectWorkspace8, SelectWorkspace9, SplitDown, SplitRight, }; +use crate::core::cli_agent::CLIAgent; use crate::core::config::{Config, RightPanelTab}; use crate::core::shells::DetectedShell; use crate::daemon::protocol::ShellSpec; @@ -718,6 +719,14 @@ const MENU_HOSTS: usize = 6; /// by frecency. Everything else is one row away, in the search. const MENU_SHELLS: usize = 3; +/// How many agents the New Tab menu names. +/// +/// The same cap as [`MENU_SHELLS`], and for the same reason: the agents +/// someone actually runs are two or three, and a menu is not a search field. +/// Only agents that have been run are named — there is no "default agent" to +/// lead the way shells' default does. Everything else is one row away. +const MENU_AGENTS: usize = 3; + /// The Search Everywhere button in the middle of the title bar: the width of /// a field that reads as one, and the 28px of the rail's own search. const TITLEBAR_SEARCH_W: f32 = 360.; @@ -728,14 +737,15 @@ const MENU_W: Pixels = px(360.); /// How tall, before it starts scrolling. /// -/// Both lists are capped — [`MENU_SHELLS`] shells and [`MENU_HOSTS`] hosts — -/// so the menu's full hand is a fixed number of rows: those, both headings, the -/// row closing each section and the modifier hint, at the 26px a row occupies. -/// This leaves room above that for a seam row or two more, so the full hand -/// always arrives whole and never scrolls on its own. It is capped again -/// against the window in [`NewTabMenu::build`], since a menu taller than what -/// it hangs off is worse than one that scrolls. -const MENU_H: Pixels = px(560.); +/// The lists are capped — [`MENU_SHELLS`] shells, [`MENU_AGENTS`] agents and +/// [`MENU_HOSTS`] hosts — so the menu's full hand is a fixed number of rows: +/// those, the three headings, the row closing each section, the separators and +/// the modifier hint, at the 26px a row occupies. The Agents section is absent +/// when nothing is offered, and this still leaves room above the full hand for +/// a seam row or two more, so it always arrives whole and never scrolls on its +/// own. It is capped again against the window in [`NewTabMenu::build`], since +/// a menu taller than what it hangs off is worse than one that scrolls. +const MENU_H: Pixels = px(720.); /// What the row closing the Local section types into the search's Terminals /// tab for you. @@ -756,6 +766,14 @@ const MENU_H: Pixels = px(560.); /// so this lands on exactly the shells, default first and then by frecency. const SEARCH_SHELL_QUERY: &str = "shell"; +/// What the row closing the Agents section types into the search's Terminals +/// tab. +/// +/// The same seam as [`SEARCH_SHELL_QUERY`]. Every quick-launch row is titled +/// `Agent: {name}` ([`L10nKey::AppCmdAgentLaunchTitle`], the same word in every +/// language), so this lands on exactly the agents. +const SEARCH_AGENT_QUERY: &str = "agent"; + /// How this platform spells the key that turns a New Tab row into a split. fn split_modifier() -> &'static str { if cfg!(target_os = "macos") { @@ -783,6 +801,13 @@ struct NewTabMenu { /// Saved host, its display name, and the `user@host:port` beside it — /// empty when the name already says it. hosts: Vec<(uuid::Uuid, SharedString, SharedString)>, + /// Agents the menu names — at most [`MENU_AGENTS`], and only ones that + /// have actually been run. + agents: Vec, + /// The window can offer agents the menu does not name, so the section + /// closes with a row into the palette. Also the reason the section exists + /// when nothing has been run yet: the installed agents are all in there. + more_agents: bool, } impl NewTabMenu { @@ -906,6 +931,44 @@ impl NewTabMenu { } })); + // A peer of Local and SSH, and absent when this machine has no agent + // to offer — an empty heading would be a section about nothing. The + // rows are the ones actually run; the seam holds the rest, already + // filtered, the way Other Shells does. + if !self.agents.is_empty() || self.more_agents { + menu = menu + .item(PopupMenuItem::separator()) + .item(PopupMenuItem::label(t(L10nKey::CmdGroupAgents))); + for agent in self.agents.iter().copied() { + let app = self.app.clone(); + menu = menu.item(PopupMenuItem::new(agent.display_name()).on_click( + move |_, window, cx| { + let at = SpawnWhere::from_modifiers(window.modifiers()); + if let Some(app) = app.upgrade() { + app.update(cx, |this, cx| this.launch_agent(agent, at, window, cx)); + } + }, + )); + } + if self.more_agents { + let app = self.app.clone(); + menu = menu.item(PopupMenuItem::new(t(L10nKey::TabMenuOtherAgents)).on_click( + move |_, window, cx| { + if let Some(app) = app.upgrade() { + app.update(cx, |this, cx| { + this.open_search( + SearchTab::Terminals, + SEARCH_AGENT_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 // above it: a separator divides two lists of things to pick, and this @@ -966,6 +1029,27 @@ fn menu_shells<'a>( .collect() } +/// The agents the menu names, off the front of the frecency-ordered list +/// quick launch already built: only those that have actually been run, at +/// most [`MENU_AGENTS`]. +/// +/// An agent nobody has run is not named just because it is installed and +/// there is room. A zero count is not a use either — launching stamps +/// recency before the pane reports the agent, and the count is what says it +/// really ran. `offered` is already most-used-first; this does not reorder. +fn menu_agents( + offered: &[CLIAgent], + usage: &std::collections::HashMap, + now: u64, +) -> Vec { + offered + .iter() + .copied() + .filter(|agent| usage.get(agent.slug()).is_some_and(|u| u.score(now) > 0.0)) + .take(MENU_AGENTS) + .collect() +} + /// The hosts the menu names, in the order they were handed over — frecency, /// so the ones that fit are the ones actually used. /// @@ -1731,6 +1815,8 @@ impl Tty7App { let now = crate::core::config::unix_now(); let sorted = shells_by_frecency(&self.shells.shells, &default_shell, usage, now); let shells = menu_shells(&sorted, &default_shell, usage, now); + let offered = self.offered_agents(cx); + let agents = menu_agents(&offered, &cx.global::().agent_frecency, now); NewTabMenu { app, more_shells: shells.len() < sorted.len(), @@ -1740,6 +1826,8 @@ impl Tty7App { .collect(), default_shell: SharedString::from(default_shell), hosts: menu_hosts(crate::ui::ssh_connect::ssh_profiles_by_frecency(cx)), + more_agents: agents.len() < offered.len(), + agents, } } @@ -3819,4 +3907,50 @@ mod tests { "no inventory: the fallback row, nothing more" ); } + + #[test] + fn the_new_tab_menu_names_agents_that_have_actually_been_run() { + use crate::core::cli_agent::CLIAgent; + let now = 100_000_000u64; + let day = 86_400u64; + // Already most-used-first, the way `offered_agents` hands them over, + // except Amp has never been run and sits between two that have — the + // filter has to skip it rather than stop. + let offered = [ + CLIAgent::Claude, + CLIAgent::Amp, + CLIAgent::Codex, + CLIAgent::Gemini, + CLIAgent::Aider, + ]; + let usage = used(&[ + ("claude", 12, now - 2 * day), + ("codex", 3, now - day), + ("gemini", 1, now - 30 * day), + ("aider", 0, now), + ]); + let named = menu_agents(&offered, &usage, now); + assert_eq!( + named, + vec![CLIAgent::Claude, CLIAgent::Codex, CLIAgent::Gemini], + "three that have run fill the cap; a zero count is not a use, and a gap is skipped" + ); + assert!(named.len() < offered.len(), "the rest are the seam row's"); + + let none = used(&[]); + let named = menu_agents(&offered, &none, now); + assert!(named.is_empty(), "installed but never run is not named"); + assert!( + named.len() < offered.len(), + "so the section is only the seam" + ); + assert!(menu_agents(&[], &usage, now).is_empty()); + + // Every offered agent has been run and they fit: no seam row. + let few = [CLIAgent::Claude, CLIAgent::Codex]; + let two = used(&[("claude", 4, now), ("codex", 1, now)]); + let named = menu_agents(&few, &two, now); + assert_eq!(named, vec![CLIAgent::Claude, CLIAgent::Codex]); + assert_eq!(named.len(), few.len()); + } }