diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..692692a3 --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index f987e91e..d6740848 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `, `claude --resume + --fork-session`, `opencode --session --fork`, `grok --resume + --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 diff --git a/crates/tty7-core/src/core/cli_agent.rs b/crates/tty7-core/src/core/cli_agent.rs index 94a7fc35..b645a1e1 100644 --- a/crates/tty7-core/src/core/cli_agent.rs +++ b/crates/tty7-core/src/core/cli_agent.rs @@ -173,33 +173,14 @@ impl CLIAgent { session_id: &str, launch_argv: Option<&[String]>, ) -> Option { - // 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 { + 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 { + 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 `: 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 ` — or `codex fork `, 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::>(); + + // 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); diff --git a/docs/features.md b/docs/features.md index 8eccfa0b..66ab2dca 100644 --- a/docs/features.md +++ b/docs/features.md @@ -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 `, `claude --resume --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) diff --git a/docs/features.zh-CN.md b/docs/features.zh-CN.md index 83b79b09..54eae2b4 100644 --- a/docs/features.zh-CN.md +++ b/docs/features.zh-CN.md @@ -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 `、`claude --resume --fork-session`,OpenCode 和 Grok Build 同样支持),把当前对话分叉成一个独立会话;原会话原封不动,两边各自往下走。在 pane 上右键可选择分屏位置,在标签 / 侧栏行上右键则直接开新标签。需要先装好该 agent 的 hooks(fork 认的是 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`,默认开启) diff --git a/src/core/actions.rs b/src/core/actions.rs index 27892395..59190321 100644 --- a/src/core/actions.rs +++ b/src/core/actions.rs @@ -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, diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 23b89317..74fa4a38 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -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)) diff --git a/src/ui/app.rs b/src/ui/app.rs index 79dfee80..90b09c16 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -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, + /// 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 { + 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, + ) { + 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 `, `claude + /// --resume --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, + ) { + 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.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, + window: &mut Window, + cx: &mut Context, + ) -> Option { + 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!( diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs index 4a3ec415..484e5e2c 100644 --- a/src/ui/keymap.rs +++ b/src/ui/keymap.rs @@ -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 { "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), diff --git a/src/ui/palette.rs b/src/ui/palette.rs index 3e87b69e..7c1f2bb4 100644 --- a/src/ui/palette.rs +++ b/src/ui/palette.rs @@ -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), diff --git a/src/ui/pane.rs b/src/ui/pane.rs index 577fd05e..0d13cdd2 100644 --- a/src/ui/pane.rs +++ b/src/ui/pane.rs @@ -225,21 +225,31 @@ impl Pane { } /// 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 { .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 diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs index 12dd7224..980a2034 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -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(); diff --git a/src/ui/theme.rs b/src/ui/theme.rs index 9737d48f..ba3a6bf5 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -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),