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());
+ }
}