mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 08:02:24 +00:00
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:
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 的 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`,默认开启)
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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!(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user