feat(agents): fork an agent session, and copy its session id

A coding-agent conversation is a single thread: to try a risky direction you
either lose the one that got you there, or you don't try it. Every agent tty7
resumes already knows how to branch — `codex fork <id>`, `claude --resume <id>
--fork-session`, `opencode --session <id> --fork`, `grok --resume <id>
--fork-session` — but nothing in tty7 reached them, so the capability was
invisible from the terminal that already knows every pane's session id.

Fork is a per-agent capability beside the existing resume table
(`CLIAgent::fork_command`), not a Codex special case: it is the same `match
self` shape, it reuses the same id validation and the same launch-flag replay,
and four installed agents qualify today. Every command was checked against that
CLI's own `--help`; agents with no fork tty7 could verify return `None` and are
never offered the action, since a guessed flag shape would only ever produce a
usage error in the pane.

Flag replay needed one correctness fix to survive this. A forked pane's own
argv *is* a fork command, so relaunching it would replay the stale subcommand
and id (`codex fork <old>` → an old id as a positional prompt) or double the
modifier (`--fork-session --fork-session`). `codex fork` now sheds its
subcommand exactly as `codex resume` did, and `--fork-session` / `--fork` join
their agents' stale session-targeting lists. That also settles restore: a forked
pane restores through `resume_command`, which now drops the fork flag — a
restart continues the fork rather than branching it again.

Placement follows where the user asked from. A pane-level ask is spatial, so
the pane right-click menu offers Split Right / Left / Down / Up (pane splits
gained a `before` slot for the Left/Up half, which the tree had no way to
express). A tab-level ask isn't, so the tab context menu — inherited verbatim by
the sidebar rows, which is where the request came from — opens the fork in a new
tab with no placement question. The bare action behind the palette, the File
menu and Settings → Keybindings takes the tab-level meaning.

The three ways a fork can't run all surface rather than no-op: no session id
yet (hooks not installed) and a remote pane (the command would shell the *local*
agent) render the row disabled instead of hiding it, so the capability stays
discoverable, and the action paths that have no row to grey out say so in a
notification. Forking mid-turn is allowed but announced — agents fork from the
persisted transcript, so the turn in flight is absent from the copy — and the
parent is untouched either way.

Copy Session ID sits beside Copy Working Directory. Codex has no
copy-or-duplicate subcommand, so "copy the session" is the id: paste it into
`codex resume`, a bug report, or another tool.

Deliberately not built: any reading or writing of an agent's own session files.
tty7's exposure stays the public CLI contract plus the hook payload's session
id, so a change to Codex's rollout format or its version-numbered SQLite index
costs at most a visible shell error. Forked tabs also look exactly like their
parent, by decision — "Rename Tab" is the answer.

Closes #211
This commit is contained in:
l0ng-ai
2026-07-29 00:12:07 +08:00
parent a88a8b6515
commit 65892e155e
13 changed files with 838 additions and 50 deletions
+39
View File
@@ -0,0 +1,39 @@
# Project agent memory
This file is the project's committed home for project-intrinsic agent knowledge: build, test, release, architecture, and sharp-edge notes that should travel with the code.
- Add durable project-specific notes here as they are discovered through real work.
## Adding a user-facing action
Actions are declared once in `src/core/actions.rs` and then wired into every
surface by hand — miss one and the action silently exists nowhere. The full
set of places, each a plain table you extend by copying the row above:
| File | What |
|---|---|
| `src/core/actions.rs` | declare it in `actions!` |
| `src/ui/app.rs` | an `on_action` listener in `Render for Tty7App`, and a `run_command` arm if it is in the palette |
| `src/ui/keymap.rs` | `default_bindings()` (empty string = bindable, no default chord) **and** `make_binding()` |
| `src/ui/palette.rs` | `CommandKind` variant, `id()`, the action-name map, and a `Command::new` row |
| `src/ui/theme.rs` | the macOS menu-bar item |
| `src/ui/tab_strip.rs` | `tab_context_menu` — the tab-strip chips **and** the sidebar rows share it verbatim, so a row added here appears in both |
| `src/terminal/view.rs` | the pane right-click menu. It has no `Tty7App` handle, so rows here must dispatch actions, not closures; a submenu needs its own `action_context` (it does not inherit the parent menu's) |
## Third-party coding agents
`src/core/cli_agent.rs` is the whole registry: detection, per-agent resume /
fork commands, and the launch-flag replay that carries a pane's original flags
onto them. Per-agent behaviour is always a `match self` table returning `None`
for agents without the capability — follow that shape rather than special-casing
one agent. tty7 never reads or writes an agent's own session files; it shells
the agent's own subcommand, so an agent changing its on-disk format costs at
most a visible shell error. Only claim a flag you have checked against the
installed CLI's own `--help`.
## Maintaining this file
Keep this file for knowledge useful to almost every future agent session in this project.
Do not repeat what the codebase already shows; point to the authoritative file or command instead.
Prefer rewriting or pruning existing entries over appending new ones.
When updating this file, preserve this bar for all agents and keep entries concise.
+32
View File
@@ -24,6 +24,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
resumed at all — that pane never wrote a session to disk, and reopening one
would override the choice to keep it ephemeral. (#225)
- **Fork an agent session** — branch a live agent conversation into a second,
independent one, so a risky direction can be tried without losing the thread
that got you there. tty7 shells the agent's *own* fork command rather than
touching its transcript files: `codex fork <id>`, `claude --resume <id>
--fork-session`, `opencode --session <id> --fork`, `grok --resume <id>
--fork-session` — every one checked against the installed CLI's own help.
Agents with no fork tty7 could verify simply don't offer the action, rather
than getting a row that can only produce a usage error. The command carries
the pane's original launch flags exactly as session restore does, and sheds
the stale session-targeting ones so a fork of a fork can't branch twice or
replay an old id as a prompt.
Where the fork lands follows where you asked from: right-click a **pane** and
it asks for a split placement (Right / Left / Down / Up), since a pane-level
ask is a spatial one; right-click a **tab** or a sidebar row and it opens in a
new tab, with no placement question. Both are also reachable from the command
palette, the File menu, and Settings → Keybindings.
A fork needs the session id the agent's hooks report, so the row disables
itself — rather than disappearing — until one arrives, and a remote pane can't
fork at all (the command would run against the *local* agent). Forking while a
turn is in flight is allowed but says so: agents fork from the persisted
transcript, so the turn you're watching won't be in the copy. The parent is
never modified either way. (#211)
- **Copy Session ID** — the agent's native session id on the clipboard, beside
*Copy Working Directory* in the tab / sidebar context menu, the palette and
the File menu. Codex has no copy-or-duplicate subcommand — forking *is* how
you duplicate a conversation there — so copying the id is what "copy the
session" means: paste it into `codex resume`, a bug report, or another tool.
(#211)
## [26.7.6] - 2026-07-28
### Added
+250 -27
View File
@@ -173,33 +173,14 @@ impl CLIAgent {
session_id: &str,
launch_argv: Option<&[String]>,
) -> Option<String> {
// Ids come from the agent's own events, but they still land on a shell
// command line — refuse anything that isn't a plain token so a
// malicious/corrupt id can't smuggle shell syntax.
if session_id.is_empty()
|| !session_id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.')
{
return None;
}
// A pane the user launched as deliberately ephemeral has nothing on
// disk to come back to, whatever id the agent reported.
// disk to come back to, whatever id the agent reported. Resume-only:
// no agent that opts out of sessions has a fork command today, so
// hoisting this into the shared helper would only add a dead branch.
if launch_argv.is_some_and(|argv| self.opts_out_of_sessions(argv)) {
return None;
}
// The user's launch flags, pre-joined with a leading space so they
// splice into the format strings below; empty when none survive.
let flags = launch_argv
.and_then(|argv| self.replay_flags(argv))
.map(|flags| {
flags.iter().fold(String::new(), |mut s, f| {
s.push(' ');
s.push_str(f);
s
})
})
.unwrap_or_default();
let flags = self.session_command_flags(session_id, launch_argv)?;
match self {
CLIAgent::Claude => Some(format!("claude{flags} --resume {session_id}")),
// Codex resumes via a subcommand that accepts the interactive
@@ -245,6 +226,89 @@ impl CLIAgent {
argv.iter().any(|t| ephemeral.contains(&t.as_str()))
}
/// The shell command that *forks* a previous session of this agent — one
/// that branches the transcript into a fresh session id, leaving the
/// original untouched so both can be continued independently. `None` for
/// agents tty7 has no verified fork command for; those must not be offered
/// the action at all rather than shown a command that fails in the pane.
///
/// Shares [`resume_command`](Self::resume_command)'s id validation and
/// launch-flag replay verbatim: every agent below takes the same option set
/// on its fork path as on its resume path, so there is no second table to
/// keep in step.
///
/// Deliberately *not* wired into session restore. Restoring a forked pane
/// after a tty7 restart must **continue** that fork (its own id resumes),
/// not fork it again — see `ui::app`'s restore path, which calls
/// `resume_command` for every pane including forks.
pub fn fork_command(self, session_id: &str, launch_argv: Option<&[String]>) -> Option<String> {
let flags = self.session_command_flags(session_id, launch_argv)?;
match self {
// `codex fork [OPTIONS] [SESSION_ID]` — a first-class subcommand
// taking the same options as `codex resume`. It mints a new thread
// id, writes a new rollout recording `forked_from_id`, and leaves
// the parent's bytes untouched.
CLIAgent::Codex => Some(format!("codex fork {session_id}{flags}")),
// `--fork-session` is a modifier on the resume, not a mode of its
// own: it makes the resumed conversation take a new session id.
CLIAgent::Claude => Some(format!(
"claude{flags} --resume {session_id} --fork-session"
)),
CLIAgent::Grok => Some(format!("grok{flags} --resume {session_id} --fork-session")),
// opencode's `--fork` likewise only means anything alongside
// `--session`/`--continue`.
CLIAgent::OpenCode => Some(format!("opencode{flags} --session {session_id} --fork")),
// Everything else: no fork flag we could verify from the CLI's own
// help, so tty7 claims none. Guessing a flag shape here would
// surface a menu row that only ever produces a usage error.
_ => None,
}
}
/// The agent's own word for forking, for menu labels — otty's convention,
/// and the word a user hunting the menu will be looking for. `Some` exactly
/// when [`fork_command`](Self::fork_command) can build a command, so the UI
/// can use it as the single capability gate.
pub fn fork_label(self) -> Option<&'static str> {
match self {
// Claude Code calls it branching.
CLIAgent::Claude => Some("Branch Session"),
CLIAgent::Codex | CLIAgent::Grok | CLIAgent::OpenCode => Some("Fork Session"),
_ => None,
}
}
/// The launch-flag tail replayed onto a resume/fork command, pre-joined
/// with a leading space so it splices straight into the format strings, and
/// empty when no flags survive. `None` rejects the whole command: ids come
/// from the agent's own events but still land on a shell command line, so
/// anything that isn't a plain token could smuggle shell syntax.
fn session_command_flags(
self,
session_id: &str,
launch_argv: Option<&[String]>,
) -> Option<String> {
if session_id.is_empty()
|| !session_id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.')
{
return None;
}
Some(
launch_argv
.and_then(|argv| self.replay_flags(argv))
.map(|flags| {
flags.iter().fold(String::new(), |mut s, f| {
s.push(' ');
s.push_str(f);
s
})
})
.unwrap_or_default(),
)
}
/// The launch-flag tail of `argv` worth replaying on a resume command, or
/// `None` to resume bare. Deliberately conservative: anything ambiguous
/// falls back to no flags rather than a corrupted command line.
@@ -271,9 +335,10 @@ impl CLIAgent {
let named = argv.iter().position(|t| names_self(t))?;
let mut tail: Vec<&str> = argv[named + 1..].iter().map(String::as_str).collect();
// A relaunched `codex resume <old-id>`: drop the subcommand and its id
// so they don't replay as a positional prompt.
if self == CLIAgent::Codex && tail.first() == Some(&"resume") {
// A relaunched `codex resume <old-id>` — or `codex fork <old-id>`, which
// is what a forked pane's argv looks like: drop the subcommand and its
// id so they don't replay as a positional prompt.
if self == CLIAgent::Codex && matches!(tail.first(), Some(&"resume") | Some(&"fork")) {
tail.remove(0);
if tail.first().is_some_and(|t| !t.starts_with('-')) {
tail.remove(0);
@@ -284,6 +349,10 @@ impl CLIAgent {
// stripped together with one following non-flag value token (harmless
// for the value-less ones — anything trailing them is positional).
let stale: &[&str] = match self {
// `--fork-session` / `--fork` are session-targeting too: left in
// place they would branch again on every relaunch (and double up on
// a fork of a fork). Both resume and fork re-add them from the
// table when they are what the user actually asked for.
CLIAgent::Claude => &[
"--resume",
"-r",
@@ -291,10 +360,11 @@ impl CLIAgent {
"-c",
"--session-id",
"--from-pr",
"--fork-session",
],
CLIAgent::Gemini | CLIAgent::Cursor => &["--resume", "-r"],
CLIAgent::Copilot => &["--resume", "-r", "--continue", "-c"],
CLIAgent::OpenCode => &["--session", "-s", "--continue", "-c"],
CLIAgent::OpenCode => &["--session", "-s", "--continue", "-c", "--fork"],
// `--last` targets "the most recent session" and would contradict
// the explicit id we inject.
CLIAgent::Codex => &["--last"],
@@ -1563,6 +1633,159 @@ mod tests {
);
}
#[test]
fn fork_commands_cover_exactly_the_agents_with_a_verified_fork() {
// Each command below was checked against the installed CLI's own
// `--help`; anything unverified stays `None` so the UI never offers a
// row that can only produce a usage error.
assert_eq!(
CLIAgent::Codex.fork_command("abc-123", None).as_deref(),
Some("codex fork abc-123")
);
assert_eq!(
CLIAgent::Claude.fork_command("abc-123", None).as_deref(),
Some("claude --resume abc-123 --fork-session")
);
assert_eq!(
CLIAgent::Grok.fork_command("g-1", None).as_deref(),
Some("grok --resume g-1 --fork-session")
);
assert_eq!(
CLIAgent::OpenCode.fork_command("s-1", None).as_deref(),
Some("opencode --session s-1 --fork")
);
// Not forkable: resumable but with no fork flag (Gemini, Copilot,
// Cursor, Amp), and agents tty7 can't even resume.
for agent in [
CLIAgent::Gemini,
CLIAgent::Copilot,
CLIAgent::Cursor,
CLIAgent::Amp,
CLIAgent::Aider,
CLIAgent::Qwen,
] {
assert_eq!(
agent.fork_command("abc", None),
None,
"{} must not claim a fork command",
agent.slug()
);
}
// `fork_label` is the UI's capability gate, so it must agree with
// `fork_command` for every agent — no menu row without a command, and
// no command the menu can't name.
for agent in CLIAgent::ALL {
assert_eq!(
agent.fork_label().is_some(),
agent.fork_command("abc", None).is_some(),
"{}: fork_label and fork_command disagree",
agent.slug()
);
}
}
#[test]
fn fork_commands_are_shell_safe() {
// Same id gate as resume: an id carrying shell syntax is refused
// outright rather than escaped, because it reaches a command line.
for id in ["abc; rm -rf /", "$(boom)", "", "a b"] {
assert_eq!(
CLIAgent::Codex.fork_command(id, None),
None,
"codex accepted a non-token id: {id:?}"
);
assert_eq!(CLIAgent::Claude.fork_command(id, None), None);
}
}
#[test]
fn fork_carries_launch_flags_and_sheds_stale_session_targeting() {
let argv = |parts: &[&str]| parts.iter().map(|s| s.to_string()).collect::<Vec<_>>();
// The user's mode flags ride onto the fork exactly as onto a resume.
assert_eq!(
CLIAgent::Codex
.fork_command("id-1", Some(&argv(&["codex", "--yolo"])))
.as_deref(),
Some("codex fork id-1 --yolo")
);
assert_eq!(
CLIAgent::Claude
.fork_command(
"abc",
Some(&argv(&["claude", "--dangerously-skip-permissions"]))
)
.as_deref(),
Some("claude --dangerously-skip-permissions --resume abc --fork-session")
);
// Fork of a fork: the pane's own argv is a fork command, so the stale
// subcommand + id (codex) and the stale `--fork-session` / `--fork`
// modifier (claude, grok, opencode) must not replay.
assert_eq!(
CLIAgent::Codex
.fork_command("id-2", Some(&argv(&["codex", "fork", "id-1", "--yolo"])))
.as_deref(),
Some("codex fork id-2 --yolo")
);
assert_eq!(
CLIAgent::Claude
.fork_command(
"new",
Some(&argv(&["claude", "--resume", "old", "--fork-session"]))
)
.as_deref(),
Some("claude --resume new --fork-session")
);
assert_eq!(
CLIAgent::Grok
.fork_command(
"g-2",
Some(&argv(&["grok", "--resume", "g-1", "--fork-session"]))
)
.as_deref(),
Some("grok --resume g-2 --fork-session")
);
assert_eq!(
CLIAgent::OpenCode
.fork_command(
"s-2",
Some(&argv(&["opencode", "--session", "s-1", "--fork"]))
)
.as_deref(),
Some("opencode --session s-2 --fork")
);
// Restoring a forked pane must *continue* it, not fork it again: the
// resume command built from a fork's own argv carries no fork flag.
assert_eq!(
CLIAgent::Codex
.resume_command("id-2", Some(&argv(&["codex", "fork", "id-1", "--yolo"])))
.as_deref(),
Some("codex resume id-2 --yolo")
);
assert_eq!(
CLIAgent::Claude
.resume_command(
"new",
Some(&argv(&["claude", "--resume", "old", "--fork-session"]))
)
.as_deref(),
Some("claude --resume new")
);
assert_eq!(
CLIAgent::OpenCode
.resume_command(
"s-2",
Some(&argv(&["opencode", "--session", "s-1", "--fork"]))
)
.as_deref(),
Some("opencode --session s-2")
);
}
#[test]
fn status_metadata_is_consistent() {
assert_eq!(AgentStatus::Idle.dot_rgb(), None);
+2
View File
@@ -60,6 +60,8 @@ it never wraps or replaces the agent.
- **Notifications** — "needs your permission…" the moment an agent blocks on you, and "finished after Ns" per turn, honoring your notification policy
- **Branch at a glance** — each sidebar row shows its pane's git branch and working-tree diff (`+N M`), refreshed on `cd` and when a command finishes
- **Session resume** — panes lost to a reboot re-launch their agent conversation on restore, carrying the original launch flags (`claude --dangerously-skip-permissions --resume …`) (`restore_agent_sessions`, on by default)
- **Fork session** — branch a live agent conversation into a second, independent one by shelling the agent's own fork command (`codex fork <id>`, `claude --resume <id> --fork-session`, also OpenCode and Grok Build); the original is untouched and both continue separately. Right-click a pane to pick a split placement, or right-click the tab / sidebar row to open the fork in a new tab. Needs the agent's hooks installed, since the fork targets the session id they report — and note a fork copies the whole transcript, so repeated forking costs real disk in the agent's own session store
- **Copy Session ID** — put the agent's native session id on the clipboard, beside *Copy Working Directory*, for pasting into `codex resume`, a bug report, or another tool
- **Context feed** — palette commands send the current selection or the repo's `git diff` to the running agent as a ready-made prompt
- **Tray icon** — a system tray / menu bar item that flips to an attention state the moment any agent needs your input; its menu lists every agent pane (brand avatar + status dot, click to reveal), switches the notification policy, and offers *Quit and Stop Daemon* alongside the plain session-keeping quit (`show_tray_icon`, on by default)
+2
View File
@@ -57,6 +57,8 @@ Aider、Amp、OpenCode 等约 17 个)并在其外围加功能 —— 绝不包
- **通知** —— agent 卡在等你批准的那一刻弹 "needs your permission…",每轮结束弹 "finished after Ns",遵循你的通知策略
- **一眼看分支** —— 侧栏每行显示该 pane 的 git 分支和工作区改动(`+N M`),`cd` 或命令跑完时自动刷新
- **会话恢复** —— 重启后无法重连的 pane 会自动续上 agent 对话,并带上原始启动 flags(`claude --dangerously-skip-permissions --resume …``restore_agent_sessions`,默认开启)
- **Fork 会话** —— 直接调 agent 自己的 fork 命令(`codex fork <id>``claude --resume <id> --fork-session`OpenCode 和 Grok Build 同样支持),把当前对话分叉成一个独立会话;原会话原封不动,两边各自往下走。在 pane 上右键可选择分屏位置,在标签 / 侧栏行上右键则直接开新标签。需要先装好该 agent 的 hooksfork 认的是 hooks 上报的 session id);另外 fork 会整份复制对话历史,反复 fork 会在 agent 自己的会话目录里占掉不少磁盘
- **复制 Session ID** —— 把 agent 的原生 session id 复制到剪贴板,就在 *Copy Working Directory* 旁边,方便粘进 `codex resume`、bug 报告或别的工具
- **上下文回填** —— 面板命令把当前选区或仓库 `git diff` 打包成 prompt 直接喂给正在跑的 agent
- **托盘图标** —— 系统托盘 / 菜单栏常驻图标,任何 agent 等你输入时立即切换为提醒态;菜单列出所有 agent pane(品牌头像 + 状态点,点击直达)、可切换通知策略,并在保留会话的普通退出之外提供 *Quit and Stop Daemon*`show_tray_icon`,默认开启)
+16
View File
@@ -52,6 +52,22 @@ actions!(
CloseTabsToTheRight,
CopyWorkingDirectory,
MarkTabUnread,
// Branch the coding-agent session running in this tab into a second,
// independent one by shelling the agent's own fork command (issue
// #211). Placement follows where the user asked from: the bare action —
// menu bar, palette, a bound key — and the tab context menu open a new
// tab, while the pane right-click menu offers the four split directions
// below, since a pane-level ask is a spatial one.
ForkAgentSession,
ForkAgentSessionRight,
ForkAgentSessionLeft,
ForkAgentSessionDown,
ForkAgentSessionUp,
// Put the agent's *native* session id on the clipboard, beside "Copy
// Working Directory". Codex has no copy/duplicate subcommand, so
// "copy the session" means copying its id — paste it into `codex
// resume`, a bug report, or another tool.
CopyAgentSessionId,
SplitRight,
SplitDown,
FocusNextPane,
+48 -6
View File
@@ -12,7 +12,7 @@ use gpui::{
Window, actions, div, prelude::*, px,
};
use gpui_component::kbd::Kbd;
use gpui_component::menu::ContextMenuExt;
use gpui_component::menu::{ContextMenuExt, PopupMenuItem};
use gpui_component::{ActiveTheme as _, Icon, IconName, WindowExt as _, h_flex};
use super::TermSize;
@@ -26,7 +26,8 @@ use super::reverse_search::{self, ReverseSearch};
use super::search::{LinkTarget, SearchState};
use super::typeahead::{RawInput, Typeahead};
use crate::core::actions::{
CloseActiveTab, NewTab, SendBackTab, SendTab, SplitDown, SplitRight, ToggleMaximizePane,
CloseActiveTab, ForkAgentSessionDown, ForkAgentSessionLeft, ForkAgentSessionRight,
ForkAgentSessionUp, NewTab, SendBackTab, SendTab, SplitDown, SplitRight, ToggleMaximizePane,
};
use crate::core::config::{BellMode, Config, NotifyMode};
use crate::daemon::protocol::{RemoteContext, ShellSpec};
@@ -6200,6 +6201,18 @@ impl Render for TerminalView {
// selected in either the grid or the prompt editor.
let menu_focus = self.focus_handle.clone();
let has_selection = self.any_selection();
// Fork rows: offered only for agents tty7 has a verified fork command
// for, labelled with that agent's own word for it ("Fork Session" /
// "Branch Session"). A *pane*-level ask is a spatial one, so this menu
// asks where the fork goes; the tab menu, which has no pane in hand,
// just opens a new tab (issue #211). Disabled — not hidden — until the
// session id is known, so the capability stays discoverable when the
// agent's hooks aren't installed; a remote pane can't fork at all,
// since the fork command would run against the *local* agent.
let fork_label = self.agent().and_then(|a| a.fork_label());
let can_fork = fork_label.is_some()
&& self.remote_context().is_none()
&& self.agent_session().is_some_and(|s| s.session_id.is_some());
div()
.id("terminal-surface")
@@ -6284,7 +6297,7 @@ impl Render for TerminalView {
.children(reverse_search_menu)
.children(integration_notice)
// Right-click context menu (gpui-component PopupMenu).
.context_menu(move |menu, _window, _cx| {
.context_menu(move |menu, window, cx| {
// Default (26px) rows: with the flat full-bleed highlight (no
// floating pill, no inter-row gap) they read dense, not airy, and
// match the command palette's row height. A fixed min-width keeps
@@ -6296,7 +6309,8 @@ impl Render for TerminalView {
// We render the hint ourselves via `menu_row_with_hint` to keep the
// whole menu consistent, rather than register real bindings (which
// would risk the Ctrl+C SIGINT fall-through on Windows/Linux).
menu.min_w(px(220.))
let menu = menu
.min_w(px(220.))
.action_context(menu_focus.clone())
.menu_element_with_disabled(
Box::new(CopyText),
@@ -6323,8 +6337,36 @@ impl Render for TerminalView {
// auto-render its shortcut hint (correct per platform) like the
// items below, instead of a hand-rolled mac-only one.
.menu("Find…", Box::new(FindInTerminal))
.menu("Clear", Box::new(ClearScrollback))
.separator()
.menu("Clear", Box::new(ClearScrollback));
// The fork block. Its rows dispatch actions that `Tty7App`
// handles, so the submenu carries the same `action_context` as
// the parent — a submenu is a menu of its own and does not
// inherit it.
let menu = match fork_label {
Some(label) if can_fork => {
let focus = menu_focus.clone();
menu.separator()
.submenu(label, window, cx, move |submenu, _window, _cx| {
submenu
.action_context(focus.clone())
.menu("Split Right", Box::new(ForkAgentSessionRight))
.menu("Split Left", Box::new(ForkAgentSessionLeft))
.menu("Split Down", Box::new(ForkAgentSessionDown))
.menu("Split Up", Box::new(ForkAgentSessionUp))
})
}
// Forkable agent, but nothing to fork *from* yet (no
// session id) or the wrong machine (a remote pane). A flat
// disabled row rather than an empty submenu: there is no
// placement to pick when the fork itself can't run.
Some(label) => menu
.separator()
.item(PopupMenuItem::new(label).disabled(true)),
None => menu,
};
menu.separator()
.menu("Split Right", Box::new(SplitRight))
.menu("Split Down", Box::new(SplitDown))
.menu("Maximize Pane", Box::new(ToggleMaximizePane))
+309 -2
View File
@@ -710,6 +710,45 @@ pub(crate) enum SshCloseKind {
Pane,
}
/// Where a forked agent session lands. The placement is not a preference but a
/// consequence of *where the user asked from* (issue #211): a pane-level ask is
/// spatial, so the pane menu offers the four directions; a tab-level ask is
/// not, so the tab menu — and the bare action behind the palette / menu bar —
/// opens a new tab with no placement question.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum ForkPlacement {
NewTab,
/// Split the source pane along `axis`, the fork taking the second slot —
/// or the first when `before`, which is Split Left / Split Up.
Split {
axis: Axis,
before: bool,
},
}
/// What the agent-session menu rows need to know about a tab, read at
/// menu-open time (like `tab_cwd`) so enablement can't go stale between render
/// and click.
pub(crate) struct TabAgentSession {
/// The agent's own word for forking, or `None` when tty7 has no verified
/// fork command for it — then no fork row is offered at all, rather than a
/// disabled one promising a capability that doesn't exist.
pub(crate) fork_label: Option<&'static str>,
/// The agent's native session id, absent until its hooks report one.
pub(crate) session_id: Option<String>,
/// A remote pane. Forking shells a *local* agent binary, which would branch
/// the wrong machine's session, so the row disables there.
pub(crate) remote: bool,
}
impl TabAgentSession {
/// Whether a fork can actually run right now: the agent has a fork command,
/// tty7 has seen its session id, and the pane is local.
pub(crate) fn forkable(&self) -> bool {
self.fork_label.is_some() && self.session_id.is_some() && !self.remote
}
}
impl Tty7App {
/// A window on `id`'s workspace — reopening one from the picker — or on a
/// fresh workspace when `id` is `None` (New Workspace) or names a workspace
@@ -3176,7 +3215,10 @@ impl Tty7App {
}
};
if let Some(tab) = self.tabs.get_mut(self.active) {
if tab.pane.split_leaf(target.entity_id(), axis, new.clone()) {
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);
@@ -3686,6 +3728,217 @@ impl Tty7App {
}
}
/// What the tab's agent-session menu rows ("Fork Session" / "Branch
/// Session" and "Copy Session ID") need, or `None` when the tab's
/// label-driving pane runs no coding agent — then neither row is offered.
/// Reads the same leaf `tab_cwd` does, so all three rows agree on which
/// pane a tab-level action means.
pub(crate) fn tab_agent_session(
&self,
index: usize,
window: &Window,
cx: &App,
) -> Option<TabAgentSession> {
let leaf = self.tabs.get(index)?.pane.focused_or_first(window, cx)?;
let view = leaf.read(cx);
let agent = view.agent()?;
Some(TabAgentSession {
fork_label: agent.fork_label(),
session_id: view.agent_session().and_then(|s| s.session_id),
remote: view.remote_context().is_some(),
})
}
/// "Copy Session ID": put the agent's native session id on the clipboard —
/// the id `codex resume` / `claude --resume` take. A no-op when no agent
/// has reported one, which is also when the menu row renders disabled.
pub(crate) fn copy_agent_session_id(
&mut self,
index: usize,
window: &Window,
cx: &mut Context<Self>,
) {
if let Some(id) = self
.tab_agent_session(index, window, cx)
.and_then(|s| s.session_id)
{
cx.write_to_clipboard(gpui::ClipboardItem::new_string(id));
}
}
/// "Fork Session" (issue #211): branch the agent session in tab `index`'s
/// focused pane into a second, independent one, landing it per `placement`.
///
/// The fork itself is entirely the agent's own — tty7 spawns a pane and
/// types the agent's fork command into it (`codex fork <id>`, `claude
/// --resume <id> --fork-session`, …), exactly as session restore types a
/// resume command. tty7 never reads or writes the agent's transcript files,
/// so a change to their on-disk format costs at most a visible shell error
/// in the new pane.
///
/// Every reason a fork can't happen surfaces as a notification rather than
/// a silent no-op: the menu rows disable themselves for the same reasons,
/// but the action is also reachable from the palette, the menu bar and a
/// bound key, where there is no row to grey out.
pub(crate) fn fork_agent_session(
&mut self,
index: usize,
placement: ForkPlacement,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(source) = self
.tabs
.get(index)
.and_then(|t| t.pane.focused_or_first(window, cx))
else {
return;
};
let Some(cmd) = self.agent_fork_command(&source, window, cx) else {
return;
};
// A split acts on the *active* tab's focused pane, so bring the
// right-clicked tab forward first — a no-op when it already is, and the
// same order the context menu's own Split rows use. Done before the
// terminal is created, since constructing one steals focus.
if matches!(placement, ForkPlacement::Split { .. }) {
self.activate(index, window, cx);
}
// The fork inherits the source pane's directory and shell pick, like
// every other tty7 spawn. Deliberately *not* passed to the agent as a
// `--cd`: Codex has its own resume/fork cwd preference and this must
// not override the setting the user chose there.
let (cwd, shell) = {
let view = source.read(cx);
(view.local_cwd(), view.shell_spec())
};
let new = match new_terminal(
self.window_workspace(cx),
self.font_size,
cwd,
None,
shell,
window,
cx,
) {
Ok(view) => view,
Err(e) => {
log::error!("fork spawn failed: {e}");
window.push_notification(format!("Could not open a terminal: {e}"), cx);
return;
}
};
// Same hand-off session restore uses: the bytes queue in the PTY until
// the (still starting) shell reads them.
//
// A slot that is still connecting has no terminal to hand the command
// to. Forking gates on a *local* pane, so this is unreachable in
// practice — but say so rather than placing a pane that silently never
// forks.
let Some(terminal) = new.terminal() else {
log::error!("fork spawn produced a pane that is still connecting");
window.push_notification("Could not fork: the pane is still connecting", cx);
return;
};
terminal.read(cx).run_command_line(&cmd);
match placement {
ForkPlacement::NewTab => {
self.remember_active_pane(window, cx);
self.maximized = None;
let insert_at = self.new_tab_insert_at(cx);
self.tabs.insert(insert_at, Tab::new(Pane::leaf(new)));
self.active = insert_at;
self.focus_active(window, cx);
}
ForkPlacement::Split { axis, before } => {
let placed = self.tabs.get_mut(index).is_some_and(|tab| {
tab.pane
.split_leaf(source.entity_id(), axis, before, new.clone())
});
if !placed {
return;
}
self.maximized = None;
self.focus_leaf(&new, window, cx);
}
}
self.save_session(cx);
cx.notify();
}
/// Fork the active tab's focused pane into a split beside it — the pane
/// right-click menu's placement pick, and what a bound key means (the
/// focused pane is the one the user is pointing at).
pub(crate) fn fork_focused_pane_session(
&mut self,
axis: Axis,
before: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.fork_agent_session(
self.active,
ForkPlacement::Split { axis, before },
window,
cx,
);
}
/// The fork command to type into a new pane for `source`'s agent session,
/// or `None` after telling the user why there isn't one.
fn agent_fork_command(
&self,
source: &Entity<TerminalView>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<String> {
use crate::core::cli_agent::AgentStatus;
let view = source.read(cx);
let (agent, session, remote) = (view.agent(), view.agent_session(), view.remote_context());
let Some(agent) = agent else {
window.push_notification("This pane isn't running a coding agent", cx);
return None;
};
let name = agent.display_name();
if agent.fork_label().is_none() {
window.push_notification(format!("tty7 has no fork command for {name}"), cx);
return None;
}
if remote.is_some() {
window.push_notification(
format!("{name} sessions can only be forked from a local pane"),
cx,
);
return None;
}
let session = session.unwrap_or_default();
let Some(id) = session.session_id.as_deref() else {
window.push_notification(
format!("tty7 hasn't seen a {name} session id in this pane — install its hooks in Settings → Agents"),
cx,
);
return None;
};
let Some(cmd) = agent.fork_command(id, session.launch_argv.as_deref()) else {
window.push_notification(format!("{name}'s session id isn't a plain token"), cx);
return None;
};
// Agents fork from the *persisted* transcript, so a turn still in
// flight is simply absent from the fork (Codex documents that an
// in-progress turn cannot even be a fork point). Harmless — the parent
// is untouched — but the user must not have to discover it.
if session.status == AgentStatus::Working {
window.push_notification(
format!("{name} is mid-turn — the fork won't include the turn in flight"),
cx,
);
}
Some(cmd)
}
/// An explicit "check now", from the App menu or the tray. Forced, so it
/// works even with the startup check turned off — "I asked" outranks "don't
/// ask on my behalf" — and it opens About, where the result lands.
@@ -4154,6 +4407,10 @@ impl Tty7App {
CloseTabsToTheRight => self.close_tabs_right_of(self.active, window, cx),
CopyWorkingDirectory => self.copy_active_cwd(window, cx),
MarkTabUnread => self.mark_tab_unread(self.active, cx),
ForkAgentSession => {
self.fork_agent_session(self.active, ForkPlacement::NewTab, 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),
ShowKeyboardShortcuts => {
@@ -6496,6 +6753,27 @@ impl Render for Tty7App {
.on_action(cx.listener(|this, _: &MarkTabUnread, _window, cx| {
this.mark_tab_unread(this.active, cx)
}))
// Fork: the bare action has no pane the user pointed at, so it
// opens a new tab; the four directional ones come from the pane
// right-click menu, where the ask *was* spatial.
.on_action(cx.listener(|this, _: &ForkAgentSession, window, cx| {
this.fork_agent_session(this.active, ForkPlacement::NewTab, window, cx)
}))
.on_action(cx.listener(|this, _: &ForkAgentSessionRight, window, cx| {
this.fork_focused_pane_session(Axis::Horizontal, false, window, cx)
}))
.on_action(cx.listener(|this, _: &ForkAgentSessionLeft, window, cx| {
this.fork_focused_pane_session(Axis::Horizontal, true, window, cx)
}))
.on_action(cx.listener(|this, _: &ForkAgentSessionDown, window, cx| {
this.fork_focused_pane_session(Axis::Vertical, false, window, cx)
}))
.on_action(cx.listener(|this, _: &ForkAgentSessionUp, window, cx| {
this.fork_focused_pane_session(Axis::Vertical, true, window, cx)
}))
.on_action(cx.listener(|this, _: &CopyAgentSessionId, window, cx| {
this.copy_agent_session_id(this.active, window, cx)
}))
// Settings destinations that deserve their own way in: Help →
// Keyboard Shortcuts and the App menu's About both used to require
// opening Settings and then hunting for the section.
@@ -7243,7 +7521,10 @@ fn apply_ssh_o_option(
#[cfg(test)]
mod tests {
use super::{leaf_shares_the_window_daemon, parse_ssh_connect_input, parse_ssh_option_words};
use super::{
TabAgentSession, leaf_shares_the_window_daemon, parse_ssh_connect_input,
parse_ssh_option_words,
};
/// A remote window's saved layout can hold a native-SSH pane, whose russh
/// session runs in *this* client's daemon rather than the machine's. Its
@@ -7262,6 +7543,32 @@ mod tests {
assert!(leaf_shares_the_window_daemon(false, false));
}
// The single gate every fork surface consults. All three conditions have to
// hold: an agent with a verified fork command, a session id the hooks have
// reported, and a local pane — a remote one would shell the *local* agent
// and branch the wrong machine's session.
#[test]
fn a_fork_needs_a_command_an_id_and_a_local_pane() {
let session = |fork_label, session_id: Option<&str>, remote| TabAgentSession {
fork_label,
session_id: session_id.map(str::to_string),
remote,
};
assert!(session(Some("Fork Session"), Some("abc"), false).forkable());
assert!(
!session(None, Some("abc"), false).forkable(),
"an agent with no fork command is never forkable"
);
assert!(
!session(Some("Fork Session"), None, false).forkable(),
"no session id yet — the hooks haven't reported one"
);
assert!(
!session(Some("Fork Session"), Some("abc"), true).forkable(),
"a remote pane would fork the wrong machine's session"
);
}
#[test]
fn parses_ssh_option_words_with_quotes() {
assert_eq!(
+15
View File
@@ -126,6 +126,15 @@ pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> {
("CloseTabsToTheRight", ""),
("CopyWorkingDirectory", ""),
("MarkTabUnread", ""),
// Fork: the bare action opens a new tab; the four directional ones are
// the pane right-click menu's placement pick, bindable here for anyone
// who wants a chord straight to one direction.
("ForkAgentSession", ""),
("ForkAgentSessionRight", ""),
("ForkAgentSessionLeft", ""),
("ForkAgentSessionDown", ""),
("ForkAgentSessionUp", ""),
("CopyAgentSessionId", ""),
// No default chord on purpose: this is the one action that kills running
// sessions, and it must not sit one slip away from ⌘W. Reachable from
// the Shell menu and the palette; bindable in Settings for anyone who
@@ -549,6 +558,12 @@ fn make_binding(action: &str, keystroke: &str) -> Option<KeyBinding> {
"CloseTabsToTheRight" => KeyBinding::new(keystroke, CloseTabsToTheRight, None),
"CopyWorkingDirectory" => KeyBinding::new(keystroke, CopyWorkingDirectory, None),
"MarkTabUnread" => KeyBinding::new(keystroke, MarkTabUnread, None),
"ForkAgentSession" => KeyBinding::new(keystroke, ForkAgentSession, None),
"ForkAgentSessionRight" => KeyBinding::new(keystroke, ForkAgentSessionRight, None),
"ForkAgentSessionLeft" => KeyBinding::new(keystroke, ForkAgentSessionLeft, None),
"ForkAgentSessionDown" => KeyBinding::new(keystroke, ForkAgentSessionDown, None),
"ForkAgentSessionUp" => KeyBinding::new(keystroke, ForkAgentSessionUp, None),
"CopyAgentSessionId" => KeyBinding::new(keystroke, CopyAgentSessionId, None),
"SplitRight" => KeyBinding::new(keystroke, SplitRight, None),
"SplitDown" => KeyBinding::new(keystroke, SplitDown, None),
"FocusNextPane" => KeyBinding::new(keystroke, FocusNextPane, None),
+13
View File
@@ -76,6 +76,11 @@ pub enum CommandKind {
CloseTabsToTheRight,
CopyWorkingDirectory,
MarkTabUnread,
/// Branch this tab's agent session into a second, independent one, opened
/// in a new tab. The pane right-click menu offers the split placements;
/// the palette has no pane in hand, so it takes the tab-level meaning.
ForkAgentSession,
CopyAgentSessionId,
ResetFontSize,
NextPane,
PrevPane,
@@ -194,6 +199,8 @@ impl CommandKind {
CloseTabsToTheRight => "close-tabs-right",
CopyWorkingDirectory => "copy-cwd",
MarkTabUnread => "mark-tab-unread",
ForkAgentSession => "fork-agent-session",
CopyAgentSessionId => "copy-agent-session-id",
ResetFontSize => "reset-font-size",
NextPane => "next-pane",
PrevPane => "prev-pane",
@@ -294,6 +301,8 @@ impl CommandKind {
CloseTabsToTheRight => "CloseTabsToTheRight",
CopyWorkingDirectory => "CopyWorkingDirectory",
MarkTabUnread => "MarkTabUnread",
ForkAgentSession => "ForkAgentSession",
CopyAgentSessionId => "CopyAgentSessionId",
ResetFontSize => "ResetFontSize",
NextPane => "FocusNextPane",
PrevPane => "FocusPrevPane",
@@ -505,6 +514,10 @@ impl Command {
Command::new("Next Tab", NextTab),
Command::new("Previous Tab", PrevTab),
Command::new("Copy Working Directory", CopyWorkingDirectory),
Command::new("Copy Session ID", CopyAgentSessionId)
.with_subtitle("the coding agent's own session id"),
Command::new("Fork Session", ForkAgentSession)
.with_subtitle("branch this agent session into a new tab"),
Command::new("Mark Tab as Unread", MarkTabUnread),
Command::new("Close Pane / Tab", ClosePane),
Command::new("Close Other Tabs", CloseOtherTabs),
+59 -15
View File
@@ -225,21 +225,31 @@ impl<L: Clone> Pane<L> {
}
/// Split the first leaf matching `is_target` along `axis`, inserting `new`
/// as the second child. Returns whether a matching leaf was found.
fn split_leaf_where(&mut self, is_target: &impl Fn(&L) -> bool, axis: Axis, new: L) -> bool {
/// as the second child — or as the *first* when `before`, which is what
/// puts a pane to the left of / above its source rather than right of /
/// below it. Returns whether a matching leaf was found.
fn split_leaf_where(
&mut self,
is_target: &impl Fn(&L) -> bool,
axis: Axis,
before: bool,
new: L,
) -> bool {
match self {
Pane::Leaf(v) => {
if is_target(v) {
let old = v.clone();
*self = Pane::split_node(axis, 0.5, Pane::Leaf(old), Pane::Leaf(new));
let old = Pane::Leaf(v.clone());
let new = Pane::Leaf(new);
let (a, b) = if before { (new, old) } else { (old, new) };
*self = Pane::split_node(axis, 0.5, a, b);
true
} else {
false
}
}
Pane::Split { a, b, .. } => {
a.split_leaf_where(is_target, axis, new.clone())
|| b.split_leaf_where(is_target, axis, new)
a.split_leaf_where(is_target, axis, before, new.clone())
|| b.split_leaf_where(is_target, axis, before, new)
}
Pane::Empty => false,
}
@@ -568,11 +578,19 @@ impl Pane<PaneSlot> {
.position(|l| l.entity_id() == focused.entity_id())
}
/// Split a specific leaf (matched by entity identity) along `axis`, inserting
/// `new` as the second child. The target must be captured *before* creating
/// `new`, since constructing a terminal steals window focus.
pub fn split_leaf(&mut self, target: gpui::EntityId, axis: Axis, new: PaneSlot) -> bool {
self.split_leaf_where(&|v| v.entity_id() == target, axis, new)
/// Split a specific leaf (matched by entity identity) along `axis`,
/// inserting `new` as the second child — or the first when `before`, which
/// is how "Split Left" / "Split Up" differ from their opposites. The target
/// must be captured *before* creating `new`, since constructing a terminal
/// steals window focus.
pub fn split_leaf(
&mut self,
target: gpui::EntityId,
axis: Axis,
before: bool,
new: PaneSlot,
) -> bool {
self.split_leaf_where(&|v| v.entity_id() == target, axis, before, new)
}
/// Replace the leaf with entity id `target` with `new`, preserving the tree
@@ -818,7 +836,7 @@ mod tests {
/// the target was found.
fn split(pane: &mut TestPane, target: u32, axis: Axis, new: u32) {
assert!(
pane.split_leaf_where(&is(target), axis, new),
pane.split_leaf_where(&is(target), axis, false, new),
"split target {target} not found"
);
}
@@ -828,7 +846,7 @@ mod tests {
#[test]
fn split_leaf_replaces_target_with_split_keeping_original_first() {
let mut pane = TestPane::leaf(0);
assert!(pane.split_leaf_where(&is(0), Axis::Horizontal, 1));
assert!(pane.split_leaf_where(&is(0), Axis::Horizontal, false, 1));
match &pane {
Pane::Split {
axis, a, b, ratio, ..
@@ -843,6 +861,32 @@ mod tests {
assert_well_formed(&pane);
}
// `before` is what makes "Split Left" / "Split Up" differ from their
// opposites: same axis, the new pane just takes the first slot. Only the
// targeted leaf moves — its siblings keep their order.
#[test]
fn split_leaf_before_puts_the_new_pane_first() {
// [0 | 1] -> split 1 horizontally with 2, before -> [0 | [2 | 1]]
let mut pane = TestPane::leaf(0);
split(&mut pane, 0, Axis::Horizontal, 1);
assert!(pane.split_leaf_where(&is(1), Axis::Horizontal, true, 2));
assert_eq!(pane.leaves(), vec![0, 2, 1]);
match &pane {
Pane::Split { a, b, .. } => {
assert!(matches!(**a, Pane::Leaf(0)), "sibling must not move");
match &**b {
Pane::Split { a, b, .. } => {
assert!(matches!(**a, Pane::Leaf(2)));
assert!(matches!(**b, Pane::Leaf(1)));
}
_ => panic!("targeted leaf should have become a nested split"),
}
}
_ => panic!("root should still be the original horizontal split"),
}
assert_well_formed(&pane);
}
// A split must land on exactly the targeted leaf, leaving every other
// subtree untouched (guards against splitting the first leaf found).
#[test]
@@ -879,7 +923,7 @@ mod tests {
fn split_leaf_reports_missing_target_without_changing_tree() {
let mut pane = TestPane::leaf(0);
split(&mut pane, 0, Axis::Horizontal, 1);
assert!(!pane.split_leaf_where(&is(99), Axis::Vertical, 2));
assert!(!pane.split_leaf_where(&is(99), Axis::Vertical, false, 2));
assert_eq!(pane.leaves(), vec![0, 1]);
assert_well_formed(&pane);
}
@@ -1149,7 +1193,7 @@ mod tests {
let mut pane: TestPane = Pane::Empty;
assert!(pane.leaves().is_empty());
assert_eq!(pane.first_leaf(), None);
assert!(!pane.split_leaf_where(&is(0), Axis::Horizontal, 1));
assert!(!pane.split_leaf_where(&is(0), Axis::Horizontal, false, 1));
assert!(matches!(
pane.close_leaf_where(&is(0)),
CloseOutcome::NotFound
+51
View File
@@ -1010,6 +1010,40 @@ impl Tty7App {
}));
}
// Fork / Branch Session — the same kind of operation as New Worktree
// Tab (spin a parallel line of work off this one), so it sits in the
// same block. A *tab*-level ask carries no placement question, so it
// lands in a new tab; the pane right-click menu is where the split
// directions live (issue #211). Offered only for agents tty7 has a
// verified fork command for, labelled with that agent's own word for
// it; disabled — not hidden — while the session id is still unknown,
// so the capability stays discoverable when the hooks aren't installed.
let agent_session = this.tab_agent_session(index, window, cx);
if let Some(session) = &agent_session
&& let Some(label) = session.fork_label
{
// Open the block ourselves when the worktree row above didn't
// (this tab's cwd isn't in a repo), so the row never glues onto
// "Mark as Unread".
if !in_repo {
menu = menu.separator();
}
let forkable = session.forkable();
menu = menu.item(PopupMenuItem::new(label).disabled(!forkable).on_click({
let app = app.clone();
move |_, window, cx| {
let _ = app.update(cx, |this, cx| {
this.fork_agent_session(
index,
crate::ui::app::ForkPlacement::NewTab,
window,
cx,
)
});
}
}));
}
// Splits act on the right-clicked tab: activate it first (a no-op when
// it already is), then split its focused pane — one code path with the
// keyboard actions.
@@ -1046,6 +1080,23 @@ impl Tty7App {
}),
);
// Copy Session ID, beside Copy Working Directory — the agent's own
// native id, the one its `--resume` takes. Offered for every agent tab
// (there is nothing agent-specific about an id) and disabled until one
// has been reported. The id is read here at open time, so the row can't
// copy a stale one.
if let Some(session_id) = agent_session.map(|s| s.session_id) {
menu = menu.item(
PopupMenuItem::new("Copy Session ID")
.disabled(session_id.is_none())
.on_click(move |_, _window, cx| {
if let Some(id) = session_id.as_ref() {
cx.write_to_clipboard(gpui::ClipboardItem::new_string(id.clone()));
}
}),
);
}
menu.separator()
.item(PopupMenuItem::new("Close Tab").on_click({
let app = app.clone();
+2
View File
@@ -72,6 +72,8 @@ pub(crate) fn set_menus(cx: &mut App) {
MenuItem::separator(),
MenuItem::action("Rename Tab…", RenameTab),
MenuItem::action("Copy Working Directory", CopyWorkingDirectory),
MenuItem::action("Copy Session ID", CopyAgentSessionId),
MenuItem::action("Fork Session", ForkAgentSession),
MenuItem::separator(),
MenuItem::action("Close Pane / Tab", CloseActiveTab),
MenuItem::action("Close Other Tabs", CloseOtherTabs),