diff --git a/CHANGELOG.md b/CHANGELOG.md index 85714799..426b3eed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,63 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Quick launch for the coding agents on your PATH** (#955). Every agent tty7 + recognises whose binary is on `PATH` is a palette command — "Agent: Claude + Code", "Agent: Codex", … — ordered by how often and how recently it was + launched or seen running, and each is bindable as `LaunchAgent:`. "New + Agent Tab" (⌘⇧A on macOS, unbound elsewhere, where Ctrl+Shift+A is + select-all) starts the one used last, and the New Tab menu's "Launch Agent…" + row opens the palette on the list. A launch always opens a new tab in the + current tab's directory and types the agent's command into its shell once the + pane exists, never into a pane that was already there, so detection, status + and resume work as for a hand-typed agent and quitting it returns to the + shell. The command is the agent's bare binary unless `agent_launch` in + `config.json` gives it one (`"claude": "claude --dangerously-skip-permissions"`); + a wrapper named there is detected as that agent without an `agent_commands` + entry, including a script run under its interpreter, and the daemon picks up + an edit without a restart. A running agent's pane menu has "Set Current Launch + Args as Default", which writes the flags it was started with — minus the + session it resumed and any prompt — into `agent_launch`. A remote workspace + cannot be asked for its `PATH`, so there the list is the agents already seen + running in that workspace. + +- **Tabs can be put to sleep, and woken where they were** (#762). Right-click a + tab → Hibernate, or "Hibernate Tab" in the command palette, stops every + process in the tab to give its memory back while the tab keeps its place in + the sidebar, faded and marked with a moon. Selecting it wakes it — or + right-click → Wake to warm it up without switching. Waking is the restore a + reboot already runs: each pane comes back in its old directory and shell, + opens on the screen it left, and a supported agent with a captured session + is resumed. A sleeping tab stays asleep across app and daemon restarts; the + tab on screen is always awake, so the last awake tab cannot be put to sleep. + `tty7 pane ls` shows a sleeping tab's panes as `asleep`, and `tab ls`/`pane + ls --json` carry a `hibernated` flag. Offered only where the machine's server + can do it (this build or newer). + +- **A port forward can be switched off without losing its rule** (#439). A + forward could only be removed, so pointing one local port at a different + remote target meant deleting the rule and typing the other one in again. Each + forward in the Ports section, and each rule under Settings → SSH → Port + forwarding, now has a switch: off releases the listener and keeps the rule in + the list, on binds it again from the rule it was made from. Switching one on + while another switched-on forward holds its port is refused with a notice + naming that forward, rather than failing at bind time. A switch flipped in the + panel on a rule that came from a saved host is written back to that host, so + the next connection opens the same set; a rule saved switched off is listed + but not opened. Profiles saved before this load with every rule on. + +- **SSH tabs can be named after the host instead of whatever the remote shell + titles itself** (#726). **Settings → Window & Tabs → SSH tab title** is + *Dynamic* (the default, and what tty7 always did), *Profile name* — the saved + host's name, the alias for a `~/.ssh/config` host, the address typed for a + quick connect — or *Hostname*, the address dialled. Only the tab's name is + pinned: OSC 0/2 titles are still tracked and come back the moment it is + *Dynamic* again, a tab you renamed keeps its name, a split tab follows the + pane in front as before, and an ended session still says so. The key is + `ssh_tab_title` (`dynamic`, `profile-name`, `hostname`). + ### Changed - **The sidebar groups tabs by repo automatically; pin what you want to keep** @@ -29,6 +86,65 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 grouping* choice; off, unpinned tabs sit in one flat list under the pinned groups. Scratch is now **Ungrouped**. +- **The New Tab menu names three shells, not every one the machine has.** A + stock macOS box reports nine, so the `+` menu opened on a column of `csh`, + `tcsh` and `ksh` that almost nobody runs, above the SSH hosts people came for. + The Local section now lists the default shell, always first, and then only + shells that have actually been opened, by frecency — three rows at most, the + way the SSH section already caps its hosts. The rest sit behind an "Other + Shells…" row that opens the command palette filtered to them: every shell is + now a palette command, "Shell: {name}", and running one does what its menu + row does, splitting instead when ⌥ (Alt) is held. The row is left out when + the menu already names the whole list. + +- **Saved SSH hosts live in `servers.json`, beside `config.json`** (#911), so + `config.json` can be synced between machines for its colours and keys + without carrying a list of servers. `ssh_profiles` and + `ssh_profile_frecency` move across by themselves the first time a new build + reads an older `config.json`: `servers.json` is written first (mode `0600`), + and only once it has landed are the two keys taken out of `config.json` — + nothing else in that file is touched, including keys this build does not + know. When both files hold hosts, `servers.json` wins and the stale copy in + `config.json` is dropped at its next save. A `servers.json` that cannot be + parsed is kept aside as `servers.json.corrupt` and saving is refused until it + is repaired, the same rule `config.json` has. Hand edits to `servers.json` + hot-reload. Passwords and passphrases stay in the OS keychain. + +- **`tty7 exec` runs a command in a pane that already exists and hands back its + result** (#839). `run` makes a new pane and `send` does not wait, so every + script that wanted a command's exit code from a shell it already had ended up + as `send`, a sleep, a `capture` and a guess, or an `echo $? > /tmp/rc` side + channel. `tty7 exec %3 -- cargo test` types the line at the pane's prompt, + follows the shell integration's marks to the command's end, prints what it + printed — as text, the way `capture --plain` reads it, or escapes intact with + `--raw` — and exits with its exit code. `--timeout` gives up with 124 and + leaves the command running; a pane with no prompt marks, or one that is not + at a prompt, is refused before anything is typed instead of waited on. + +- **`tty7 send` takes its text from stdin or a file, and can paste it** + (#838). A token passed as `send %1 "$TOKEN"` sits in the CLI's command line, + where any local user reads it in `ps`, and in the caller's shell history; + `--stdin` and `--from-file` send the bytes exactly as read and never echo + them in `--json`. `--paste` sends the text the way a paste into the window + does — in bracketed paste when the pane has switched it on, so a multi-line + text arrives as text instead of running line by line, with any ESC stripped + so the text cannot close the paste itself. A pane without the mode gets the + same unframed paste the GUI would send it, and `--json` says which one went. + The server now reports each pane's bracketed-paste mode in `tty7 procs`. + +- **The source control panel's changed files can be filtered and shown as a + tree** (#473). A filter field sits above the list: every word typed has to + appear somewhere in a file's path, in any order and any case, and the group + headers' counts and their stage/unstage/discard-all buttons follow what the + filter leaves on screen. The tile at its end switches the list to a + directory tree — directories before files, a chain of directories that hold + nothing but the next one compacted into a single row (`crates/core/src`), + each directory row foldable and counting the files beneath it. A filter + opens every folded directory while it is set. The choice between list and + tree is remembered in the config. Both are built from the status the host + already sends, so a remote repository gets them unchanged. Searching the + diff text itself is not part of this. + ### Removed - **The *By repo or folder* grouping mode, "Group Automatically", and groups @@ -38,6 +154,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 moves to v11, so each remote host needs one Update Server, which ends the sessions on it. +### Fixed + +- **Nerd Font icons from a fallback font come out at the text's size** (#866). + With a Nerd Font icon face such as Symbols Nerd Font Mono behind a primary + that lacks the icons, an icon followed by a space on the same background — + every icon in a coloured Powerline or p10k segment — was held to one cell + and shrunk to about two thirds of the text's height. Icons with a plain + space after them were already drawn full size, so one prompt had icons in + two sizes. A lone Private Use Area glyph supplied by a fallback face is now + fitted, aspect ratio kept, to its cells and one em of height, taking the + blank after it when that blank paints no background or the icon's own and + when doing so makes it bigger, and centred in the row; a face whose icons + ink less than a cell grows (at most 2×). The Powerline separators, CJK, + emoji, other text and the primary font's own icons are drawn as before. + Not on Linux, where the text system reports a glyph's advance box rather + than its ink. + ## [26.9.3] - 2026-09-23 ### Added diff --git a/README.md b/README.md index 83466bbb..74d6f4ac 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,8 @@ that tells tty7 which session to fork. None of them are wrapped or proxied — the agent you start is the agent you get, in a normal PTY, with its own interface. An agent launched through a wrapper script can be mapped to one by name with `agent_commands` in `config.json`. +Every agent on your `PATH` is also a palette command (**Agent: Claude Code**, …) +that opens it in a new tab; `agent_launch` sets the command line it starts with. ## Documentation diff --git a/README.zh-CN.md b/README.zh-CN.md index 67a78a13..571937ec 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -96,6 +96,7 @@ macOS、Windows、Linux 的原生构建都在 [**Releases**](https://github.com/ tty7 不包装、不代理其中任何一个 —— 你启动的就是那个 agent 本身,运行在普通 PTY 中,界面仍然是它自己的。 如果你通过 wrapper 脚本启动 agent,在 `config.json` 的 `agent_commands` 里把脚本名映射到对应 agent 即可。 +PATH 上的每个 agent 也都是一条命令面板命令(**Agent:Claude Code** 等),会在新标签页中启动它;`agent_launch` 可以设置它的启动命令行。 ## 文档 diff --git a/assets/icons/list-flat.svg b/assets/icons/list-flat.svg new file mode 100644 index 00000000..b6249b13 --- /dev/null +++ b/assets/icons/list-flat.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/list-tree.svg b/assets/icons/list-tree.svg new file mode 100644 index 00000000..04086a1a --- /dev/null +++ b/assets/icons/list-tree.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/power.svg b/assets/icons/power.svg new file mode 100644 index 00000000..16fc83f7 --- /dev/null +++ b/assets/icons/power.svg @@ -0,0 +1 @@ + diff --git a/crates/tty7-cli/src/backend.rs b/crates/tty7-cli/src/backend.rs index 448cfc66..7807524d 100644 --- a/crates/tty7-cli/src/backend.rs +++ b/crates/tty7-cli/src/backend.rs @@ -61,6 +61,17 @@ pub trait Backend { fn run_wait(&mut self) -> Result>; + /// Type `line` at the pane's shell prompt and follow the pane until its + /// prompt comes back, the deadline passes, or it exits. Refuses — sending + /// nothing — a pane with no shell integration or one that is not at a + /// prompt; see [`ExecEnd`](crate::exec::ExecEnd). + fn exec( + &mut self, + pane: u64, + line: Vec, + timeout: Option, + ) -> Result; + fn events(&mut self, on_event: &mut dyn FnMut(ControlEvent) -> Result<()>) -> Result<()>; } @@ -78,6 +89,7 @@ pub mod mock { use tty7_core::daemon::protocol::{PROTOCOL_VERSION, PaneInfo, PaneProcs}; use super::{Backend, CaptureSegment, RunSpec}; + use crate::exec::{ExecEnd, ExecRun}; pub struct MockBackend { pub machine: Machine, @@ -103,6 +115,8 @@ pub mod mock { pub events: Vec, /// Set to make `hello` fail — doctor's unreachable-server branch. pub unreachable: bool, + pub execs: Vec<(u64, Vec, Option)>, + pub exec_run: ExecRun, } impl Default for MockBackend { @@ -127,6 +141,21 @@ pub mod mock { run_exit: Some(0), events: Vec::new(), unreachable: false, + execs: Vec::new(), + exec_run: ExecRun { + end: ExecEnd::Finished { + exit: Some(0), + ran: true, + }, + output: Vec::new(), + dropped: 0, + size: tty7_core::daemon::protocol::WinSize { + cols: 80, + rows: 24, + cell_w: 8, + cell_h: 16, + }, + }, } } } @@ -219,6 +248,16 @@ pub mod mock { Ok(self.run_exit) } + fn exec( + &mut self, + pane: u64, + line: Vec, + timeout: Option, + ) -> Result { + self.execs.push((pane, line, timeout)); + Ok(self.exec_run.clone()) + } + fn events(&mut self, on_event: &mut dyn FnMut(ControlEvent) -> Result<()>) -> Result<()> { for event in self.events.drain(..) { on_event(event)?; diff --git a/crates/tty7-cli/src/backend/real.rs b/crates/tty7-cli/src/backend/real.rs index 9447eac6..8723d2ea 100644 --- a/crates/tty7-cli/src/backend/real.rs +++ b/crates/tty7-cli/src/backend/real.rs @@ -13,6 +13,7 @@ use tty7_core::daemon::protocol::{DaemonMsg, PaneInfo, PaneProcs, ShellSpec, Win use tty7_core::daemon::router::RouteTarget; use super::{Backend, CaptureSegment, RunSpec}; +use crate::exec::{ExecEnd, ExecRun, Transcript}; const SESSION_SIZE: WinSize = WinSize { cols: 120, @@ -23,6 +24,9 @@ const SESSION_SIZE: WinSize = WinSize { const REPLAY_FIRST_WAIT: Duration = Duration::from_secs(10); const REPLAY_SETTLE: Duration = Duration::from_millis(300); +/// A backstop on reading the replay: it ends on the first live output or a +/// quiet spell, and this only bounds a daemon that keeps sending status. +const EXEC_REPLAY_MAX: Duration = Duration::from_secs(10); const NOT_RUNNING: &str = "could not reach the tty7 server on this machine — `tty7 server start` brings one up"; @@ -275,6 +279,113 @@ impl Backend for RealBackend { Ok(code) } + fn exec(&mut self, pane: u64, line: Vec, timeout: Option) -> Result { + let deadline = timeout.and_then(|t| std::time::Instant::now().checked_add(t)); + // Watching starts before anything is typed, so no byte the command + // prints can go past unseen. The replay that comes first is only read + // for the pane's state — the daemon sends its prompt report last. + let mut session = self + .pane_client()? + .observe(pane, SESSION_SIZE) + .with_context(|| format!("observing pane %{pane}"))?; + let mut size = SESSION_SIZE; + // (integration active, at a prompt), as the daemon last reported them. + let mut prompt: Option<(bool, bool)> = None; + let until = std::time::Instant::now() + EXEC_REPLAY_MAX; + let _ = session.set_recv_timeout(Some(REPLAY_FIRST_WAIT)); + let finish = |session: PaneSession, end, size| -> Result { + let _ = session.detach(); + Ok(ExecRun { + end, + output: Vec::new(), + dropped: 0, + size, + }) + }; + loop { + match session.recv() { + Ok(DaemonMsg::Size(seen)) => size = seen, + Ok(DaemonMsg::Prompt { + active, at_prompt, .. + }) => prompt = Some((active, at_prompt)), + Ok(DaemonMsg::Exited { code }) => { + return finish(session, ExecEnd::PaneExited(code), size); + } + // Live output: the replay is over, and so is `capture`'s + // reading of it. A pane still printing is not at a prompt, + // and its report has already said so. + Ok(DaemonMsg::Output(_)) => break, + Ok(_) => {} + Err(e) if timed_out(&e) => break, + Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => { + return finish(session, ExecEnd::PaneExited(None), size); + } + Err(e) => return Err(anyhow!(e).context("reading the pane replay")), + } + if std::time::Instant::now() >= until { + break; + } + let _ = session.set_recv_timeout(Some(REPLAY_SETTLE)); + } + match prompt { + Some((true, true)) => {} + Some((true, false)) => return finish(session, ExecEnd::Busy, size), + _ => return finish(session, ExecEnd::NoIntegration, size), + } + + self.send_input(pane, line)?; + let mut transcript = Transcript::new(); + let end = loop { + let wait = match deadline { + Some(d) => { + let left = d.saturating_duration_since(std::time::Instant::now()); + if left.is_zero() { + break ExecEnd::TimedOut { + started: transcript.started(), + }; + } + Some(left) + } + None => None, + }; + let _ = session.set_recv_timeout(wait); + match session.recv() { + Ok(DaemonMsg::Output(bytes)) => { + transcript.output(&bytes); + transcript.compact(); + } + Ok(DaemonMsg::Size(seen)) => size = seen, + Ok(DaemonMsg::Prompt { + at_prompt, + last_exit, + .. + }) => { + if let Some(done) = transcript.prompt(at_prompt, last_exit) { + break ExecEnd::Finished { + exit: done.exit, + ran: done.ran, + }; + } + } + Ok(DaemonMsg::Exited { code }) => break ExecEnd::PaneExited(code), + Ok(_) => {} + Err(e) if timed_out(&e) => continue, + Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => { + break ExecEnd::PaneExited(None); + } + Err(e) => return Err(anyhow!(e).context("following the command's output")), + } + }; + let _ = session.detach(); + let (output, dropped) = transcript.take(); + Ok(ExecRun { + end, + output, + dropped, + size, + }) + } + fn events(&mut self, on_event: &mut dyn FnMut(ControlEvent) -> Result<()>) -> Result<()> { let client = self.control_client()?; for event in client.events() { diff --git a/crates/tty7-cli/src/cli.rs b/crates/tty7-cli/src/cli.rs index 7cfd0b73..368d3be2 100644 --- a/crates/tty7-cli/src/cli.rs +++ b/crates/tty7-cli/src/cli.rs @@ -72,11 +72,35 @@ pub enum Command { // written out here: a hand-copied vocabulary drifts the first time a key // is added, and this is the text a caller reaches for to learn the names. #[command( - about = "Type text into a pane, or send it keystrokes with --key", + about = "Type text into a pane — from an argument, --stdin or --from-file — or send \ + it keystrokes with --key", long_about = crate::keys::send_long_help() )] Send(SendArgs), + // `run` is the other half of this pair, and the split is docker's: `run` + // makes a new place for a command, `exec` uses one that already exists. + // Not a flag on `run` — that verb execs an argv in a fresh pty, while this + // types a line into a shell that is already sitting at its prompt, and + // none of `run`'s flags (`--keep`, `--cwd`, `--ws`) mean anything here. + #[command( + about = "Run a command line in an existing pane's shell, wait for its prompt to \ + come back, print what the command printed, and exit with its exit code", + long_about = "Run a command line in an existing pane's shell, wait for its prompt to \ + come back, print what the command printed, and exit with its exit code.\n\n\ + The line is typed at the shell's prompt, exactly as `send … --enter` \ + would, so it runs with that shell's cwd, environment and history. The \ + pane's shell integration is what says where the command's output starts \ + and ends and what its exit code was: a pane with none (no OSC 133 prompt \ + marks yet) is refused rather than waited on, and so is a pane that is not \ + at a prompt. The output is what the pane printed between the command \ + starting and the prompt coming back, replayed through a terminal to \ + text the way `capture --plain` does — or as raw bytes with `--raw`.\n\n\ + A line the shell never runs — an unclosed quote waiting on a continuation \ + prompt — has no end to wait for; pass --timeout in anything unattended." + )] + Exec(ExecArgs), + #[command( about = "Print a pane's output — as text with `--plain`, otherwise with its ANSI \ escapes intact, decoded as UTF-8 (invalid bytes become U+FFFD): the \ @@ -158,6 +182,47 @@ pub struct RunArgs { pub cmd: Vec, } +#[derive(Debug, Args)] +pub struct ExecArgs { + #[arg( + value_name = "%PANE", + help = "Pane whose shell runs the command; defaults to $TTY7_PANE inside a tty7 shell" + )] + pub target: Option, + + #[arg( + long, + value_name = "SECS", + help = "Give up after this many seconds, with exit code 124; the command is left \ + running in the pane" + )] + pub timeout: Option, + + // Text is the default here, unlike `capture`: what a caller of this verb + // wants is the answer, and the raw bytes between the marks are not only + // the command's — zsh draws its PROMPT_SP (the `%` that marks a missing + // newline, then a row of spaces and a CR) *before* its precmd hooks run, + // so before the `D` mark. Replayed through a grid that erases itself, as + // it does on screen; left raw it is a line of noise after every command. + #[arg( + long, + help = "Print the output as the pane received it, escapes intact, instead of \ + replaying it through a terminal to text (the `capture` default)" + )] + pub raw: bool, + + // Joined with spaces and handed to the pane's shell as one line, which is + // what separates this from `run`: `tty7 exec %3 -- 'make && ls'` means the + // shell's `&&`, where `run` would look for a program called `make && ls`. + #[arg( + last = true, + required = true, + value_name = "CMD", + help = "The command line, after `--`: tty7 exec %3 -- cargo test" + )] + pub cmd: Vec, +} + #[derive(Debug, Args)] #[command(group = ArgGroup::new("axis").required(true))] pub struct SplitArgs { @@ -221,6 +286,34 @@ pub struct SendArgs { (C-c, escape, up, enter, …). See `tty7 send --help`" )] pub keys: Vec, + + // The other place the text can come from. A secret in an argument is in + // this process's command line, where every local user can read it in + // `ps`, and in the caller's own shell history; reading it from a pipe or a + // file keeps it out of both. The bytes go to the pty exactly as read. + #[arg( + long, + conflicts_with_all = ["from_file", "second"], + help = "Send what stdin holds, byte for byte, instead of TEXT — keeps a secret \ + out of `ps` and your shell history" + )] + pub stdin: bool, + + #[arg( + long, + value_name = "PATH", + conflicts_with = "second", + help = "Send the file's contents, byte for byte, instead of TEXT" + )] + pub from_file: Option, + + #[arg( + long, + help = "Send the text as a paste, the way the GUI pastes: framed in bracketed \ + paste when the pane has switched it on, so the lines of a multi-line text \ + arrive as text rather than each running on its own" + )] + pub paste: bool, } /// One resting place a `wait` can end on. Three ontologies meet here, which is @@ -945,4 +1038,54 @@ mod tests { let err = Cli::try_parse_from(["tty7", "tab", "move", "@7", "not-a-number"]).unwrap_err(); assert_eq!(err.exit_code(), 2); } + + #[test] + fn exec_takes_an_address_then_the_command_after_the_separator() { + let Some(Command::Exec(args)) = parse(&[ + "tty7", + "exec", + "%3", + "--timeout", + "5", + "--", + "make", + "--raw", + ]) + .command + else { + panic!("exec did not parse"); + }; + assert_eq!(args.target.as_deref(), Some("%3")); + assert_eq!(args.timeout, Some(5)); + assert!(!args.raw, "after `--` a flag belongs to the command"); + assert_eq!(args.cmd, ["make", "--raw"]); + + let Some(Command::Exec(args)) = parse(&["tty7", "exec", "--", "ls"]).command else { + panic!("exec without an address did not parse"); + }; + assert_eq!(args.target, None, "the pane then comes from $TTY7_PANE"); + + let err = Cli::try_parse_from(["tty7", "exec", "%3"]).unwrap_err(); + assert_eq!(err.exit_code(), 2, "a command is required"); + } + + #[test] + fn send_reads_its_text_from_one_place() { + let Some(Command::Send(args)) = + parse(&["tty7", "send", "%3", "--stdin", "--paste", "--enter"]).command + else { + panic!("send --stdin did not parse"); + }; + assert!(args.stdin && args.paste && args.enter); + assert_eq!(args.first.as_deref(), Some("%3")); + + for bad in [ + vec!["tty7", "send", "%3", "text", "--stdin"], + vec!["tty7", "send", "%3", "text", "--from-file", "f"], + vec!["tty7", "send", "%3", "--stdin", "--from-file", "f"], + ] { + let err = Cli::try_parse_from(&bad).unwrap_err(); + assert_eq!(err.exit_code(), 2, "{bad:?} should be a usage error"); + } + } } diff --git a/crates/tty7-cli/src/commands.rs b/crates/tty7-cli/src/commands.rs index 02b47d1c..45198d3f 100644 --- a/crates/tty7-cli/src/commands.rs +++ b/crates/tty7-cli/src/commands.rs @@ -11,9 +11,10 @@ use tty7_core::daemon::protocol::PROTOCOL_VERSION; use crate::address::{self, Context, WorkspaceAddress}; use crate::backend::{Backend, RunSpec}; use crate::cli::{ - CaptureArgs, Cli, Command, MachineCmd, PaneCmd, RunArgs, SendArgs, ServerCmd, SplitArgs, - TabCmd, WaitArgs, WaitState, WsCmd, + CaptureArgs, Cli, Command, ExecArgs, MachineCmd, PaneCmd, RunArgs, SendArgs, ServerCmd, + SplitArgs, TabCmd, WaitArgs, WaitState, WsCmd, }; +use crate::exec::ExecEnd; use crate::output; use crate::resolve; use crate::screen; @@ -78,6 +79,7 @@ pub fn execute(cli: Cli, ctx: &Context, backend: &mut dyn Backend) -> Result send(args, ctx, backend), + Some(Command::Exec(args)) => exec(args, ctx, backend), Some(Command::Capture(args)) => capture(args, ctx, backend), Some(Command::Procs { target }) => procs(target.as_deref(), ctx, backend), Some(Command::Tab(TabCmd::Ls { ws })) => tab_ls(ws.as_deref(), ctx, backend), @@ -486,8 +488,6 @@ fn pane_split(args: SplitArgs, ctx: &Context, backend: &mut dyn Backend) -> Resu } fn send(args: SendArgs, ctx: &Context, backend: &mut dyn Backend) -> Result { - const KEY_GAP: Duration = Duration::from_millis(200); - // `--enter` is the same thing as `--key enter`, and predates it. Keeping it // as sugar rather than deprecating it: it reads better for the overwhelming // case, which is typing one command and running it. Going through the same @@ -500,6 +500,43 @@ fn send(args: SendArgs, ctx: &Context, backend: &mut dyn Backend) -> Result { + std::fs::read(path).with_context(|| format!("reading {}", path.display()))? + } + None => { + let mut bytes = Vec::new(); + std::io::Read::read_to_end(&mut std::io::stdin(), &mut bytes) + .context("reading stdin")?; + bytes + } + }; + return deliver( + pane, + Payload::Piped { + source: if args.stdin { "stdin" } else { "file" }, + bytes: payload, + }, + &args, + pressed, + backend, + ); + } + // Three shapes reach here, and only the address is ever ambiguous: // `send %3 "text"`, `send "text"` (this pane), and — new with --key — // `send %3 --key C-c`, where there is no text at all and the lone @@ -555,9 +592,75 @@ fn send(args: SendArgs, ctx: &Context, backend: &mut dyn Backend) -> Result Payload::Typed(text.to_string()), + None => Payload::None, + }; + deliver(pane, payload, &args, pressed, backend) +} + +/// What `send` has to put in front of the keys. +enum Payload { + None, + /// From the command line — echoed back in `--json`, as it always was. + Typed(String), + /// From stdin or a file. Never echoed: the reason to use these is to keep + /// the bytes out of places other people can read, and a JSON line in a + /// log is one of them. + Piped { + source: &'static str, + bytes: Vec, + }, +} + +fn deliver( + pane: u64, + payload: Payload, + args: &SendArgs, + pressed: Vec, + backend: &mut dyn Backend, +) -> Result { + const KEY_GAP: Duration = Duration::from_millis(200); + + let (source, bytes) = match &payload { + Payload::None => ("none", &[][..]), + Payload::Typed(text) => ("argument", text.as_bytes()), + Payload::Piped { source, bytes } => (*source, &bytes[..]), + }; + if args.paste && bytes.is_empty() { + bail!("--paste needs text to paste — TEXT, --stdin or --from-file"); + } + // `--paste` frames the way the GUI's paste does, which is to ask the pane + // first: brackets for a pane that switched mode 2004 on, and for one that + // did not, the line breaks a keyboard would send. Forcing the brackets on + // a pane that never asked for them would put `ESC[200~` in front of a + // program that reads it as keystrokes. `None` is a server that cannot say. + let mode = if args.paste { + backend.procs(pane)?.context.and_then(|c| c.bracketed_paste) + } else { + None + }; + let bracketed = mode == Some(true); + if args.paste && !bracketed { + eprintln!( + "tty7: pane %{pane} {} bracketed paste, so the text went unframed — each line \ + break was sent as Enter", + if mode.is_none() { + "is on a server too old to say whether it has" + } else { + "has not switched on" + } + ); + } + let wire = if args.paste { + tty7_core::core::paste::paste_bytes(bytes, bracketed) + } else { + bytes.to_vec() + }; + let mut already_wrote = false; - if let Some(text) = text { - backend.send_input(pane, text.as_bytes().to_vec())?; + if !wire.is_empty() { + backend.send_input(pane, wire)?; already_wrote = true; } for key in &pressed { @@ -576,7 +679,18 @@ fn send(args: SendArgs, ctx: &Context, backend: &mut dyn Backend) -> Result Some(text.as_str()), + Payload::None => Some(""), + Payload::Piped { .. } => None, + }, + "source": source, + "bytes": bytes.len(), + "paste": args.paste, + // Whether the paste went framed, and what the pane said about the + // mode — `null` when nothing was asked or the server cannot say. + "bracketed": bracketed, + "bracketed_paste_mode": mode, "enter": args.enter, "keys": pressed.iter().map(|k| k.name.as_str()).collect::>(), }), @@ -594,6 +708,132 @@ fn tried_to_write_an_address(s: &str) -> bool { .is_some_and(|c| c.is_ascii_digit()) } +fn exec(args: ExecArgs, ctx: &Context, backend: &mut dyn Backend) -> Result { + let pane = address::pane_or_context(args.target.as_deref(), ctx)?; + let line = args.cmd.join(" "); + if line.trim().is_empty() { + bail!("exec needs a command after `--`"); + } + // The line is typed, so every byte of it goes through the shell's line + // editor: a newline would submit half of it and a Tab would complete. One + // line of printable text is the only thing that runs as written — the same + // test the GUI applies before it types a line rather than pasting it. + if line.chars().any(char::is_control) { + bail!( + "exec types one line at the shell's prompt, and this one holds a control \ + character (a newline, a tab, an escape) the line editor would act on — join \ + the commands with `;` or `&&`, or put them in a script and exec that" + ); + } + let mut wire = line.clone().into_bytes(); + wire.push(b'\r'); + let started = std::time::Instant::now(); + let run = backend.exec(pane, wire, args.timeout.map(Duration::from_secs))?; + let elapsed_ms = started.elapsed().as_millis() as u64; + + // The caller's own pane is the one pane that can never be at a prompt + // while this runs: its shell is busy running `tty7 exec`. + let own = ctx + .pane + .as_deref() + .and_then(|p| address::parse_pane(p).ok()) + == Some(pane); + match run.end { + ExecEnd::NoIntegration => bail!( + "pane %{pane} has sent no shell-integration prompt marks, so nothing would say \ + when a command there ends — exec needs tty7's shell integration loaded in the \ + pane's shell (`tty7 doctor`); without it, `send … --enter` and \ + `wait --until free` are the way" + ), + ExecEnd::Busy if own => bail!( + "pane %{pane} is this shell, and it is busy running this very command — exec \ + into another pane" + ), + ExecEnd::Busy => bail!( + "pane %{pane} is not at a shell prompt — something is already running there. \ + `tty7 wait %{pane} --until free` waits for it" + ), + _ => {} + } + + let text = if args.raw { + String::from_utf8_lossy(&run.output).into_owned() + } else { + screen::render(&[crate::backend::CaptureSegment { + size: run.size, + bytes: run.output.clone(), + }]) + }; + if run.dropped > 0 { + eprintln!( + "tty7: exec %{pane}: the first {} bytes of output were dropped to keep the rest \ + in memory — the pane's own scrollback may still hold them", + run.dropped + ); + } + let (code, known, timed_out) = match run.end { + ExecEnd::Finished { + exit: Some(code), + ran: true, + } => (code, true, false), + ExecEnd::Finished { + exit: None, + ran: true, + } => { + eprintln!("tty7: {EXIT_CODE_UNKNOWN}"); + (1, false, false) + } + ExecEnd::Finished { ran: false, .. } => { + eprintln!( + "tty7: pane %{pane}: the shell came back to its prompt without running the \ + line — a syntax error, most likely; what it printed is the output" + ); + (1, false, false) + } + ExecEnd::TimedOut { started: true } => { + eprintln!( + "tty7: pane %{pane}: still running after {}s — timed out, and left running \ + (`tty7 send %{pane} --key C-c` interrupts it)", + args.timeout.unwrap_or_default() + ); + (124, false, true) + } + ExecEnd::TimedOut { started: false } => { + eprintln!( + "tty7: pane %{pane}: timed out after {}s, and the shell never started the \ + line — it is most likely waiting on a continuation prompt (an unclosed \ + quote or bracket); `tty7 send %{pane} --key C-c` abandons it", + args.timeout.unwrap_or_default() + ); + (124, false, true) + } + ExecEnd::PaneExited(_) => { + eprintln!("tty7: pane %{pane} exited before its shell came back to a prompt"); + (1, false, false) + } + ExecEnd::NoIntegration | ExecEnd::Busy => unreachable!("refused above"), + }; + // `-q` keeps the exit code and drops the output, which is what the global + // flag already does to any report. + Ok(Outcome::Exit( + code, + Report { + human: text.clone(), + json: json!({ + "pane": pane, + "command": line, + "exit": code, + "exit_code_known": known, + "timed_out": timed_out, + "pane_exited": matches!(run.end, ExecEnd::PaneExited(_)), + "output": text, + "bytes": run.output.len(), + "elapsed_ms": elapsed_ms, + }), + }, + )) +} + fn capture(args: CaptureArgs, ctx: &Context, backend: &mut dyn Backend) -> Result { let pane = address::pane_or_context(args.target.as_deref(), ctx)?; let segments = backend.capture(pane, args.scrollback)?; @@ -707,6 +947,7 @@ fn tab_ls(explicit: Option<&str>, ctx: &Context, backend: &mut dyn Backend) -> R "folder": g.folder, })), "panes": tab.root.pane_ids(), + "hibernated": tab.hibernated, }) }) .collect(); @@ -920,6 +1161,7 @@ fn pane_ls(explicit: Option<&str>, all: bool, backend: &mut dyn Backend) -> Resu "tab": tab.id.to_string(), "cwd": record.and_then(|r| r.cwd.clone()), "live": record.map(|r| r.live), + "hibernated": tab.hibernated, })); } } @@ -3511,6 +3753,7 @@ mod tests { local_pty: true, at_prompt, remote_prompt_seen: false, + bracketed_paste: None, } } @@ -3537,6 +3780,7 @@ mod tests { local_pty: true, at_prompt, remote_prompt_seen, + bracketed_paste: None, }), } } @@ -3558,6 +3802,7 @@ mod tests { local_pty: false, at_prompt, remote_prompt_seen: at_prompt == Some(true), + bracketed_paste: None, }), } } @@ -4537,4 +4782,375 @@ mod tests { backend.control_calls ); } + + #[test] + fn tab_ls_and_pane_ls_say_which_tabs_are_asleep() { + let mut backend = mock(); + backend.machine.workspaces[0].tabs[1].hibernated = true; + + let Outcome::Report(tabs) = run_cli( + &["tty7", "tab", "ls", "api"], + &Context::default(), + &mut backend, + ) else { + panic!("tab ls must report"); + }; + let tabs = tabs.json["tabs"].as_array().expect("tabs").clone(); + assert_eq!(tabs[0]["hibernated"], false); + assert_eq!(tabs[1]["hibernated"], true); + + let Outcome::Report(panes) = run_cli( + &["tty7", "pane", "ls", "api"], + &Context::default(), + &mut backend, + ) else { + panic!("pane ls must report"); + }; + let panes = panes.json["panes"].as_array().expect("panes").clone(); + let asleep: Vec = panes + .iter() + .filter(|p| p["hibernated"] == true) + .map(|p| p["pane"].as_u64().unwrap()) + .collect(); + assert_eq!(asleep, vec![2, 3]); + } + + fn exec_run(end: ExecEnd, output: &[u8]) -> crate::exec::ExecRun { + crate::exec::ExecRun { + end, + output: output.to_vec(), + dropped: 0, + size: segment(b"").size, + } + } + + fn exec_outcome(args: &[&str], ctx: &Context, backend: &mut MockBackend) -> (i32, Report) { + match execute(cli(args), ctx, backend) { + Ok(Outcome::Exit(code, report)) => (code, report), + other => panic!("exec stands in for the command, so it exits: {other:?}"), + } + } + + #[test] + fn exec_types_one_line_and_passes_the_exit_code_through() { + let mut backend = mock(); + backend.exec_run = exec_run( + ExecEnd::Finished { + exit: Some(3), + ran: true, + }, + b"built\r\n", + ); + let (code, report) = exec_outcome( + &[ + "tty7", + "exec", + "%1", + "--raw", + "--timeout", + "30", + "--", + "make", + "&&", + "ls", + ], + &Context::default(), + &mut backend, + ); + assert_eq!(code, 3, "the command's own code, not ours"); + assert_eq!( + backend.execs, + vec![(1, b"make && ls\r".to_vec(), Some(Duration::from_secs(30)))], + "the words are one shell line, typed and submitted" + ); + assert_eq!(report.human, "built\r\n"); + assert_eq!(report.json["exit"], 3); + assert_eq!(report.json["exit_code_known"], true); + assert_eq!(report.json["command"], "make && ls"); + assert_eq!(report.json["output"], "built\r\n"); + assert_eq!(report.json["timed_out"], false); + // Nothing was sent anywhere but through the exec seam. + assert!(backend.sent.is_empty()); + } + + #[test] + fn exec_renders_the_output_the_way_capture_plain_does() { + let mut backend = mock(); + backend.exec_run = exec_run( + ExecEnd::Finished { + exit: Some(0), + ran: true, + }, + b"\x1b[31mred\x1b[0m\r\nprogress 10%\rprogress 100%\r\n", + ); + let (code, report) = exec_outcome( + &["tty7", "exec", "%1", "--", "x"], + &Context::default(), + &mut backend, + ); + assert_eq!(code, 0); + assert!(!report.human.contains('\u{1b}'), "{:?}", report.human); + assert!(report.human.contains("red"), "{:?}", report.human); + assert!(report.human.contains("progress 100%"), "{:?}", report.human); + assert!( + !report.human.contains("progress 10%\n"), + "{:?}", + report.human + ); + } + + #[test] + fn exec_refuses_a_pane_it_cannot_follow_before_typing_anything() { + // The backend sends nothing on these; the verb has to turn them into + // an error that says what to do instead of an exit code that says + // nothing. + let mut backend = mock(); + backend.exec_run = exec_run(ExecEnd::NoIntegration, b""); + let err = execute( + cli(&["tty7", "exec", "%1", "--", "ls"]), + &Context::default(), + &mut backend, + ) + .unwrap_err() + .to_string(); + assert!(err.contains("shell-integration"), "{err}"); + assert!(err.contains("wait --until free"), "{err}"); + + let mut backend = mock(); + backend.exec_run = exec_run(ExecEnd::Busy, b""); + let err = execute( + cli(&["tty7", "exec", "%1", "--", "ls"]), + &Context::default(), + &mut backend, + ) + .unwrap_err() + .to_string(); + assert!(err.contains("not at a shell prompt"), "{err}"); + + // The caller's own pane can only ever be busy — running this. + let ctx = Context { + pane: Some("%1".into()), + ..Context::default() + }; + let err = execute(cli(&["tty7", "exec", "--", "ls"]), &ctx, &mut backend) + .unwrap_err() + .to_string(); + assert!(err.contains("this shell"), "{err}"); + } + + #[test] + fn exec_refuses_a_line_the_editor_would_act_on() { + for line in ["make\nrm -rf build", "ls\tx", "a\u{1b}b"] { + let mut backend = mock(); + let err = execute( + cli(&["tty7", "exec", "%1", "--", line]), + &Context::default(), + &mut backend, + ) + .unwrap_err() + .to_string(); + assert!(err.contains("control"), "{line:?}: {err}"); + assert!(backend.execs.is_empty(), "nothing may be typed: {line:?}"); + } + } + + #[test] + fn exec_reports_every_way_it_can_end_without_an_exit_code() { + let cases = [ + (ExecEnd::TimedOut { started: true }, 124, true, false), + (ExecEnd::TimedOut { started: false }, 124, true, false), + (ExecEnd::PaneExited(Some(0)), 1, false, true), + ( + ExecEnd::Finished { + exit: None, + ran: true, + }, + 1, + false, + false, + ), + ( + ExecEnd::Finished { + exit: None, + ran: false, + }, + 1, + false, + false, + ), + ]; + for (end, want, timed_out, pane_exited) in cases { + let mut backend = mock(); + backend.exec_run = exec_run(end.clone(), b"partial\r\n"); + let (code, report) = exec_outcome( + &["tty7", "exec", "%1", "--raw", "--timeout", "1", "--", "x"], + &Context::default(), + &mut backend, + ); + assert_eq!(code, want, "{end:?}"); + assert_eq!(report.json["exit_code_known"], false, "{end:?}"); + assert_eq!(report.json["timed_out"], timed_out, "{end:?}"); + assert_eq!(report.json["pane_exited"], pane_exited, "{end:?}"); + assert_eq!( + report.human, "partial\r\n", + "what did arrive is still the answer: {end:?}" + ); + } + } + + fn procs_with_paste_mode(mode: Option) -> tty7_core::daemon::protocol::PaneProcs { + tty7_core::daemon::protocol::PaneProcs { + context: Some(tty7_core::daemon::protocol::PaneContext { + local_pty: true, + bracketed_paste: mode, + ..Default::default() + }), + ..Default::default() + } + } + + #[test] + fn send_from_file_sends_the_bytes_verbatim_and_never_echoes_them() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("token"); + std::fs::write(&path, b"s3cr\\et\n\xff").unwrap(); + let mut backend = mock(); + let json = json_of(run_cli( + &["tty7", "send", "%1", "--from-file", path.to_str().unwrap()], + &Context::default(), + &mut backend, + )); + assert_eq!(backend.sent, vec![(1, b"s3cr\\et\n\xff".to_vec())]); + assert!( + json["sent"].is_null(), + "the secret must not be echoed: {json}" + ); + assert!(!json.to_string().contains("s3cr"), "{json}"); + assert_eq!(json["source"], "file"); + assert_eq!(json["bytes"], 9); + assert!( + backend.procs_calls.is_empty(), + "without --paste the pane is not asked anything" + ); + + // Keys still follow the payload, the way they follow typed text. + let mut backend = mock(); + run_cli( + &[ + "tty7", + "send", + "%1", + "--from-file", + path.to_str().unwrap(), + "--enter", + ], + &Context::default(), + &mut backend, + ); + assert_eq!( + backend.sent, + vec![(1, b"s3cr\\et\n\xff".to_vec()), (1, b"\r".to_vec())] + ); + } + + #[test] + fn send_from_file_takes_its_positional_as_an_address_only() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("payload"); + std::fs::write(&path, b"x").unwrap(); + let mut backend = mock(); + let err = execute( + cli(&[ + "tty7", + "send", + "echo", + "--from-file", + path.to_str().unwrap(), + ]), + &Context::default(), + &mut backend, + ) + .unwrap_err(); + let err = format!("{err:#}"); + assert!(err.contains("has to be the pane"), "{err}"); + assert!(backend.sent.is_empty(), "nothing may be typed"); + + // With no address it is this pane, as it is for text. + let ctx = Context { + pane: Some("%3".into()), + ..Context::default() + }; + run_cli( + &["tty7", "send", "--from-file", path.to_str().unwrap()], + &ctx, + &mut backend, + ); + assert_eq!(backend.sent, vec![(3, b"x".to_vec())]); + } + + #[test] + fn send_paste_frames_only_for_a_pane_that_switched_bracketed_paste_on() { + let mut backend = mock(); + backend.procs_reply = procs_with_paste_mode(Some(true)); + let json = json_of(run_cli( + &[ + "tty7", + "send", + "%1", + "a\r\nb\x1b[201~c", + "--paste", + "--enter", + ], + &Context::default(), + &mut backend, + )); + assert_eq!( + backend.sent, + vec![ + (1, b"\x1b[200~a\nb[201~c\x1b[201~".to_vec()), + (1, b"\r".to_vec()) + ], + "the GUI's framing: CRLF folded, ESC stripped so the text cannot close the paste" + ); + assert_eq!(json["bracketed"], true); + assert_eq!(json["bracketed_paste_mode"], true); + + // Off: brackets would reach the program as keystrokes, so the text goes + // the way a paste into such a pane always has — a CR per line break. + let mut backend = mock(); + backend.procs_reply = procs_with_paste_mode(Some(false)); + let json = json_of(run_cli( + &["tty7", "send", "%1", "a\nb", "--paste"], + &Context::default(), + &mut backend, + )); + assert_eq!(backend.sent, vec![(1, b"a\rb".to_vec())]); + assert_eq!(json["bracketed"], false); + assert_eq!(json["bracketed_paste_mode"], false); + + // A server that cannot say is not taken as a yes. + let mut backend = mock(); + backend.procs_reply = procs_with_paste_mode(None); + let json = json_of(run_cli( + &["tty7", "send", "%1", "a\nb", "--paste"], + &Context::default(), + &mut backend, + )); + assert_eq!(backend.sent, vec![(1, b"a\rb".to_vec())]); + assert!(json["bracketed_paste_mode"].is_null(), "{json}"); + } + + #[test] + fn send_paste_needs_something_to_paste() { + let mut backend = mock(); + let err = execute( + cli(&["tty7", "send", "%1", "--paste", "--enter"]), + &Context::default(), + &mut backend, + ) + .unwrap_err() + .to_string(); + assert!(err.contains("--paste needs text"), "{err}"); + assert!(backend.sent.is_empty()); + } } diff --git a/crates/tty7-cli/src/exec.rs b/crates/tty7-cli/src/exec.rs new file mode 100644 index 00000000..623ab544 --- /dev/null +++ b/crates/tty7-cli/src/exec.rs @@ -0,0 +1,305 @@ +//! Finding one command's output and exit code in a pane's byte stream. +//! +//! `tty7 exec` types a line at a shell's prompt and has to know two things the +//! pty alone never says: where the command's output starts and ends, and how it +//! exited. The shell integration says both, in OSC 133 marks: `C` right before +//! the command runs, `D;` the moment it is over, `A` when the next prompt +//! opens. The daemon already reads those marks — it is what sends `Prompt` — +//! but only as a state, not as a position in the bytes, so the positions are +//! found here, in the same stream the daemon read them from. +//! +//! Which of the two gets the last word is deliberate. The byte marks say +//! *where*; the daemon's `Prompt` report says *when*, because it is the one +//! that has already set aside marks relayed by a program running in the +//! foreground (a nested shell, an `ssh`) — a `D` in the bytes is only the end +//! once the daemon agrees the shell is back at its prompt. + +use tty7_core::core::osc::OscTokenizer; +use tty7_core::daemon::protocol::WinSize; + +/// Output kept past this is dropped from the front: the end of a long build is +/// the part worth reading, and a command that prints without bound must not +/// take the CLI's memory with it. The pane's own scrollback is the same size. +pub const KEEP_OUTPUT: usize = 8 * 1024 * 1024; + +const MARK_D: &[u8] = b"\x1b]133;D"; +const MARK_A: &[u8] = b"\x1b]133;A"; + +/// How an `exec` ended. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ExecEnd { + /// The pane's shell has never sent a prompt mark, so nothing would ever + /// say the command is over. Nothing was sent. + NoIntegration, + /// The pane is not at a prompt — something already runs there. Nothing + /// was sent. + Busy, + /// The shell is back at its prompt. `ran` is false when it came back + /// without running anything — a syntax error, most likely — in which case + /// there is no exit code to report and the output is what the shell said + /// about the line. + Finished { exit: Option, ran: bool }, + /// The deadline passed first. `started` says whether the shell ever + /// began the command: when it did not, the line is most likely sitting on + /// a continuation prompt (an unclosed quote) rather than running. + TimedOut { started: bool }, + /// The pane went away before its shell came back to a prompt. + PaneExited(Option), +} + +/// What an `exec` saw: how it ended, and the output that belongs to it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExecRun { + pub end: ExecEnd, + pub output: Vec, + /// Bytes of output dropped from the front to stay under [`KEEP_OUTPUT`]. + pub dropped: usize, + /// The pane's size, which is what `--plain` replays the output at. + pub size: WinSize, +} + +/// The pane's output from the moment the line was sent, and where the marks +/// that bound the command fell in it. +pub struct Transcript { + tok: OscTokenizer, + bytes: Vec, + /// Absolute offset of `bytes[0]` — everything before it was dropped. + base: usize, + /// One past the command's `C` mark: where its output starts. + started: Option, + /// Where the newest `D` after `started` begins: where the output ends. + ended: Option, + /// Where an `A` that arrived with no `C` before it begins — the shell drew + /// a fresh prompt without running the line. + reprompted: Option, + exit_mark: Option, +} + +/// What the daemon's `Prompt` report made of the pane. +pub struct Finished { + pub exit: Option, + pub ran: bool, +} + +impl Default for Transcript { + fn default() -> Transcript { + Transcript::new() + } +} + +impl Transcript { + pub fn new() -> Transcript { + Transcript { + tok: OscTokenizer::new(&[b"133"]), + bytes: Vec::new(), + base: 0, + started: None, + ended: None, + reprompted: None, + exit_mark: None, + } + } + + /// Take the next chunk of the pane's output. + pub fn output(&mut self, chunk: &[u8]) { + let at = self.base + self.bytes.len(); + self.bytes.extend_from_slice(chunk); + let (bytes, base) = (&self.bytes, self.base); + let (mut started, mut ended, mut reprompted, mut exit_mark) = + (self.started, self.ended, self.reprompted, self.exit_mark); + self.tok.feed_at(chunk, |end, payload| { + let end = at + end; + // Where the sequence that just ended began: the newest copy of its + // introducer before its terminator. Payloads carry no ESC, so the + // search cannot land inside a different sequence. + let begin = |intro: &[u8]| { + let upto = end - base; + bytes[..upto] + .windows(intro.len()) + .rposition(|w| w == intro) + .map_or(end, |at| base + at) + }; + match payload.get(4) { + // Only the first: a command that prints a `C` of its own must + // not move the start of its own output. + Some(b'C') if started.is_none() => started = Some(end), + Some(b'D') if started.is_some() => { + ended = Some(begin(MARK_D)); + exit_mark = payload + .strip_prefix(b"133;D;") + .and_then(|c| std::str::from_utf8(c).ok()) + .and_then(|c| c.trim().parse().ok()); + } + Some(b'A') if started.is_none() && reprompted.is_none() => { + reprompted = Some(begin(MARK_A)); + } + _ => {} + } + }); + (self.started, self.ended, self.reprompted, self.exit_mark) = + (started, ended, reprompted, exit_mark); + } + + /// The daemon's report that the pane's shell is (or is not) at a prompt. + /// `Some` once the command is over. + pub fn prompt(&self, at_prompt: bool, last_exit: Option) -> Option { + if !at_prompt { + return None; + } + if self.started.is_some() { + // The byte mark and the report are the same `D`, read twice; the + // report is preferred only because the daemon parsed it first. + return self.ended.map(|_| Finished { + exit: last_exit.or(self.exit_mark), + ran: true, + }); + } + self.reprompted.map(|_| Finished { + exit: None, + ran: false, + }) + } + + /// Whether the shell has begun running the command. + pub fn started(&self) -> bool { + self.started.is_some() + } + + /// The output that belongs to the command, as far as it has got, and how + /// much of it was dropped from the front to keep it bounded. + pub fn take(self) -> (Vec, usize) { + let all = self.base + self.bytes.len(); + let (from, to) = match (self.started, self.ended, self.reprompted) { + (Some(start), Some(end), _) => (start, end), + (Some(start), None, _) => (start, all), + // Never ran: what the shell printed about the line, up to the new + // prompt — the echo of the line itself and whatever error it drew. + (None, _, Some(end)) => (0, end), + (None, _, None) => (0, all), + }; + let to = to.clamp(from, all); + let kept = from.max(self.base).max(to.saturating_sub(KEEP_OUTPUT)); + ( + self.bytes[kept - self.base..to - self.base].to_vec(), + kept - from, + ) + } + + /// Bound the memory held: once twice [`KEEP_OUTPUT`] has piled up, drop + /// all but the last [`KEEP_OUTPUT`]. Only ever what [`take`](Self::take) + /// would not return anyway, which keeps the newest that much. + pub fn compact(&mut self) { + let len = self.bytes.len(); + if len <= KEEP_OUTPUT * 2 { + return; + } + let drop = len - KEEP_OUTPUT; + self.bytes.drain(..drop); + self.base += drop; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// What zsh with tty7's integration prints for one `echo hi` typed at its + /// prompt: the echo of the line, `C` with the line, the output, `D` with + /// the exit code, then the next prompt. + const ONE_COMMAND: &[u8] = b"echo hi\r\n\x1b]133;C;echo hi\x07hi\r\n\x1b]133;D;0\x07\ + \x1b]7;file://host/tmp\x07\x1b]133;A\x07% \x1b]133;B\x07"; + + #[test] + fn the_output_is_what_lies_between_c_and_d() { + let mut t = Transcript::new(); + t.output(ONE_COMMAND); + let done = t.prompt(true, Some(0)).expect("D then a prompt is the end"); + assert_eq!((done.exit, done.ran), (Some(0), true)); + assert_eq!(t.take(), (b"hi\r\n".to_vec(), 0)); + } + + #[test] + fn marks_split_across_reads_still_bound_the_output() { + // The pty hands the stream over in whatever chunks it likes, including + // through the middle of a mark. + for split in 1..ONE_COMMAND.len() { + let mut t = Transcript::new(); + t.output(&ONE_COMMAND[..split]); + t.output(&ONE_COMMAND[split..]); + assert!(t.prompt(true, Some(0)).is_some(), "split at {split}"); + assert_eq!(t.take().0, b"hi\r\n".to_vec(), "split at {split}"); + } + } + + #[test] + fn a_prompt_report_before_the_command_has_ended_is_not_the_end() { + let mut t = Transcript::new(); + // The shell has only echoed the line; a prompt redraw (a `B` in PS1 + // repainted by a transient-prompt theme) reports "at a prompt" too. + t.output(b"make\r\n\x1b]133;B\x07"); + assert!(t.prompt(true, None).is_none()); + // Running: the daemon reports "not at a prompt". + t.output(b"\x1b]133;C;make\x07building\r\n"); + assert!(t.prompt(false, None).is_none()); + // A `D` the daemon did not believe — relayed by something running in + // the foreground, reported as "not at a prompt" — ends nothing. + t.output(b"\x1b]133;D;7\x07more\r\n"); + assert!(t.prompt(false, Some(7)).is_none()); + t.output(b"done\r\n\x1b]133;D;2\x07\x1b]133;A\x07$ "); + let done = t.prompt(true, Some(2)).expect("the real end"); + assert_eq!(done.exit, Some(2)); + assert_eq!( + t.take().0, + b"building\r\n\x1b]133;D;7\x07more\r\ndone\r\n".to_vec(), + "the output runs to the D the shell's own prompt followed" + ); + } + + #[test] + fn a_prompt_with_no_command_before_it_means_the_line_never_ran() { + let mut t = Transcript::new(); + t.output(b"echo (\r\nzsh: parse error near `('\r\n\x1b]133;A\x07% "); + let done = t.prompt(true, None).expect("the shell is back"); + assert_eq!((done.exit, done.ran), (None, false)); + assert_eq!( + t.take().0, + b"echo (\r\nzsh: parse error near `('\r\n".to_vec(), + "what the shell said about the line is the only output there is" + ); + } + + #[test] + fn the_exit_code_falls_back_to_the_mark_itself() { + let mut t = Transcript::new(); + t.output(b"\x1b]133;C\x07\x1b]133;D;5\x1b\\"); + let done = t + .prompt(true, None) + .expect("ST terminates a mark as well as BEL"); + assert_eq!(done.exit, Some(5)); + assert!(t.take().0.is_empty()); + } + + #[test] + fn a_timeout_keeps_what_the_command_has_printed_so_far() { + let mut t = Transcript::new(); + t.output(b"sleep 9\r\n\x1b]133;C;sleep 9\x07tick\r\n"); + assert_eq!(t.take().0, b"tick\r\n".to_vec()); + } + + #[test] + fn output_past_the_bound_is_dropped_from_the_front() { + let mut t = Transcript::new(); + t.output(b"\x1b]133;C\x07"); + let chunk = vec![b'x'; KEEP_OUTPUT]; + for _ in 0..3 { + t.output(&chunk); + t.compact(); + } + t.output(b"end\x1b]133;D;0\x07"); + assert!(t.prompt(true, Some(0)).is_some()); + let (out, dropped) = t.take(); + assert_eq!(out.len(), KEEP_OUTPUT); + assert!(out.ends_with(b"end")); + assert_eq!(out.len() + dropped, KEEP_OUTPUT * 3 + 3); + } +} diff --git a/crates/tty7-cli/src/keys.rs b/crates/tty7-cli/src/keys.rs index 6762cf05..545bded9 100644 --- a/crates/tty7-cli/src/keys.rs +++ b/crates/tty7-cli/src/keys.rs @@ -90,6 +90,12 @@ pub fn send_long_help() -> String { when there is none, so `send %42 --enter` runs whatever is already typed in pane 42. \ An unmarked id is not a target for it — `send 83 --enter` is refused, because it \ reads just as much like typing 83 into your own pane; write %83 to mean the pane.\n\n\ + --stdin and --from-file take the text from there instead, byte for byte — the way \ + to send a secret, which as TEXT is visible to every local user in `ps` and lands in \ + your shell history. The one positional is then the pane.\n\n\ + --paste sends the text the way the GUI pastes: framed in bracketed paste when the \ + pane has switched it on, so a multi-line text arrives as one block of text instead \ + of running line by line. --json reports whether it was framed.\n\n\ Keys: {}. Aliases: {}.", vocabulary(), aliases.join(", ") diff --git a/crates/tty7-cli/src/main.rs b/crates/tty7-cli/src/main.rs index b4ef3f1b..b9f8a9c9 100644 --- a/crates/tty7-cli/src/main.rs +++ b/crates/tty7-cli/src/main.rs @@ -2,6 +2,7 @@ mod address; mod backend; mod cli; mod commands; +mod exec; mod gui; mod keys; mod output; diff --git a/crates/tty7-cli/src/output.rs b/crates/tty7-cli/src/output.rs index dd2fec8b..85639207 100644 --- a/crates/tty7-cli/src/output.rs +++ b/crates/tty7-cli/src/output.rs @@ -151,9 +151,14 @@ pub fn pane_table(machine: &Machine, only: Option) -> String { record .and_then(|r| r.cwd.clone()) .unwrap_or_else(|| "-".to_string()), - record - .map(|r| if r.live { "yes" } else { "no" }.to_string()) - .unwrap_or_else(|| "?".to_string()), + // Not running, and meant not to be: a sleeping tab's panes + // are waiting for their tab to be woken, not dead. + match record { + _ if tab.hibernated => "asleep".to_string(), + Some(r) if r.live => "yes".to_string(), + Some(_) => "no".to_string(), + None => "?".to_string(), + }, ]); } } @@ -659,4 +664,29 @@ mod tests { procs_tables(&empty) ); } + + /// A sleeping tab's panes are not running and not dead either: the table + /// says which, so nobody reads a tab put to sleep as one that crashed. + #[test] + fn the_pane_table_calls_a_sleeping_tabs_panes_asleep() { + let mut m = two_workspace_machine(); + m.workspaces[0].tabs[1].hibernated = true; + for pane in &mut m.panes { + if pane.id == 2 || pane.id == 3 { + pane.live = false; + } + } + let rendered = pane_table(&m, None); + let row = |pane: &str| { + rendered + .lines() + .find(|l| l.starts_with(pane)) + .unwrap_or_else(|| panic!("no row for {pane}: {rendered}")) + .to_string() + }; + assert!(row("%1 ").ends_with("yes"), "{rendered}"); + assert!(row("%2 ").ends_with("asleep"), "{rendered}"); + assert!(row("%3 ").ends_with("asleep"), "{rendered}"); + assert!(row("%5 ").ends_with("yes"), "{rendered}"); + } } diff --git a/crates/tty7-cli/src/testbed.rs b/crates/tty7-cli/src/testbed.rs index 28611a53..84482064 100644 --- a/crates/tty7-cli/src/testbed.rs +++ b/crates/tty7-cli/src/testbed.rs @@ -13,6 +13,7 @@ pub fn two_workspace_machine() -> Machine { group: None, last_auto: None, root: PaneNode::Leaf { pane: 1 }, + hibernated: false, }, Tab { id: TabId::new(), @@ -25,6 +26,7 @@ pub fn two_workspace_machine() -> Machine { a: Box::new(PaneNode::Leaf { pane: 2 }), b: Box::new(PaneNode::Leaf { pane: 3 }), }, + hibernated: false, }, ], active_tab: None, @@ -41,6 +43,7 @@ pub fn two_workspace_machine() -> Machine { group: None, last_auto: None, root: PaneNode::Leaf { pane: 5 }, + hibernated: false, }], active_tab: None, attachment: None, diff --git a/crates/tty7-cli/tests/cli_e2e.rs b/crates/tty7-cli/tests/cli_e2e.rs index d06c050e..89f5e70e 100644 --- a/crates/tty7-cli/tests/cli_e2e.rs +++ b/crates/tty7-cli/tests/cli_e2e.rs @@ -87,6 +87,22 @@ fn main() { "procs_says_where_the_panes_session_lives", procs_says_where_the_panes_session_lives, ), + ( + "exec_returns_the_commands_output_and_exit_code", + exec_returns_the_commands_output_and_exit_code, + ), + ( + "exec_refuses_a_pane_with_no_prompt_marks", + exec_refuses_a_pane_with_no_prompt_marks, + ), + ( + "send_stdin_delivers_bytes_it_never_echoes", + send_stdin_delivers_bytes_it_never_echoes, + ), + ( + "send_paste_asks_the_pane_for_its_bracketed_paste_mode", + send_paste_asks_the_pane_for_its_bracketed_paste_mode, + ), ]; let mut failed = 0; @@ -1052,3 +1068,210 @@ fn capture_tail_trims_a_real_panes_answer(daemon: &Daemon) { std::thread::sleep(Duration::from_millis(200)); } } + +/// A pane running `program` with no shell in front of it — so no prompt marks +/// and no line editor, just the pty's own echo. What the tests below type into +/// it comes back exactly as the pane received it. +fn bare_pane(daemon: &Daemon, program: &str) -> u64 { + let shell = ShellSpec { + program: program.into(), + args: Vec::new(), + args_are_tty7_defaults: false, + }; + let session = PaneClient::at(daemon.pane_endpoint()) + .spawn( + None, + WinSize { + cols: 80, + rows: 24, + cell_w: 8, + cell_h: 16, + }, + Some(shell), + Some("bare-pane-e2e".into()), + None, + ) + .unwrap_or_else(|e| panic!("spawn {program}: {e}")); + let pane = session.pane_id(); + session.detach().expect("leave the bare pane running"); + pane +} + +/// Block until the pane's shell has drawn its first prompt: before that there +/// are no marks for `exec` to follow, and it refuses rather than guesses. +fn await_prompt(daemon: &Daemon, address: &str) { + let deadline = Instant::now() + SETTLE_WITHIN; + loop { + let procs = daemon.run_json(&["procs", address]); + if procs["context"]["at_prompt"].as_bool() == Some(true) { + return; + } + assert!( + Instant::now() < deadline, + "the shell never reported a prompt; last procs: {procs}" + ); + std::thread::sleep(Duration::from_millis(200)); + } +} + +fn exec_returns_the_commands_output_and_exit_code(daemon: &Daemon) { + // The marks come from tty7's integration for zsh/bash/fish/nushell/pwsh; + // the shell a Windows runner defaults to is not one this can count on. + if cfg!(windows) { + return; + } + let created = daemon.run_json(&["new", &workdir()]); + let pane = created["pane"].as_u64().expect("new prints the pane id"); + let address = format!("%{pane}"); + await_prompt(daemon, &address); + + // `sh -c` so the exit code does not depend on which shell the pane runs. + let out = daemon.run(&[ + "exec", + &address, + "--timeout", + "60", + "--", + "sh", + "-c", + "'echo tty7_e2e_exec_$((6*7)); exit 3'", + ]); + let stdout = String::from_utf8_lossy(&out.stdout); + assert_eq!( + out.status.code(), + Some(3), + "exec exits with the command's code; stdout {stdout:?}, stderr {:?}", + String::from_utf8_lossy(&out.stderr) + ); + assert!(stdout.contains("tty7_e2e_exec_42"), "{stdout:?}"); + assert!( + !stdout.contains("6*7") && !stdout.contains("exit 3"), + "the typed line is not the command's output: {stdout:?}" + ); + + // The pane is back at its prompt, so it takes the next one straight away. + // `--json` ahead of `--`: after it, it would be an argument to `true`. + let out = daemon.run_ok(&["exec", &address, "--json", "--timeout", "60", "--", "true"]); + let json: serde_json::Value = serde_json::from_str(&out).expect("--json prints JSON"); + assert_eq!(json["exit"], 0, "{json}"); + assert_eq!(json["exit_code_known"], true, "{json}"); + assert_eq!(json["pane"], pane, "{json}"); + assert_eq!( + json["output"], "", + "a command that prints nothing has nothing to show — not the shell's \ + prompt preparation: {json}" + ); +} + +fn exec_refuses_a_pane_with_no_prompt_marks(daemon: &Daemon) { + if cfg!(windows) { + return; + } + let pane = bare_pane(daemon, "/bin/cat"); + let out = daemon.run(&["exec", &format!("%{pane}"), "--", "echo", "never"]); + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!(out.status.code(), Some(1), "{stderr}"); + assert!(stderr.contains("shell-integration"), "{stderr}"); + // Refused before typing: nothing reached the pane. + let seen = daemon.run_ok(&["capture", &format!("%{pane}"), "--scrollback"]); + assert!(!seen.contains("never"), "{seen:?}"); +} + +fn send_via_stdin(daemon: &Daemon, args: &[&str], input: &[u8]) -> Output { + let mut child = daemon + .cli(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap_or_else(|e| panic!("could not run tty7 {args:?}: {e}")); + child + .stdin + .take() + .expect("stdin was piped") + .write_all(input) + .expect("feed tty7's stdin"); + child.wait_with_output().expect("tty7 ran to completion") +} + +fn await_capture(daemon: &Daemon, address: &str, marker: &str) -> String { + let deadline = Instant::now() + SETTLE_WITHIN; + loop { + let seen = daemon.run_ok(&["capture", address, "--scrollback"]); + if seen.contains(marker) { + return seen; + } + assert!( + Instant::now() < deadline, + "{marker:?} never showed up; last capture:\n{seen}" + ); + std::thread::sleep(Duration::from_millis(200)); + } +} + +fn send_stdin_delivers_bytes_it_never_echoes(daemon: &Daemon) { + if cfg!(windows) { + return; + } + let pane = bare_pane(daemon, "/bin/cat"); + let address = format!("%{pane}"); + let out = send_via_stdin( + daemon, + &["send", &address, "--stdin", "--json"], + b"tty7_e2e_stdin_secret\n", + ); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + out.status.success(), + "{stdout}{}", + String::from_utf8_lossy(&out.stderr) + ); + assert!( + !stdout.contains("tty7_e2e_stdin_secret"), + "the payload must not come back in the report: {stdout}" + ); + let json: serde_json::Value = serde_json::from_str(&stdout).expect("--json prints JSON"); + assert_eq!(json["source"], "stdin", "{json}"); + assert_eq!(json["bytes"], 22, "{json}"); + await_capture(daemon, &address, "tty7_e2e_stdin_secret"); +} + +fn send_paste_asks_the_pane_for_its_bracketed_paste_mode(daemon: &Daemon) { + if cfg!(windows) { + return; + } + let pane = bare_pane(daemon, "/bin/cat"); + let address = format!("%{pane}"); + + // Off to begin with: nothing in the pane has asked for it. + let json = daemon.run_json(&["send", &address, "tty7_e2e_one\ntty7_e2e_two", "--paste"]); + assert_eq!(json["bracketed_paste_mode"], false, "{json}"); + assert_eq!(json["bracketed"], false, "{json}"); + await_capture(daemon, &address, "tty7_e2e_two"); + + // `cat` hands back what it reads, so the program in the pane is now the + // one that switched mode 2004 on — the daemon sees it in the output. + let out = send_via_stdin(daemon, &["send", &address, "--stdin"], b"\x1b[?2004h\n"); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + let deadline = Instant::now() + SETTLE_WITHIN; + loop { + let procs = daemon.run_json(&["procs", &address]); + if procs["context"]["bracketed_paste"].as_bool() == Some(true) { + break; + } + assert!( + Instant::now() < deadline, + "the pane never reported bracketed paste on: {procs}" + ); + std::thread::sleep(Duration::from_millis(200)); + } + let json = daemon.run_json(&["send", &address, "tty7_e2e_framed", "--paste"]); + assert_eq!(json["bracketed"], true, "{json}"); + // The frame reached the pty: cat's echo of it carries the opener's tail. + let seen = await_capture(daemon, &address, "tty7_e2e_framed"); + assert!(seen.contains("[200~"), "{seen:?}"); +} diff --git a/crates/tty7-core/src/core/cli_agent.rs b/crates/tty7-core/src/core/cli_agent.rs index eb7d5865..42b44526 100644 --- a/crates/tty7-core/src/core/cli_agent.rs +++ b/crates/tty7-core/src/core/cli_agent.rs @@ -304,6 +304,32 @@ impl CLIAgent { } fn replay_flags(self, argv: &[String]) -> Option> { + let tail = self.session_free_tail(argv)?; + let safe = |t: &str| { + !t.is_empty() + && t.bytes() + .all(|b| b.is_ascii_alphanumeric() || b"-_=./,:@+~".contains(&b)) + }; + if !tail.iter().all(|t| safe(t)) { + return None; + } + let mut prev_was_flag = false; + for t in &tail { + let is_flag = t.starts_with('-'); + if !is_flag && !prev_was_flag { + return None; + } + prev_was_flag = is_flag; + } + Some(tail.into_iter().map(String::from).collect()) + } + + /// What follows the agent's own name in `argv`, with everything that names + /// a session taken out: the resume/fork subcommands and the flags listed as + /// stale for this agent, each with its value. `None` when no token in + /// `argv` names this agent — a wrapper, where there is no telling which + /// arguments are the agent's. + fn session_free_tail(self, argv: &[String]) -> Option> { let names_self = |token: &str| { token.split(['/', '\\']).any(|seg| { CLIAgent::match_token(&base_stem(seg).to_ascii_lowercase()) == Some(self) @@ -490,24 +516,7 @@ impl CLIAgent { i += 1; } } - - let safe = |t: &str| { - !t.is_empty() - && t.bytes() - .all(|b| b.is_ascii_alphanumeric() || b"-_=./,:@+~".contains(&b)) - }; - if !tail.iter().all(|t| safe(t)) { - return None; - } - let mut prev_was_flag = false; - for t in &tail { - let is_flag = t.starts_with('-'); - if !is_flag && !prev_was_flag { - return None; - } - prev_was_flag = is_flag; - } - Some(tail.into_iter().map(String::from).collect()) + Some(tail) } pub fn accent_rgb(self) -> u32 { @@ -598,6 +607,58 @@ impl CLIAgent { .find(|a| a.aliases().contains(&token)) } + /// The program a launch with no override runs: the first name the agent + /// is detected by, which is the one its installer puts on `PATH`. + pub fn binary(self) -> &'static str { + self.aliases()[0] + } + + /// The command line a quick launch types for this agent: the user's + /// `agent_launch` entry when there is one, the bare binary otherwise. + /// + /// Keys are matched the way [`CLIAgent::from_slug`] reads them, so a + /// hand-written `"Claude"` still counts. A blank entry is no override. + pub fn launch_command(self, overrides: &HashMap) -> String { + overrides + .iter() + .find(|(slug, _)| CLIAgent::from_slug(slug) == Some(self)) + .map(|(_, line)| line.trim()) + .filter(|line| !line.is_empty()) + .map_or_else(|| self.binary().to_string(), str::to_string) + } + + /// `argv` as a command line worth launching this agent with again: its + /// binary followed by the flags it ran with, minus everything that names + /// one particular session (a resume, a fork, a session id) and minus + /// positional arguments such as a prompt, which belong to that one run. + /// + /// A run that went through something that does not name the agent — a + /// wrapper script mapped by `agent_commands` — cannot be taken apart, so + /// it comes back whole: it is what was typed, and it launched the agent. + pub fn launch_argv_for_default(self, argv: &[String]) -> Option> { + let env = argv.iter().take_while(|t| is_env_assignment(t)).count(); + if argv.len() == env { + return None; + } + let Some(tail) = self.session_free_tail(argv) else { + return Some(argv.to_vec()); + }; + let mut out: Vec = argv[..env].to_vec(); + out.push(self.binary().to_string()); + let mut takes_value = false; + for t in tail { + let is_flag = t.starts_with('-') && t.len() > 1; + if is_flag { + out.push(t.to_string()); + takes_value = !t.contains('='); + } else if takes_value { + out.push(t.to_string()); + takes_value = false; + } + } + Some(out) + } + #[cfg_attr(not(test), allow(dead_code))] pub fn detect_from_argv(argv: &[String]) -> Option { Self::detect_from_argv_with(argv, &HashMap::new()) @@ -625,6 +686,20 @@ impl CLIAgent { return Some(agent); } + // A wrapper that is a script runs as its interpreter, so the name the + // user mapped is the script's, one token in: `bash ~/bin/cc`, + // `node ~/bin/cc.js`. Only the mapped names are looked for there — the + // built-in ones already have the scan below. + if (is_interpreter(launcher_stem) || is_shell(launcher_stem)) + && let Some(agent) = rest + .clone() + .find(|a| !a.starts_with('-') && !is_env_assignment(a)) + .and_then(|script| custom.get(&base_stem(script).to_ascii_lowercase())) + .and_then(|slug| CLIAgent::from_slug(slug)) + { + return Some(agent); + } + if is_interpreter(launcher_stem) { for arg in rest { if arg.starts_with('-') { @@ -719,6 +794,99 @@ fn is_interpreter(stem: &str) -> bool { ) } +fn is_shell(stem: &str) -> bool { + matches!( + stem.to_ascii_lowercase().as_str(), + "sh" | "bash" | "zsh" | "dash" | "ksh" | "fish" + ) +} + +/// The program a launch command line runs: its first word that is not an +/// environment assignment. +pub fn launch_program(command: &str) -> Option { + command_argv(command) + .into_iter() + .find(|t| !is_env_assignment(t)) +} + +/// The wrapper names `agent_launch` implies, in the shape `agent_commands` +/// takes: `{"claude": "cc --fast"}` means a `cc` in a pane is Claude Code. +/// +/// This is how a pane opened by a quick launch through a wrapper is still +/// recognised without the user also writing an `agent_commands` entry: the +/// launch typed exactly this command, so its program is known to be that +/// agent. A program that is already an agent's own name needs no entry, and +/// an interpreter (`npx`, `node`, `env`, …) or a shell is never mapped +/// wholesale — `npx` is not Claude Code just because Claude Code is one of the +/// things it can run. +pub fn launch_aliases(overrides: &HashMap) -> HashMap { + let mut out = HashMap::new(); + for (slug, line) in overrides { + let Some(agent) = CLIAgent::from_slug(slug) else { + continue; + }; + let Some(program) = launch_program(line) else { + continue; + }; + let stem = base_stem(&program).to_ascii_lowercase(); + if stem.is_empty() + || CLIAgent::match_token(&stem).is_some() + || is_interpreter(&stem) + || is_shell(&stem) + { + continue; + } + out.insert(stem, agent.slug().to_string()); + } + out +} + +/// Whether `program` names an executable file, either directly (it has a +/// directory in it) or through one of the directories in `path`. +/// +/// On Windows a bare name is also tried with the extensions an npm or winget +/// install leaves behind, since `claude` there is really `claude.cmd`. +pub fn program_on_path(program: &str, path: &std::ffi::OsStr) -> bool { + let program = program.trim(); + if program.is_empty() { + return false; + } + if program.contains(['/', '\\']) { + // The launch is typed into a shell, which expands a leading `~/`. + let home = program + .strip_prefix("~/") + .and_then(|rest| Some(std::path::PathBuf::from(std::env::var_os("HOME")?).join(rest))); + return is_executable(home.as_deref().unwrap_or(std::path::Path::new(program))); + } + let exts: &[&str] = if cfg!(windows) { + &["", ".exe", ".cmd", ".bat", ".ps1"] + } else { + &[""] + }; + std::env::split_paths(path).any(|dir| { + exts.iter() + .any(|ext| is_executable(&dir.join(format!("{program}{ext}")))) + }) +} + +fn is_executable(path: &std::path::Path) -> bool { + let Ok(meta) = std::fs::metadata(path) else { + return false; + }; + if !meta.is_file() { + return false; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + meta.permissions().mode() & 0o111 != 0 + } + #[cfg(not(unix))] + { + true + } +} + pub const AGENT_EVENT_SENTINEL: &str = "tty7://cli-agent"; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -2253,4 +2421,160 @@ mod tests { "\"waiting\"" ); } + + fn overrides(pairs: &[(&str, &str)]) -> HashMap { + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() + } + + #[test] + fn a_launch_is_the_bare_binary_unless_overridden() { + let none = HashMap::new(); + assert_eq!(CLIAgent::Claude.launch_command(&none), "claude"); + assert_eq!(CLIAgent::Codex.launch_command(&none), "codex"); + assert_eq!(CLIAgent::Cursor.launch_command(&none), "cursor-agent"); + assert_eq!(CLIAgent::Antigravity.launch_command(&none), "agy"); + assert_eq!(CLIAgent::QoderCLI.launch_command(&none), "qoder"); + + let set = overrides(&[ + ("claude", " claude --dangerously-skip-permissions "), + // Keys are read the way `from_slug` reads them. + ("Codex", "codex --model o3"), + // A blank entry is no override at all. + ("gemini", " "), + ]); + assert_eq!( + CLIAgent::Claude.launch_command(&set), + "claude --dangerously-skip-permissions" + ); + assert_eq!(CLIAgent::Codex.launch_command(&set), "codex --model o3"); + assert_eq!(CLIAgent::Gemini.launch_command(&set), "gemini"); + assert_eq!(CLIAgent::Aider.launch_command(&set), "aider"); + } + + #[test] + fn every_agent_launches_as_a_name_it_is_detected_by() { + let none = HashMap::new(); + for agent in CLIAgent::ALL { + let argv = command_argv(&agent.launch_command(&none)); + assert_eq!( + CLIAgent::detect_from_argv(&argv), + Some(agent), + "{agent:?} would launch as something detection does not know" + ); + } + } + + #[test] + fn a_wrapper_named_by_a_launch_override_is_detected_as_its_agent() { + let aliases = launch_aliases(&overrides(&[("claude", "FOO=1 cc --fast")])); + // Run as a binary, and run as a script under its interpreter. + for run in [ + argv(&["cc", "--fast"]), + argv(&["/Users/me/bin/cc", "--fast"]), + argv(&["/bin/bash", "/Users/me/bin/cc", "--fast"]), + argv(&["node", "/Users/me/bin/cc.js", "--fast"]), + ] { + assert_eq!( + CLIAgent::detect_from_argv_with(&run, &aliases), + Some(CLIAgent::Claude), + "{run:?}" + ); + } + // Without the override the same wrapper is nobody. + assert_eq!( + CLIAgent::detect_from_argv_with(&argv(&["cc", "--fast"]), &HashMap::new()), + None + ); + } + + #[test] + fn a_launch_override_never_maps_an_interpreter_or_a_real_agent_name() { + let aliases = launch_aliases(&overrides(&[ + ("claude", "npx @anthropic-ai/claude-code"), + ("codex", "bash -lc codex"), + // Already detected as itself; mapping it would add nothing. + ("gemini", "gemini --yolo"), + ("not-an-agent", "cc"), + ])); + assert!(aliases.is_empty(), "{aliases:?}"); + // A shell running something else is still nobody. + assert_eq!( + CLIAgent::detect_from_argv_with( + &argv(&["bash", "./build.sh"]), + &launch_aliases(&overrides(&[("claude", "cc")])) + ), + None + ); + } + + #[test] + fn launch_args_drop_the_session_and_the_prompt_but_keep_the_flags() { + assert_eq!( + CLIAgent::Claude.launch_argv_for_default(&argv(&[ + "node", + "/opt/homebrew/lib/node_modules/@anthropic-ai/claude-code/cli.js", + "--model", + "opus", + "--resume", + "abc-123", + "--append-system-prompt", + "be terse", + "fix the bug", + ])), + Some(argv(&[ + "claude", + "--model", + "opus", + "--append-system-prompt", + "be terse", + ])) + ); + assert_eq!( + CLIAgent::Codex.launch_argv_for_default(&argv(&[ + "codex", + "resume", + "019a", + "--model=o3", + "prompt", + ])), + Some(argv(&["codex", "--model=o3"])) + ); + // A wrapper cannot be taken apart; what was typed is what launched it. + assert_eq!( + CLIAgent::Claude.launch_argv_for_default(&argv(&["cc", "--fast", "x"])), + Some(argv(&["cc", "--fast", "x"])) + ); + assert_eq!(CLIAgent::Claude.launch_argv_for_default(&[]), None); + } + + #[test] + fn program_on_path_finds_only_executables() { + let dir = tempfile::TempDir::new().unwrap(); + let bin = dir.path().join("claude"); + std::fs::write(&bin, "#!/bin/sh\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + std::fs::write(dir.path().join("codex"), "not executable").unwrap(); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert!(!program_on_path("codex", dir.path().as_os_str())); + } + std::fs::create_dir(dir.path().join("gemini")).unwrap(); + let path = + std::env::join_paths([std::path::Path::new("/nonexistent"), dir.path()]).unwrap(); + assert!(program_on_path("claude", &path)); + assert!( + !program_on_path("gemini", &path), + "a directory is not a program" + ); + assert!(!program_on_path("aider", &path)); + assert!(program_on_path( + bin.to_str().unwrap(), + std::ffi::OsStr::new("") + )); + assert!(!program_on_path("", &path)); + } } diff --git a/crates/tty7-core/src/core/config.rs b/crates/tty7-core/src/core/config.rs index fb81b7a9..5c6b9a18 100644 --- a/crates/tty7-core/src/core/config.rs +++ b/crates/tty7-core/src/core/config.rs @@ -213,6 +213,11 @@ pub struct Config { pub new_tab_position: NewTabPosition, #[serde(default, deserialize_with = "de_lenient")] pub tab_bar_position: TabBarPosition, + /// What an SSH pane's tab is called (#726). Only the name the tab shows: + /// the titles the remote side sets are still read and kept, and win again + /// the moment this is back on [`SshTabTitle::Dynamic`]. + #[serde(default, deserialize_with = "de_lenient")] + pub ssh_tab_title: SshTabTitle, #[serde(default = "default_sidebar_width")] pub sidebar_width: f32, #[serde(default)] @@ -246,6 +251,12 @@ pub struct Config { /// impression than one they asked for. #[serde(default)] pub scm_graph_expanded: bool, + /// Whether the source control panel lists changed files as a directory + /// tree rather than one flat run per group. Off by default: the flat list + /// is what a handful of changes reads best as, and the tree is for the + /// agent run that touched sixty files across a dozen modules. + #[serde(default)] + pub scm_changes_tree: bool, /// Whether a file opens in the code panel with soft wrap on. Not a /// setting anyone picks up front: it is whatever the status bar's Wrap /// toggle (or `ToggleDocumentWrap`) was last left at, so the next file @@ -365,20 +376,45 @@ pub struct Config { #[serde(default)] pub env: HashMap, - #[serde(default)] + /// The saved SSH hosts. They live in [`SERVERS_FILE`], not in + /// `config.json` (#911): people sync `config.json` between machines for its + /// colours and keys, and a list of servers is not something every machine + /// should carry. Still read from `config.json` so [`Config::load`] can + /// move an older file's hosts across, never written back to it. + #[serde(default, skip_serializing)] pub ssh_profiles: Vec, #[serde(default = "default_true")] pub verify_host_keys: bool, #[serde(default)] pub ssh_warn_on_close: bool, - #[serde(default)] + /// How often each saved host is used, keyed by its id — which means + /// nothing without the hosts it counts, so it lives in [`SERVERS_FILE`] + /// beside them. + #[serde(default, skip_serializing)] pub ssh_profile_frecency: HashMap, + /// How often and how lately each shell was opened from the New Tab menu + /// or its palette command, keyed by the label the inventory shows it + /// under. Orders the menu's short list of shells the way + /// `ssh_profile_frecency` orders its hosts. + #[serde(default)] + pub shell_frecency: HashMap, #[serde(default)] pub command_frecency: HashMap, #[serde(default)] pub agent_commands: HashMap, + /// The command line a quick launch types for an agent, keyed by its slug: + /// `{"claude": "claude --dangerously-skip-permissions"}`. An agent with no + /// entry is launched as its bare binary. A wrapper named here is detected + /// as that agent without an `agent_commands` entry of its own. + #[serde(default)] + pub agent_launch: HashMap, + /// How often and how recently each agent (by slug) was launched or seen + /// running — what orders the quick-launch commands and picks the agent + /// "New Agent Tab" opens. + #[serde(default)] + pub agent_frecency: HashMap, #[serde(default = "default_true")] pub restore_agent_sessions: bool, /// Give each pane its own shell history instead of one file every pane @@ -400,6 +436,45 @@ pub struct Config { /// permanent data loss (#537). Cleared only by a load that parses. #[serde(skip)] pub quarantined: bool, + /// [`SERVERS_FILE`] is there but could not be read or parsed. The hosts + /// in hand are not the ones in it, so [`Config::save`] refuses to run + /// rather than write them over it — the same rule, for the same reason, + /// as [`Self::quarantined`]. + #[serde(skip)] + pub servers_unreadable: bool, +} + +/// Where the saved SSH hosts live, beside `config.json` (#911). +pub const SERVERS_FILE: &str = "servers.json"; + +/// What [`SERVERS_FILE`] holds. The keys are the ones `config.json` used to +/// carry, so a hand-moved block pastes straight across. +#[derive(Debug, Default, PartialEq, Deserialize)] +#[serde(default)] +struct ServersFile { + ssh_profiles: Vec, + ssh_profile_frecency: HashMap, +} + +#[derive(Serialize)] +struct ServersFileRef<'a> { + ssh_profiles: &'a [crate::core::ssh_profile::SshProfile], + ssh_profile_frecency: &'a HashMap, +} + +/// Whether a `config.json` still carries the keys that moved to +/// [`SERVERS_FILE`] — the cue for the one-time move. +#[derive(Deserialize)] +struct LegacyServerKeys { + #[serde(default)] + ssh_profiles: Option, + #[serde(default)] + ssh_profile_frecency: Option, +} + +fn has_legacy_server_keys(text: &str) -> bool { + serde_json::from_str::(strip_bom(text)) + .is_ok_and(|keys| keys.ssh_profiles.is_some() || keys.ssh_profile_frecency.is_some()) } #[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] @@ -477,6 +552,21 @@ pub enum TabBarPosition { Left, } +/// Where an SSH pane's tab takes its name from. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum SshTabTitle { + /// Whatever the remote shell or program titles itself, falling back to + /// the host's name until it says anything — what tty7 always did. + #[default] + Dynamic, + /// The saved host's name, the alias for a `~/.ssh/config` host, the + /// address typed for a quick connect. + ProfileName, + /// The address the connection dialled. + Hostname, +} + /// Native window backdrop material for the Windows GUI. Other platforms retain /// the value for config synchronization but do not use it for rendering. /// `Auto` keeps the legacy behavior where theme blur decides between blurred @@ -644,6 +734,7 @@ impl Default for Config { scrollback_limit: 10_000, new_tab_position: NewTabPosition::AfterCurrent, tab_bar_position: TabBarPosition::Left, + ssh_tab_title: SshTabTitle::Dynamic, sidebar_width: default_sidebar_width(), sidebar_collapsed: false, right_panel_visible: false, @@ -653,6 +744,7 @@ impl Default for Config { document_layout: DocumentLayout::default(), document_ratio: default_document_ratio(), scm_graph_expanded: false, + scm_changes_tree: false, editor_soft_wrap: false, editor_markdown_preview: false, sidebar_auto_grouping: true, @@ -691,11 +783,15 @@ impl Default for Config { verify_host_keys: true, ssh_warn_on_close: false, ssh_profile_frecency: HashMap::new(), + shell_frecency: HashMap::new(), command_frecency: HashMap::new(), agent_commands: HashMap::new(), + agent_launch: HashMap::new(), + agent_frecency: HashMap::new(), restore_agent_sessions: true, per_pane_history: false, quarantined: false, + servers_unreadable: false, } } } @@ -742,13 +838,42 @@ impl Config { /// app onto the result needs the outcome to keep a broken file from /// evicting the settings the app is running on. pub fn load_with_outcome() -> (Self, LoadOutcome) { - let Some(path) = Self::path() else { - return (Config::default(), LoadOutcome::Absent); - }; - let text = match std::fs::read_to_string(&path) { + match Self::path() { + Some(path) => Self::load_from(&path), + None => (Config::default(), LoadOutcome::Absent), + } + } + + /// [`Config::load_with_outcome`] for the `config.json` at `path`, with the + /// saved hosts read from the [`SERVERS_FILE`] beside it. + /// + /// A `config.json` from before the split still holds the hosts itself. + /// When there is no [`SERVERS_FILE`] yet they are moved into one, once: + /// the new file is written first and `config.json` rewritten without them + /// only after that has landed, so a failure anywhere leaves them where + /// they were. When both files are there the new one wins and the copy in + /// `config.json` is not read — it is what an older tty7 wrote after the + /// move, or a synced file brought in, and it falls out of `config.json` + /// at its next save. + pub(crate) fn load_from(path: &Path) -> (Self, LoadOutcome) { + let (mut cfg, outcome, text) = Self::load_config_file(path); + let servers = path.with_file_name(SERVERS_FILE); + let found = cfg.load_servers(&servers); + if !found + && let Some(text) = text + && has_legacy_server_keys(&text) + { + cfg.move_servers_out(&text, path, &servers); + } + (cfg, outcome) + } + + /// `config.json` alone, with the text it parsed from. + fn load_config_file(path: &Path) -> (Self, LoadOutcome, Option) { + let text = match std::fs::read_to_string(path) { Ok(text) => text, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - return (Config::default(), LoadOutcome::Absent); + return (Config::default(), LoadOutcome::Absent, None); } Err(e) => { // Unreadable is not unparseable, but the rule is the same: @@ -761,13 +886,13 @@ impl Config { ); let mut cfg = Config::default(); cfg.quarantined = true; - return (cfg, LoadOutcome::Unreadable); + return (cfg, LoadOutcome::Unreadable, None); } }; match serde_json::from_str::(strip_bom(&text)) { Ok(mut cfg) => { cfg.sanitize(); - (cfg, LoadOutcome::Parsed) + (cfg, LoadOutcome::Parsed, Some(text)) } Err(e) => { // The next `save` overwrites this file wholesale, so handing @@ -779,14 +904,83 @@ impl Config { "failed to parse config at {}: {e}; keeping it aside and using defaults", path.display() ); - quarantine(&path); + quarantine(path); let mut cfg = Config::default(); cfg.quarantined = true; - (cfg, LoadOutcome::Quarantined) + (cfg, LoadOutcome::Quarantined, None) } } } + /// Takes the hosts from the [`SERVERS_FILE`] at `path`, if there is one. + /// Returns whether there was — a file that is there but broken counts, so + /// that nothing is moved over the top of it. + fn load_servers(&mut self, path: &Path) -> bool { + let text = match std::fs::read_to_string(path) { + Ok(text) => text, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return false, + Err(e) => { + log::warn!( + "failed to read {}: {e}; saved SSH hosts unavailable, writes suppressed", + path.display() + ); + self.servers_unreadable = true; + return true; + } + }; + match serde_json::from_str::(strip_bom(&text)) { + Ok(servers) => { + self.ssh_profiles = servers.ssh_profiles; + self.ssh_profile_frecency = servers.ssh_profile_frecency; + } + Err(e) => { + log::warn!( + "failed to parse {}: {e}; keeping it aside, writes suppressed", + path.display() + ); + quarantine(path); + self.servers_unreadable = true; + } + } + true + } + + /// The one-time move of the hosts out of `config.json` (#911). New file + /// first: until it has landed, `config.json` is the only copy. + /// + /// `config.json` is then rewritten from its own `text` with just those + /// two keys taken out, not from `self` the way a save writes it. Nobody + /// asked for this write, so it should change nothing else: a hand-kept + /// file stays the handful of keys it was rather than growing every + /// default, and a key this build does not know — one a newer tty7 on a + /// synced machine wrote — survives. + fn move_servers_out(&self, text: &str, config: &Path, servers: &Path) { + if let Err(e) = self.write_servers(servers) { + log::warn!( + "could not move SSH hosts to {}: {e}; leaving them in config.json", + servers.display() + ); + return; + } + let rewrite = || -> std::io::Result<()> { + let mut value: serde_json::Value = + serde_json::from_str(strip_bom(text)).map_err(std::io::Error::other)?; + if let Some(object) = value.as_object_mut() { + object.remove("ssh_profiles"); + object.remove("ssh_profile_frecency"); + } + let text = serde_json::to_string_pretty(&value).map_err(std::io::Error::other)?; + write_atomic(config, text.as_bytes()) + }; + match rewrite() { + Ok(()) => log::info!("moved SSH hosts from config.json to {}", servers.display()), + Err(e) => log::warn!( + "moved SSH hosts to {}, but could not rewrite config.json: {e}", + servers.display() + ), + } + } + /// What a clicked file link should open in, with the pre-setting default /// filled in for a `Config` that never went through [`Self::sanitize`]. pub fn file_open_mode(&self) -> LinkFileOpen { @@ -874,18 +1068,62 @@ impl Config { /// Persist without hiding a failure from an interactive settings editor. pub fn try_save(&self) -> std::io::Result<()> { + let path = Self::path() + .ok_or_else(|| std::io::Error::other("configuration directory is unavailable"))?; + self.try_save_to(&path) + } + + /// [`Config::try_save`] for the `config.json` at `path`, and the + /// [`SERVERS_FILE`] beside it. + pub(crate) fn try_save_to(&self, path: &Path) -> std::io::Result<()> { if self.quarantined { return Err(std::io::Error::other( "the existing configuration could not be read; repair it before saving", )); } - let path = Self::path() - .ok_or_else(|| std::io::Error::other("configuration directory is unavailable"))?; + if self.servers_unreadable { + return Err(std::io::Error::other(format!( + "{SERVERS_FILE} could not be read; repair it before saving" + ))); + } if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } + // Hosts first, for the same reason the move out of `config.json` + // writes them first: a `config.json` that lands without them while + // their own file failed to would have dropped them. + self.write_servers(&path.with_file_name(SERVERS_FILE))?; + self.write_config(path) + } + + fn write_config(&self, path: &Path) -> std::io::Result<()> { let text = serde_json::to_string_pretty(self).map_err(std::io::Error::other)?; - write_atomic(&path, text.as_bytes()) + write_atomic(path, text.as_bytes()) + } + + /// Writes the hosts to their own file — unless it already says exactly + /// this, which is nearly every save: a dragged divider has nothing to do + /// with the hosts, and rewriting the file anyway would wake the watcher a + /// second time. No hosts and no file stays no file. + fn write_servers(&self, path: &Path) -> std::io::Result<()> { + let on_disk = match std::fs::read_to_string(path) { + Ok(text) => serde_json::from_str::(strip_bom(&text)).ok(), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Some(ServersFile::default()), + Err(_) => None, + }; + if on_disk.as_ref().is_some_and(|held| { + held.ssh_profiles == self.ssh_profiles + && held.ssh_profile_frecency == self.ssh_profile_frecency + }) { + return Ok(()); + } + let text = serde_json::to_string_pretty(&ServersFileRef { + ssh_profiles: &self.ssh_profiles, + ssh_profile_frecency: &self.ssh_profile_frecency, + }) + .map_err(std::io::Error::other)?; + // Host names, users and key paths: nobody else's business. + write_atomic_private(path, text.as_bytes()) } fn path() -> Option { @@ -1170,15 +1408,44 @@ pub fn extra_env() -> HashMap { Config::load().env } -pub fn agent_commands_cached() -> &'static HashMap { - static CACHE: std::sync::OnceLock> = std::sync::OnceLock::new(); - CACHE.get_or_init(|| { - Config::load() - .agent_commands - .into_iter() - .map(|(k, v)| (k.to_ascii_lowercase(), v)) - .collect() - }) +/// Every wrapper name the daemon should read as an agent: `agent_commands`, +/// plus the programs `agent_launch` entries run (see +/// [`crate::core::cli_agent::launch_aliases`]). An explicit `agent_commands` +/// entry wins over an implied one. +/// +/// Read again whenever `config.json` changes rather than once per process: the +/// daemon outlives any number of edits, and a launch override added while it +/// runs has to be recognised by the very next launch that uses it. +pub fn agent_detection_aliases() -> std::sync::Arc> { + type Stamp = Option<(std::time::SystemTime, u64)>; + static CACHE: std::sync::Mutex>)>> = + std::sync::Mutex::new(None); + let stamp: Stamp = Config::path() + .and_then(|p| std::fs::metadata(p).ok()) + .and_then(|m| Some((m.modified().ok()?, m.len()))); + let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner()); + if let Some((seen, aliases)) = cache.as_ref() + && *seen == stamp + { + return aliases.clone(); + } + let cfg = Config::load(); + let aliases = std::sync::Arc::new(detection_aliases(&cfg.agent_commands, &cfg.agent_launch)); + *cache = Some((stamp, aliases.clone())); + aliases +} + +/// [`agent_detection_aliases`] over the two maps it reads, for a caller that +/// already holds them. +pub fn detection_aliases( + commands: &HashMap, + launch: &HashMap, +) -> HashMap { + let mut aliases = crate::core::cli_agent::launch_aliases(launch); + for (name, slug) in commands { + aliases.insert(name.to_ascii_lowercase(), slug.clone()); + } + aliases } fn default_preset() -> String { @@ -1405,8 +1672,12 @@ mod tests { last_used: 42, }, ); - let json = serde_json::to_string(&cfg).unwrap(); - let back: Config = serde_json::from_str(&json).unwrap(); + // The count travels in the servers file now (#911), so the round + // trip is through disk rather than through `config.json`'s serde. + let dir = tempfile::tempdir().unwrap(); + let config = dir.path().join("config.json"); + cfg.try_save_to(&config).unwrap(); + let (back, _) = Config::load_from(&config); assert!(back.ssh_warn_on_close); assert_eq!(back.ssh_profile_frecency.get(&id).unwrap().count, 4); } @@ -2517,4 +2788,273 @@ mod tests { std::fs::write(exe_dir.path().join(PORTABLE_DATA_DIR), "").unwrap(); assert_eq!(portable_data_dir(exe_dir.path()), None); } + + #[test] + fn launch_overrides_teach_the_daemon_their_wrappers_as_the_file_changes() { + let _guard = lock_config_file(); + pin_config_dir(); + let mut cfg = Config::default(); + cfg.agent_launch + .insert("claude".to_string(), "cc --fast".to_string()); + cfg.save(); + let aliases = agent_detection_aliases(); + assert_eq!(aliases.get("cc").map(String::as_str), Some("claude")); + + // An edit made while the daemon runs is picked up on the next read, + // and an explicit `agent_commands` entry outranks an implied one. + cfg.agent_launch + .insert("codex".to_string(), "/opt/bin/cx".to_string()); + cfg.agent_commands + .insert("CC".to_string(), "gemini".to_string()); + cfg.save(); + let aliases = agent_detection_aliases(); + assert_eq!(aliases.get("cc").map(String::as_str), Some("gemini")); + assert_eq!(aliases.get("cx").map(String::as_str), Some("codex")); + + let back: Config = serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap(); + assert_eq!(back.agent_launch, cfg.agent_launch); + Config::default().save(); + } + + fn host(name: &str, address: &str) -> crate::core::ssh_profile::SshProfile { + let mut profile = crate::core::ssh_profile::SshProfile::new(name); + profile.host = address.to_string(); + profile.user = "deploy".to_string(); + profile + } + + /// A `config.json` from before #911, holding hosts, a usage count, a key + /// this build has never heard of, and the handful of settings the user + /// actually wrote. + fn legacy_config(profile: &crate::core::ssh_profile::SshProfile) -> String { + let usage = serde_json::json!({ profile.id.to_string(): { "count": 3, "last_used": 5 } }); + serde_json::to_string_pretty(&serde_json::json!({ + "font_size": 18.0, + "a_key_from_a_newer_build": { "x": 1 }, + "ssh_profiles": [profile], + "ssh_profile_frecency": usage, + "keybindings": { "SplitRight": "cmd-d" }, + })) + .unwrap() + } + + #[test] + fn the_hosts_move_out_of_config_json_once_and_nothing_else_moves() { + let dir = tempfile::tempdir().unwrap(); + let config = dir.path().join("config.json"); + let servers = dir.path().join(SERVERS_FILE); + let profile = host("prod-web", "10.0.0.5"); + std::fs::write(&config, legacy_config(&profile)).unwrap(); + + let (cfg, outcome) = Config::load_from(&config); + assert_eq!(outcome, LoadOutcome::Parsed); + assert_eq!(cfg.ssh_profiles, vec![profile.clone()]); + assert_eq!(cfg.ssh_profile_frecency[&profile.id].count, 3); + assert_eq!(cfg.font_size, 18.0); + + let moved: ServersFile = + serde_json::from_str(&std::fs::read_to_string(&servers).unwrap()).unwrap(); + assert_eq!(moved.ssh_profiles, vec![profile.clone()]); + assert_eq!(moved.ssh_profile_frecency[&profile.id].count, 3); + + let left: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&config).unwrap()).unwrap(); + let left = left.as_object().unwrap(); + assert!(!left.contains_key("ssh_profiles")); + assert!(!left.contains_key("ssh_profile_frecency")); + // The rest of the file is exactly what it was: nothing the user did + // not write appears, and nothing this build cannot read disappears. + assert_eq!(left["font_size"], serde_json::json!(18.0)); + assert_eq!( + left["a_key_from_a_newer_build"], + serde_json::json!({ "x": 1 }) + ); + assert_eq!( + left["keybindings"], + serde_json::json!({ "SplitRight": "cmd-d" }) + ); + assert_eq!(left.len(), 3, "the move must not expand the file: {left:?}"); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + let mode = std::fs::metadata(&servers).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600); + } + + // Once is once: a second load reads the new file and writes nothing. + let config_after = std::fs::read(&config).unwrap(); + let servers_after = std::fs::read(&servers).unwrap(); + let (again, _) = Config::load_from(&config); + assert_eq!(again.ssh_profiles, vec![profile]); + assert_eq!(std::fs::read(&config).unwrap(), config_after); + assert_eq!(std::fs::read(&servers).unwrap(), servers_after); + } + + #[test] + fn with_both_files_present_the_servers_file_wins() { + let dir = tempfile::tempdir().unwrap(); + let config = dir.path().join("config.json"); + let servers = dir.path().join(SERVERS_FILE); + let current = host("current", "10.0.0.1"); + let stale = host("stale", "10.0.0.2"); + let cfg = Config { + ssh_profiles: vec![current.clone()], + ..Config::default() + }; + cfg.try_save_to(&config).unwrap(); + // An older tty7, or a synced file, puts hosts back in config.json. + std::fs::write(&config, legacy_config(&stale)).unwrap(); + let servers_before = std::fs::read(&servers).unwrap(); + + let (loaded, _) = Config::load_from(&config); + assert_eq!(loaded.ssh_profiles, vec![current.clone()]); + assert!(loaded.ssh_profile_frecency.is_empty()); + assert_eq!(std::fs::read(&servers).unwrap(), servers_before); + assert_eq!( + std::fs::read_to_string(&config).unwrap(), + legacy_config(&stale), + "loading is not the moment to rewrite config.json" + ); + + // The stale copy falls out at the next ordinary save. + loaded.try_save_to(&config).unwrap(); + let text = std::fs::read_to_string(&config).unwrap(); + assert!(!text.contains("ssh_profiles"), "{text}"); + assert_eq!(Config::load_from(&config).0.ssh_profiles, vec![current]); + } + + #[test] + fn a_config_json_without_hosts_is_left_alone() { + let dir = tempfile::tempdir().unwrap(); + let config = dir.path().join("config.json"); + std::fs::write(&config, "{\"font_size\": 18}").unwrap(); + + let (cfg, _) = Config::load_from(&config); + assert!(cfg.ssh_profiles.is_empty()); + assert_eq!( + std::fs::read_to_string(&config).unwrap(), + "{\"font_size\": 18}" + ); + assert!(!dir.path().join(SERVERS_FILE).exists()); + } + + #[test] + fn an_empty_hosts_key_is_dropped_without_making_a_servers_file() { + let dir = tempfile::tempdir().unwrap(); + let config = dir.path().join("config.json"); + std::fs::write(&config, "{\"font_size\": 18, \"ssh_profiles\": []}").unwrap(); + + Config::load_from(&config); + let text = std::fs::read_to_string(&config).unwrap(); + assert!(!text.contains("ssh_profiles"), "{text}"); + assert!(text.contains("font_size"), "{text}"); + assert!(!dir.path().join(SERVERS_FILE).exists()); + } + + #[test] + fn saving_writes_the_hosts_to_their_own_file_and_nowhere_else() { + let dir = tempfile::tempdir().unwrap(); + let config = dir.path().join("config.json"); + let servers = dir.path().join(SERVERS_FILE); + + // No hosts, no file. + Config::default().try_save_to(&config).unwrap(); + assert!(!servers.exists()); + + let profile = host("prod-web", "10.0.0.5"); + let mut cfg = Config { + ssh_profiles: vec![profile.clone()], + ..Config::default() + }; + cfg.ssh_profile_frecency.insert( + profile.id, + ProfileUsage { + count: 2, + last_used: 9, + }, + ); + cfg.try_save_to(&config).unwrap(); + let text = std::fs::read_to_string(&config).unwrap(); + assert!(!text.contains("ssh_profile"), "{text}"); + assert!(!text.contains("10.0.0.5"), "{text}"); + + let (loaded, _) = Config::load_from(&config); + assert_eq!(loaded.ssh_profiles, vec![profile]); + assert_eq!(loaded.ssh_profile_frecency, cfg.ssh_profile_frecency); + + // Deleting the last host empties the file rather than leaving it be. + let mut emptied = loaded.clone(); + emptied.ssh_profiles.clear(); + emptied.ssh_profile_frecency.clear(); + emptied.try_save_to(&config).unwrap(); + assert!(Config::load_from(&config).0.ssh_profiles.is_empty()); + } + + #[test] + fn a_broken_servers_file_is_kept_aside_and_never_overwritten() { + let dir = tempfile::tempdir().unwrap(); + let config = dir.path().join("config.json"); + let servers = dir.path().join(SERVERS_FILE); + let profile = host("prod-web", "10.0.0.5"); + std::fs::write(&config, legacy_config(&profile)).unwrap(); + std::fs::write(&servers, "{ nope").unwrap(); + + let (cfg, outcome) = Config::load_from(&config); + assert_eq!(outcome, LoadOutcome::Parsed, "config.json itself is fine"); + assert!(cfg.servers_unreadable); + assert_eq!( + std::fs::read_to_string(servers.with_extension("json.corrupt")).unwrap(), + "{ nope" + ); + // Nothing moved on top of it, and nothing saves over either file. + assert_eq!( + std::fs::read_to_string(&config).unwrap(), + legacy_config(&profile) + ); + assert!(cfg.try_save_to(&config).is_err()); + assert_eq!(std::fs::read_to_string(&servers).unwrap(), "{ nope"); + assert_eq!( + std::fs::read_to_string(&config).unwrap(), + legacy_config(&profile) + ); + } + + #[test] + fn the_hosts_never_serialize_into_config_json_but_an_old_file_still_reads() { + let mut cfg: Config = serde_json::from_str( + r#"{"ssh_profiles":[{"name":"a","host":"h"}],"verify_host_keys":false}"#, + ) + .unwrap(); + assert_eq!(cfg.ssh_profiles.len(), 1); + assert!(!cfg.verify_host_keys); + cfg.ssh_profile_frecency + .insert(cfg.ssh_profiles[0].id, ProfileUsage::default()); + let value = serde_json::to_value(&cfg).unwrap(); + assert!(value.get("ssh_profiles").is_none()); + assert!(value.get("ssh_profile_frecency").is_none()); + // The SSH preferences are settings like any other and stay put. + assert_eq!(value["verify_host_keys"], serde_json::json!(false)); + } + + #[test] + fn ssh_tab_title_defaults_to_dynamic_and_round_trips_leniently() { + let cfg: Config = serde_json::from_str("{}").unwrap(); + assert_eq!(cfg.ssh_tab_title, SshTabTitle::Dynamic); + for (raw, mode) in [ + ("\"dynamic\"", SshTabTitle::Dynamic), + ("\"profile-name\"", SshTabTitle::ProfileName), + ("\"hostname\"", SshTabTitle::Hostname), + ("\"sideways\"", SshTabTitle::Dynamic), + ("7", SshTabTitle::Dynamic), + ] { + let cfg: Config = + serde_json::from_str(&format!("{{\"ssh_tab_title\": {raw}, \"font_size\": 20}}")) + .unwrap(); + assert_eq!(cfg.ssh_tab_title, mode, "{raw}"); + assert_eq!(cfg.font_size, 20.0, "a bad value must not cost the file"); + let back: Config = serde_json::from_value(serde_json::to_value(&cfg).unwrap()).unwrap(); + assert_eq!(back.ssh_tab_title, mode); + } + } } diff --git a/crates/tty7-core/src/core/machine.rs b/crates/tty7-core/src/core/machine.rs index 773775db..7040d3ab 100644 --- a/crates/tty7-core/src/core/machine.rs +++ b/crates/tty7-core/src/core/machine.rs @@ -177,6 +177,19 @@ pub struct Tab { #[serde(default, skip_serializing_if = "Option::is_none")] pub last_auto: Option, pub root: PaneNode, + /// The tab was put to sleep: its panes were stopped to give their memory + /// back, and it stays in the workspace to be woken later (#762). + /// + /// The panes keep their ids in `root` and their records in + /// [`Machine::panes`] — cwd, shell, agent session — because waking is a + /// restore of exactly those panes, through the same path a reboot takes. + /// That is also what keeps the sweeps off them: a pane the tree still + /// names is one whose stored screen and history are kept. + /// + /// Left out of the document while false, so a tree written by this build + /// reads the same to one that predates the field. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub hibernated: bool, } impl Tab { @@ -187,6 +200,7 @@ impl Tab { group: None, last_auto: None, root: PaneNode::Leaf { pane }, + hibernated: false, } } } @@ -772,6 +786,43 @@ impl MachineStore { }) } + /// Put a tab to sleep, or mark it awake again. Answers the panes the tab + /// holds, which for a tab going to sleep are the ones whoever asked is now + /// expected to stop — see [`ControlRequest::TabSetHibernated`]. + /// + /// Only the mark changes here. The panes stay in the tab and in the pane + /// list, so the facts a wake needs survive the processes: waking hands the + /// same ids to the ordinary restore, which spawns successors and swaps + /// them in with `pane_replace`. Setting the mark a tab already has is not + /// a change and raises no delta. + /// + /// The delta is `TabRestructured` carrying the whole tab rather than a + /// variant of its own, so a client that predates the mark still decodes + /// it — it reads the same tab back, minus a field it does not know. + /// + /// [`ControlRequest::TabSetHibernated`]: crate::daemon::control::ControlRequest::TabSetHibernated + pub fn tab_set_hibernated( + &self, + workspace: WorkspaceId, + tab: TabId, + hibernated: bool, + origin: Option, + ) -> io::Result> { + self.mutate(origin, |m| { + let t = find_tab(m, workspace, tab)?; + let panes = t.root.pane_ids(); + if t.hibernated == hibernated { + return Ok((panes, Vec::new())); + } + t.hibernated = hibernated; + let delta = LayoutDelta::TabRestructured { + tab: t.clone(), + pane: None, + }; + Ok((panes, vec![(workspace, delta)])) + }) + } + pub fn tab_set_group( &self, workspace: WorkspaceId, @@ -2921,4 +2972,101 @@ mod tests { assert_eq!(machine.workspaces[0].tabs[0].root.pane_ids(), vec![3]); assert!(machine.panes.is_empty()); } + + /// A tab put to sleep keeps its panes — ids in the layout, records in the + /// pane list — because waking is a restore of exactly those. Only the + /// orphan collection a close runs could take them, and a sleeping tab + /// still names them. + #[test] + fn a_sleeping_tab_keeps_its_panes_and_says_so_in_a_delta() { + let (store, _dir, ws, tab) = store_with_tab(); + store + .pane_split(ws, 1, Axis::Horizontal, 0.5, seed(2, "/work"), false, None) + .unwrap(); + let (_sub, heard) = recorded(&store); + + let panes = store.tab_set_hibernated(ws, tab.id, true, None).unwrap(); + + assert_eq!(panes, vec![1, 2]); + let m = store.machine(); + let asleep = &m.workspaces[0].tabs[0]; + assert!(asleep.hibernated); + assert_eq!(asleep.root.pane_ids(), vec![1, 2]); + assert!(m.panes.iter().any(|p| p.id == 1) && m.panes.iter().any(|p| p.id == 2)); + let heard = heard.lock().unwrap(); + assert_eq!(heard.len(), 1); + match &heard[0].1 { + LayoutDelta::TabRestructured { tab: t, pane: None } => { + assert_eq!(t.id, tab.id); + assert!(t.hibernated, "the delta carries the mark"); + } + other => panic!("expected TabRestructured, heard {other:?}"), + } + } + + #[test] + fn setting_the_mark_a_tab_already_has_raises_nothing() { + let (store, _dir, ws, tab) = store_with_tab(); + let (_sub, heard) = recorded(&store); + store.tab_set_hibernated(ws, tab.id, false, None).unwrap(); + assert!(heard.lock().unwrap().is_empty()); + + store.tab_set_hibernated(ws, tab.id, true, None).unwrap(); + store.tab_set_hibernated(ws, tab.id, true, None).unwrap(); + assert_eq!(heard.lock().unwrap().len(), 1); + + store.tab_set_hibernated(ws, tab.id, false, None).unwrap(); + assert!(!store.machine().workspaces[0].tabs[0].hibernated); + assert_eq!(heard.lock().unwrap().len(), 2); + } + + #[test] + fn hibernating_a_tab_that_is_not_there_is_refused() { + let (store, _dir, ws, _tab) = store_with_tab(); + let err = store + .tab_set_hibernated(ws, TabId::new(), true, None) + .unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::NotFound); + } + + /// Asleep has to outlive the daemon: a restart reads the tree back from + /// disk, and a tab that came back awake would be respawned on the spot — + /// every process the user put to sleep, started again unasked. + #[test] + fn a_sleeping_tab_is_still_asleep_after_the_tree_is_read_back() { + let (store, dir, ws, tab) = store_with_tab(); + let other = store + .tab_create(ws, None, seed(5, "/else"), None, None) + .unwrap(); + store.tab_set_hibernated(ws, tab.id, true, None).unwrap(); + drop(store); + + let reopened = MachineStore::open(dir.path().join(MACHINE_FILE)); + let m = reopened.machine(); + let tabs = &m.workspaces[0].tabs; + assert!(tabs.iter().find(|t| t.id == tab.id).unwrap().hibernated); + assert!(!tabs.iter().find(|t| t.id == other.id).unwrap().hibernated); + let record = m.panes.iter().find(|p| p.id == 1).unwrap(); + assert_eq!(record.cwd.as_deref(), Some("/work")); + assert!(!record.live); + } + + /// Awake is the default and stays out of the document, so a tree this + /// build writes for a tab that never slept reads the same to one that + /// predates the mark, and a tree from before it decodes as awake. + #[test] + fn the_mark_is_only_written_while_it_is_set() { + let tab = Tab::leaf(4); + let json = serde_json::to_string(&tab).unwrap(); + assert!(!json.contains("hibernated"), "{json}"); + let back: Tab = serde_json::from_str(&json).unwrap(); + assert!(!back.hibernated); + + let asleep = Tab { + hibernated: true, + ..Tab::leaf(4) + }; + let back: Tab = serde_json::from_str(&serde_json::to_string(&asleep).unwrap()).unwrap(); + assert!(back.hibernated); + } } diff --git a/crates/tty7-core/src/core/mod.rs b/crates/tty7-core/src/core/mod.rs index 43a72d97..e2ed7f97 100644 --- a/crates/tty7-core/src/core/mod.rs +++ b/crates/tty7-core/src/core/mod.rs @@ -13,6 +13,7 @@ pub mod kitty_graphics; pub mod logfile; pub mod machine; pub mod osc; +pub mod paste; pub mod path_spelling; pub mod proc; pub mod session; diff --git a/crates/tty7-core/src/core/paste.rs b/crates/tty7-core/src/core/paste.rs new file mode 100644 index 00000000..692dc32a --- /dev/null +++ b/crates/tty7-core/src/core/paste.rs @@ -0,0 +1,99 @@ +//! What a paste puts on the wire. +//! +//! Lives here rather than beside the GUI's clipboard handling because the GUI +//! is not its only sender: `tty7 send --paste` has to frame a payload exactly +//! the way a paste into the window does, and a second copy of the framing is +//! how the two would come to disagree about what sending multi-line text means. + +/// Opens a bracketed paste (DEC mode 2004). +pub const START: &[u8] = b"\x1b[200~"; +/// Closes one. +pub const END: &[u8] = b"\x1b[201~"; + +/// `payload` inside paste brackets, with every ESC taken out. +/// +/// The stripping is the point, not a cleanup: a payload that carries its own +/// `ESC[201~` would otherwise end the paste early, and whatever follows it +/// would reach the shell as typed input — which is how pasted text turns into +/// commands. No escape sequence survives the strip, so none can close the +/// bracket from inside. +pub fn bracket(payload: &[u8]) -> Vec { + let mut bytes = Vec::with_capacity(START.len() + payload.len() + END.len()); + bytes.extend_from_slice(START); + bytes.extend(payload.iter().copied().filter(|&b| b != 0x1b)); + bytes.extend_from_slice(END); + bytes +} + +/// The bytes a paste of `text` sends, for a pane that has (`bracketed`) or has +/// not switched bracketed paste on. +/// +/// CRLF folds to one line break either way, so a Windows clipboard pastes like +/// any other. Framed, the breaks stay `\n` and the shell inserts them as text. +/// Unframed there is no way to say "text" to a pty, so each break goes as the +/// `\r` a keyboard's Enter sends — the lines run one at a time, which is what +/// pasting into a terminal without the mode has always meant. +pub fn paste_bytes(text: &[u8], bracketed: bool) -> Vec { + let mut folded = Vec::with_capacity(text.len()); + let mut i = 0; + while i < text.len() { + if text[i] == b'\r' && text.get(i + 1) == Some(&b'\n') { + folded.push(b'\n'); + i += 2; + } else { + folded.push(text[i]); + i += 1; + } + } + if bracketed { + bracket(&folded) + } else { + for b in &mut folded { + if *b == b'\n' { + *b = b'\r'; + } + } + folded + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_payload_cannot_close_its_own_bracket() { + let out = bracket(b"a\x1b[201~; rm -rf /\nb"); + let inner = &out[START.len()..out.len() - END.len()]; + assert!(out.starts_with(START) && out.ends_with(END)); + assert!(!inner.contains(&0x1b), "{inner:?}"); + assert_eq!(inner, b"a[201~; rm -rf /\nb"); + } + + #[test] + fn crlf_folds_before_either_framing() { + assert_eq!(paste_bytes(b"a\r\nb\n", true), b"\x1b[200~a\nb\n\x1b[201~"); + assert_eq!(paste_bytes(b"a\r\nb\nc", false), b"a\rb\rc"); + } + + #[test] + fn a_lone_cr_is_left_alone() { + assert_eq!(paste_bytes(b"a\rb", true), b"\x1b[200~a\rb\x1b[201~"); + assert_eq!(paste_bytes(b"a\rb", false), b"a\rb"); + } + + #[test] + fn unframed_paste_keeps_escapes() { + // Only the frame needs protecting; without one there is nothing to + // close, and the GUI has always sent these bytes through untouched. + assert_eq!(paste_bytes(b"a\x1b[201~b", false), b"a\x1b[201~b"); + } + + #[test] + fn bytes_that_are_not_utf8_survive() { + assert_eq!( + paste_bytes(b"\xff\xfe", true), + b"\x1b[200~\xff\xfe\x1b[201~" + ); + } +} diff --git a/crates/tty7-core/src/core/session.rs b/crates/tty7-core/src/core/session.rs index 0b0bd9fa..4eed1d90 100644 --- a/crates/tty7-core/src/core/session.rs +++ b/crates/tty7-core/src/core/session.rs @@ -59,6 +59,16 @@ pub struct SessionTab { pub last_auto: Option, #[serde(skip)] pub tree_id: Option, + /// Asleep in the tree: comes back as a placeholder holding `pane`, and + /// nothing is spawned for it until it is woken. See + /// [`Tab::hibernated`](crate::core::machine::Tab::hibernated). + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub hibernated: bool, + /// What a sleeping tab is called while nothing in it runs to say — read + /// off the tree's pane records, the same way the switcher names a tab of + /// a window it does not own. + #[serde(skip)] + pub asleep_view: Option, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -379,6 +389,11 @@ pub struct WindowView { /// is another client's activity, not ours); opening one clears the mark. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub synced: bool, + /// Slugs of the coding agents seen running in this workspace, most recent + /// last. A remote workspace cannot be asked what is on its `PATH`, so + /// this is what its quick-launch list is made of. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub seen_agents: Vec, } impl Default for WindowView { @@ -392,6 +407,7 @@ impl Default for WindowView { label: None, subject: None, synced: false, + seen_agents: Vec::new(), } } } @@ -412,6 +428,17 @@ impl WindowView { self.host.is_some() } + /// Remember `slug` as running here, moving it to the end if it was + /// already known. Answers whether anything changed. + pub fn saw_agent(&mut self, slug: &str) -> bool { + if self.seen_agents.last().is_some_and(|s| s == slug) { + return false; + } + self.seen_agents.retain(|s| s != slug); + self.seen_agents.push(slug.to_string()); + true + } + pub fn host_id(&self) -> crate::host::HostId { match &self.host { Some(r) => r.host_id(), @@ -1167,4 +1194,29 @@ mod tests { views.views.retain(|w| w.id != b_id); assert_eq!(numbers(&views), vec![a_id, c_id]); } + + /// A remote workspace's quick-launch list is the agents seen running in + /// it, so the list has to be in `views.json` with the rest of the + /// workspace — an in-memory one would be empty after every restart. + #[test] + fn agents_seen_in_a_workspace_survive_a_restart() { + let _file = lock_session_file(); + pin_config_dir(); + let mut entry = remote_view("build-box"); + assert!(entry.saw_agent("claude")); + assert!(entry.saw_agent("codex")); + assert!(!entry.saw_agent("codex"), "already the most recent"); + assert!(entry.saw_agent("claude"), "moves to the end"); + let id = entry.id; + WindowViews { + active: Some(id), + views: vec![entry, view()], + } + .save(); + let loaded = WindowViews::load().expect("a saved views file should load back"); + assert_eq!(loaded.get(id).unwrap().seen_agents, vec!["codex", "claude"]); + // A workspace that has seen nothing writes nothing for it. + let text = std::fs::read_to_string(WindowViews::path().unwrap()).unwrap(); + assert_eq!(text.matches("seen_agents").count(), 1); + } } diff --git a/crates/tty7-core/src/core/ssh_profile.rs b/crates/tty7-core/src/core/ssh_profile.rs index 97a43d5c..651aaea0 100644 --- a/crates/tty7-core/src/core/ssh_profile.rs +++ b/crates/tty7-core/src/core/ssh_profile.rs @@ -153,6 +153,16 @@ pub struct ForwardRule { pub bind: HostPort, pub target: HostPort, pub description: String, + /// Whether the rule is opened with the connection. A switched-off rule + /// keeps its whole configuration and simply binds nothing, so one local + /// port can be pointed at different targets by switching rules rather + /// than retyping them. + /// + /// Missing from every profile saved before the switch existed, and those + /// rules were all live: the container's `#[serde(default)]` fills it from + /// `Default`, which says `true`, or upgrading would silently turn every + /// saved forward off. + pub enabled: bool, } impl Default for ForwardRule { @@ -162,6 +172,7 @@ impl Default for ForwardRule { bind: HostPort::default(), target: HostPort::default(), description: String::new(), + enabled: true, } } } @@ -569,6 +580,7 @@ mod tests { bind: HostPort::new("127.0.0.1", 8080), target: HostPort::new("10.0.0.1", 80), description: "web".to_string(), + enabled: false, }]; original.socks_proxy = Some(HostPort::new("proxy", 1080)); original.algorithms.kex = vec!["curve25519-sha256".to_string()]; @@ -597,6 +609,25 @@ mod tests { let m: AuthMode = serde_json::from_str("\"agent\"").unwrap(); assert_eq!(m, AuthMode::Agent); } + + #[test] + fn a_forward_rule_saved_before_the_switch_existed_loads_enabled() { + let old = r#"{"name":"box","host":"h","forwards":[ + {"kind":"local","bind":{"host":"127.0.0.1","port":8080}, + "target":{"host":"srv","port":80},"description":"web"} + ]}"#; + let p: SshProfile = serde_json::from_str(old).unwrap(); + assert_eq!(p.forwards.len(), 1); + assert!(p.forwards[0].enabled, "an old rule must stay live"); + assert!(ForwardRule::default().enabled); + + let mut off = p.forwards[0].clone(); + off.enabled = false; + let back: ForwardRule = + serde_json::from_str(&serde_json::to_string(&off).unwrap()).unwrap(); + assert!(!back.enabled, "a switched-off rule stays off across a save"); + assert_eq!(back, off); + } } fn new_id() -> Uuid { diff --git a/crates/tty7-core/src/core/term_modes.rs b/crates/tty7-core/src/core/term_modes.rs index b23763df..74bdf6e9 100644 --- a/crates/tty7-core/src/core/term_modes.rs +++ b/crates/tty7-core/src/core/term_modes.rs @@ -41,6 +41,9 @@ const TRACKED: &[u16] = &[ 1, 47, 1047, 1049, 1000, 1002, 1003, 1004, 1005, 1006, 1007, 1015, 1016, 2004, ]; +/// Bracketed paste: whether a paste may arrive framed as `ESC[200~ … ESC[201~`. +pub const BRACKETED_PASTE: u16 = 2004; + /// A CSI longer than this is not a mode set; keep the buffer bounded. const MAX_PARAMS: usize = 64; @@ -85,6 +88,11 @@ impl TerminalModes { &self.on } + /// Whether `mode` is on. Only ever true for a mode in [`TRACKED`]. + pub fn is_on(&self, mode: u16) -> bool { + self.on.contains(&mode) + } + /// The bytes that put a freshly reset terminal back into these modes, or /// `None` when there is nothing to restore. pub fn restore_bytes(&self) -> Option> { @@ -348,4 +356,16 @@ mod tests { modes.feed(b"\x1bc"); assert!(modes.is_empty()); } + + #[test] + fn bracketed_paste_reads_as_the_shell_last_left_it() { + // zle switches it on to read a line and off again before running it, + // so the answer has to follow both edges rather than latch the first. + let mut modes = TerminalModes::new(); + assert!(!modes.is_on(BRACKETED_PASTE)); + modes.feed(b"\x1b[?2004h"); + assert!(modes.is_on(BRACKETED_PASTE)); + modes.feed(b"\x1b[?2004l"); + assert!(!modes.is_on(BRACKETED_PASTE)); + } } diff --git a/crates/tty7-core/src/daemon/control.rs b/crates/tty7-core/src/daemon/control.rs index f36c7b76..3587f55c 100644 --- a/crates/tty7-core/src/daemon/control.rs +++ b/crates/tty7-core/src/daemon/control.rs @@ -146,6 +146,10 @@ pub mod feature { /// simply unknown here: the pane lives in the peer's registry, and the /// local daemon this client would otherwise ask has never heard of it. pub const PANE_PROCS: &str = "pane-procs"; + /// The peer can put a tab to sleep: mark it in the tree and stop its panes + /// while keeping their screens (#762). Needs both a tree and panes, so a + /// peer serving either alone does not say it. + pub const TAB_HIBERNATE: &str = "tab-hibernate"; } pub use crate::host::{Entry, MTime, Meta, Output, SearchHit}; @@ -305,6 +309,16 @@ pub enum ControlRequest { workspace: WorkspaceId, groups: crate::core::group_key::WorkspaceGroups, }, + /// Put a tab to sleep or mark it awake. Going to sleep, the peer also + /// stops every pane the tab holds, keeping each one's screen on disk for + /// the wake to restore; waking only clears the mark, because the panes a + /// wake brings up are spawned by the client like any restored pane. + /// Answers the tab's panes. Gated on [`feature::TAB_HIBERNATE`]. + TabSetHibernated { + workspace: WorkspaceId, + tab: TabId, + hibernated: bool, + }, PaneSplit { workspace: WorkspaceId, pane: u64, @@ -413,6 +427,7 @@ impl ControlRequest { | TabMove { .. } | TabSetGroup { .. } | WorkspaceSetGroups { .. } + | TabSetHibernated { .. } | PaneSplit { .. } | PaneClose { .. } | PaneSetRatio { .. } diff --git a/crates/tty7-core/src/daemon/pane.rs b/crates/tty7-core/src/daemon/pane.rs index 8c9650a8..6492ca1f 100644 --- a/crates/tty7-core/src/daemon/pane.rs +++ b/crates/tty7-core/src/daemon/pane.rs @@ -839,6 +839,10 @@ enum PaneBackend { } struct ForegroundProbes { + /// The pty's foreground process group: one ioctl, cheap enough to ask on + /// every read, and a change in it is what says the probes below would now + /// answer differently. + group: Box Option + Send>, remote: Box Option + Send>, agent: Box Option)>> + Send>, cwd: Box Option + Send>, @@ -1680,6 +1684,7 @@ impl DaemonPane { ); let fg_master = master.clone(); + let group_master = master.clone(); let remote_master = master.clone(); let agent_master = master.clone(); let cwd_master = master.clone(); @@ -1691,6 +1696,7 @@ impl DaemonPane { writer.clone(), move || foreground_command_running(&fg_master, shell_pid), ForegroundProbes { + group: Box::new(move || pty_foreground_pgid(&group_master)), remote: Box::new(move || foreground_remote_context(&remote_master)), agent: Box::new(move || foreground_agent(&agent_master)), cwd: Box::new(move || foreground_cwd(&cwd_master, shell_pid)), @@ -1914,6 +1920,7 @@ impl DaemonPane { writer.clone(), || false, ForegroundProbes { + group: Box::new(|| None), remote: Box::new(|| None), agent: Box::new(|| None), cwd: Box::new(|| None), @@ -1963,6 +1970,7 @@ impl DaemonPane { death: Arc, ) -> JoinHandle<()> { let ForegroundProbes { + group: foreground_group, remote: foreground_remote, agent: foreground_agent_fn, cwd: foreground_cwd_fn, @@ -1996,6 +2004,7 @@ impl DaemonPane { let mut tr_read_t = std::time::Duration::ZERO; let mut tr_disp_t = std::time::Duration::ZERO; let mut next_remote_check = std::time::Instant::now(); + let mut last_group = None; loop { if trace && tr_last.elapsed() >= std::time::Duration::from_secs(1) { @@ -2073,8 +2082,21 @@ impl DaemonPane { // reads, most visibly) stayed on the far end until // some unrelated output arrived (#817). let back_at_prompt = signals.shell.iter().any(|s| s.at_prompt); - let poll_now = - back_at_prompt || std::time::Instant::now() >= next_remote_check; + // A new foreground group is a new program in front + // — probe for it on its first output. The interval + // alone missed an agent that drew its whole first + // screen within it of the prompt it was typed at + // and then sat waiting for a key: a launch that + // types the command the moment the shell is up + // did that every time, and the pane never learned + // it was running an agent until something else + // printed. + let group = foreground_group(); + let group_changed = group != last_group; + last_group = group; + let poll_now = back_at_prompt + || group_changed + || std::time::Instant::now() >= next_remote_check; if poll_now { next_remote_check = std::time::Instant::now() + REMOTE_CONTEXT_POLL_INTERVAL; @@ -2272,6 +2294,7 @@ impl DaemonPane { local_pty: matches!(self.backend, PaneBackend::Pty(_)), at_prompt: st.shell.active.then_some(st.shell.mark_at_prompt), remote_prompt_seen: st.remote_prompt_seen, + bracketed_paste: Some(st.modes.is_on(crate::core::term_modes::BRACKETED_PASTE)), } } @@ -3013,7 +3036,7 @@ fn apply_signals(st: &mut PaneState, signals: SniffSignals) { if shell_mark_capture_changed(&st.shell, &shell) { apply_agent( st, - agent_from_shell_mark(&shell, crate::core::config::agent_commands_cached()), + agent_from_shell_mark(&shell, &crate::core::config::agent_detection_aliases()), ); } st.shell = shell.clone(); @@ -3057,6 +3080,14 @@ fn apply_agent_signals( let before = st.agent_session.clone(); for event in &events { + // A hook names the agent that emitted it. Once this pane has a + // foreground agent, a different one's session id must not land here: + // omp forking with a Claude UUID is "session not found". + if let (Some(current), Some(from)) = (st.agent, event.agent) + && current != from + { + continue; + } if st.agent.is_none() && event.agent.is_some() { st.agent = event.agent; notify(st, DaemonMsg::Agent(st.agent)); @@ -3183,7 +3214,9 @@ fn apply_agent( stamp_launch_argv(st, argv); return; } - if agent.is_none() && st.agent_session.is_some() { + // The session belongs to whoever was in the foreground. Switching from + // Claude to omp (or back to the shell) must not keep the previous id. + if st.agent_session.is_some() { st.agent_session = None; notify(st, DaemonMsg::AgentStatus(None)); } @@ -3335,7 +3368,7 @@ fn foreground_agent( let argv = crate::daemon::remote::foreground_argv(pid)?; let agent = crate::core::cli_agent::CLIAgent::detect_from_argv_with( &argv, - crate::core::config::agent_commands_cached(), + &crate::core::config::agent_detection_aliases(), )?; Some((agent, argv)) }; @@ -5238,6 +5271,7 @@ mod tests { null_writer(), || false, ForegroundProbes { + group: Box::new(|| None), remote: Box::new(|| None), agent: Box::new(|| Some(None)), cwd: Box::new(|| None), @@ -5299,6 +5333,7 @@ mod tests { null_writer(), || false, ForegroundProbes { + group: Box::new(|| None), remote: Box::new(|| None), agent: Box::new(|| Some(None)), cwd: Box::new(|| None), @@ -5378,6 +5413,39 @@ mod tests { assert!(matches!(rx.try_recv(), Ok(DaemonMsg::Agent(None)))); } + #[test] + fn a_foreign_agent_hook_does_not_replace_the_foreground_session() { + use crate::core::cli_agent::CLIAgent; + + let mut st = test_state(true); + let mut sniffer = OscSniffer::new(); + let omp = concat!( + "\x1b]777;notify;tty7://cli-agent;", + r#"{"v":1,"agent":"omp","event":"session-start","session_id":"01a0d21f-2f79-72e0-bb16-d6d908aaa6e0"}"#, + "\x07", + ); + apply_signals(&mut st, sniffer.feed(omp.as_bytes())); + assert_eq!(st.agent, Some(CLIAgent::OhMyPi)); + + let claude = concat!( + "\x1b]777;notify;tty7://cli-agent;", + r#"{"v":1,"agent":"claude","event":"session-start","session_id":"e18a20ff-4c8c-4b94-867b-dd4b79032a6c"}"#, + "\x07", + ); + apply_signals(&mut st, sniffer.feed(claude.as_bytes())); + assert_eq!( + st.agent_session.as_ref().unwrap().session_id.as_deref(), + Some("01a0d21f-2f79-72e0-bb16-d6d908aaa6e0"), + "a Claude hook on an omp pane must not steal the session id" + ); + + apply_agent(&mut st, Some((CLIAgent::Claude, vec!["claude".into()]))); + assert!( + st.agent_session.is_none(), + "switching the foreground agent drops the previous session" + ); + } + #[test] fn opaque_notifications_only_fall_back_when_no_rich_state() { use crate::core::cli_agent::{AgentSessionState, AgentStatus, CLIAgent}; @@ -6060,6 +6128,7 @@ mod tests { null_writer(), || false, ForegroundProbes { + group: Box::new(|| None), remote, agent: Box::new(|| None), cwd: Box::new(|| None), @@ -6107,6 +6176,7 @@ mod tests { null_writer(), || false, ForegroundProbes { + group: Box::new(|| None), remote: Box::new(|| None), agent: Box::new(|| None), cwd: Box::new(|| None), @@ -6211,6 +6281,7 @@ mod tests { null_writer(), || false, ForegroundProbes { + group: Box::new(|| None), remote: Box::new(|| None), agent: Box::new(|| None), cwd: Box::new(|| None), @@ -6308,6 +6379,7 @@ mod tests { null_writer(), || false, ForegroundProbes { + group: Box::new(|| None), remote: Box::new(|| None), agent: Box::new(|| None), cwd: Box::new(|| None), @@ -6345,6 +6417,7 @@ mod tests { null_writer(), || false, ForegroundProbes { + group: Box::new(|| None), remote: Box::new(|| None), agent: Box::new(|| None), cwd: Box::new(|| None), @@ -6415,6 +6488,7 @@ mod tests { writer, || false, ForegroundProbes { + group: Box::new(|| None), remote: Box::new(|| None), agent: Box::new(|| None), cwd: Box::new(|| None), @@ -6508,6 +6582,7 @@ mod tests { null_writer(), || false, ForegroundProbes { + group: Box::new(|| None), remote: Box::new(|| None), agent: Box::new(|| None), cwd: Box::new(|| None), @@ -6589,6 +6664,7 @@ mod tests { writer, || false, ForegroundProbes { + group: Box::new(|| None), remote: Box::new(|| None), agent: Box::new(|| None), cwd: Box::new(|| None), @@ -6626,6 +6702,7 @@ mod tests { null_writer(), || false, ForegroundProbes { + group: Box::new(|| None), remote: Box::new(|| None), agent: Box::new(|| None), cwd: Box::new(|| None), @@ -6677,6 +6754,7 @@ mod tests { null_writer(), || false, ForegroundProbes { + group: Box::new(|| None), remote: Box::new(|| None), agent: Box::new(|| None), cwd: Box::new(|| Some(PathBuf::from("/Users/alice/dev/tty7"))), @@ -6708,6 +6786,7 @@ mod tests { null_writer(), || false, ForegroundProbes { + group: Box::new(|| None), remote: Box::new(|| None), agent: Box::new(|| None), cwd: Box::new(|| None), @@ -6734,6 +6813,7 @@ mod tests { null_writer(), || false, ForegroundProbes { + group: Box::new(|| None), remote: Box::new(|| None), agent: Box::new(|| None), cwd: Box::new(|| None), @@ -7297,4 +7377,70 @@ mod tests { apply_signals(&mut st, SniffSignals::default()); assert_eq!(st.cwd, Some(PathBuf::from("/tmp/x"))); } + + /// A quick launch types the agent's command the moment the shell is up, so + /// the agent's whole first screen can land inside the poll interval of the + /// prompt it was typed at — and then it waits for a key, printing nothing + /// more. Probing only on the interval, the pane never found out an agent + /// was running. A change of foreground process group now probes at once. + #[test] + fn a_new_foreground_group_is_probed_inside_the_poll_interval() { + struct Chunks(std::collections::VecDeque>); + + impl Read for Chunks { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + match self.0.pop_front() { + Some(chunk) => { + buf[..chunk.len()].copy_from_slice(&chunk); + Ok(chunk.len()) + } + None => Ok(0), + } + } + } + + let probes_for = |groups: Vec| { + let state = Arc::new(Mutex::new(test_state(true))); + let probes = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let counted = probes.clone(); + let groups = Mutex::new(groups.into_iter()); + let handle = DaemonPane::spawn_reader( + state, + Arc::new(AtomicBool::new(false)), + Arc::new(OutputGate::new()), + // Plain output, no prompt marks: only the interval or the + // group can trigger a probe. + Box::new(Chunks( + [b"$ ".to_vec(), b"agent screen".to_vec()] + .into_iter() + .collect(), + )), + null_writer(), + || false, + ForegroundProbes { + group: Box::new(move || groups.lock().unwrap().next()), + remote: Box::new(|| None), + agent: Box::new(move || { + counted.fetch_add(1, Ordering::SeqCst); + None + }), + cwd: Box::new(|| None), + }, + Arc::new(DeathReporter::new(|| {})), + ); + handle.join().unwrap(); + probes.load(Ordering::SeqCst) + }; + + assert_eq!( + probes_for(vec![10, 10]), + 1, + "same program: the interval rules" + ); + assert_eq!( + probes_for(vec![10, 11]), + 2, + "a new program is probed at once" + ); + } } diff --git a/crates/tty7-core/src/daemon/procinfo.rs b/crates/tty7-core/src/daemon/procinfo.rs index 217a530b..e55581fb 100644 --- a/crates/tty7-core/src/daemon/procinfo.rs +++ b/crates/tty7-core/src/daemon/procinfo.rs @@ -225,23 +225,15 @@ fn walk(table: &HashMap, shell_pid: u32, fg_pgid: Option) -> Vec< #[cfg(target_os = "macos")] fn process_table() -> HashMap { let mut table = HashMap::new(); - let bytes = unsafe { libc::proc_listallpids(std::ptr::null_mut(), 0) }; - if bytes <= 0 { - return table; - } - let cap = (bytes as usize / std::mem::size_of::()) + 64; - let mut pids = vec![0 as libc::c_int; cap]; - let written = unsafe { - libc::proc_listallpids( - pids.as_mut_ptr() as *mut libc::c_void, - (cap * std::mem::size_of::()) as libc::c_int, - ) - }; - if written <= 0 { - return table; - } - let n = written as usize / std::mem::size_of::(); - for &pid in pids.iter().take(n.min(cap)) { + let pids = list_all_pids(|buf| { + let ptr = match buf.is_empty() { + true => std::ptr::null_mut(), + false => buf.as_mut_ptr() as *mut libc::c_void, + }; + let bytes = std::mem::size_of_val(buf) as libc::c_int; + unsafe { libc::proc_listallpids(ptr, bytes) } + }); + for &pid in &pids { if pid <= 0 { continue; } @@ -276,6 +268,37 @@ fn process_table() -> HashMap { table } +/// Every pid on the machine, by way of `list`, which has the calling +/// convention of libproc's `proc_listallpids`: handed a buffer, it fills as +/// much of it as it can, and it answers with a *count of pids* — not a byte +/// count, although the size it is given is in bytes. An empty buffer asks +/// only for the count, padded by the kernel for processes born in between. +/// +/// Reading that count as bytes was #731. It divided the answer by four twice +/// over, once sizing the buffer and once reading back how much was filled, so +/// only about one pid in sixteen made it into the table — and since the kernel +/// lists its newest processes first, the ones kept were whatever had started +/// most recently. A `go run` server just launched was in; the pane's shell, +/// started long before, was not, and a walk from a root the table has never +/// heard of comes back empty: no processes, no ports, and nothing to say why. +/// A process tree built moments earlier, which is what a quick check of the +/// probe builds, is exactly the case that happened to work. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +fn list_all_pids(mut list: impl FnMut(&mut [i32]) -> i32) -> Vec { + let count = list(&mut []); + if count <= 0 { + return Vec::new(); + } + let mut pids = vec![0; count as usize + 64]; + let written = list(&mut pids); + if written <= 0 { + return Vec::new(); + } + pids.truncate(written as usize); + pids.retain(|&pid| pid > 0); + pids +} + #[cfg(target_os = "macos")] fn cstr_field(buf: &[libc::c_char]) -> String { let bytes: Vec = buf @@ -968,6 +991,40 @@ mod tests { ); } + /// `proc_listallpids` as libproc implements it: the kernel's newest + /// processes first, a padded estimate when asked with no buffer, and + /// always a count of pids back, never bytes. + fn fake_listallpids(live: &[i32]) -> impl FnMut(&mut [i32]) -> i32 + '_ { + move |buf: &mut [i32]| { + if buf.is_empty() { + return live.len() as i32 + 20; + } + let n = live.len().min(buf.len()); + buf[..n].copy_from_slice(&live[..n]); + n as i32 + } + } + + /// #731 on macOS: a busy Mac has hundreds of processes, and the pane's + /// shell is an old one. Every pid has to reach the table, or the walk + /// starts from a root it cannot find and the pane shows no processes and + /// no ports at all. + #[test] + fn every_pid_is_listed_not_the_newest_sixteenth() { + // Newest first, the way the kernel lists them: the `go run` server + // just started, and the shell it runs under from an hour ago. + let live: Vec = (1..=700).rev().collect(); + let got = list_all_pids(fake_listallpids(&live)); + assert_eq!(got.len(), 700, "the whole machine, not the newest few"); + assert!(got.contains(&1), "the oldest process is still there"); + } + + #[test] + fn a_pid_list_that_cannot_be_read_is_empty() { + assert!(list_all_pids(|_| -1).is_empty()); + assert!(list_all_pids(|_| 0).is_empty()); + } + #[test] fn a_pathological_tree_still_stops() { let mut table: HashMap = [(100, row(1, "zsh"))].into_iter().collect(); diff --git a/crates/tty7-core/src/daemon/protocol.rs b/crates/tty7-core/src/daemon/protocol.rs index 3f9632f4..fad170f6 100644 --- a/crates/tty7-core/src/daemon/protocol.rs +++ b/crates/tty7-core/src/daemon/protocol.rs @@ -181,6 +181,10 @@ pub enum WorkspaceOp { RemoveForward { forward_id: u64, }, + SetForwardEnabled { + forward_id: u64, + enabled: bool, + }, ListForwards, TeardownForwards, SftpList { @@ -261,6 +265,13 @@ pub struct SshForwardRule { pub target_port: u16, #[serde(default)] pub description: Option, + /// A switched-off rule is registered but binds nothing. Defaults to on, + /// and is not a `PROTOCOL_VERSION` bump: a peer that predates it never + /// sends it and every rule it knows is live, while one that receives it + /// ignores it — the worst case is an older far end opening a rule the + /// user switched off, which is what it always did. + #[serde(default = "default_true")] + pub enabled: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -420,6 +431,13 @@ pub struct ManagedForward { #[serde(default)] pub description: Option, pub status: ForwardStatus, + /// Whether the forward is switched on. A field rather than a + /// `ForwardStatus` variant for the reason `loop_exit_status` gives: an + /// older peer fails the whole frame on a variant it has never heard of, + /// but skips a field it does not know. A switched-off forward's status is + /// an `Error` saying so, which is what such a peer then draws. + #[serde(default = "default_true")] + pub enabled: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -551,6 +569,15 @@ pub struct PaneContext { /// prompt" on a remote pane means nothing: the newest mark is then the /// near shell's own "I started `ssh`", and it will never be replaced. pub remote_prompt_seen: bool, + /// Whether the pane's output has switched bracketed paste (mode 2004) on, + /// from the same fold the replay restores modes from. `None` from a daemon + /// built before the field existed — "cannot say", not "off". + /// + /// It is here so a client with no terminal of its own — `tty7 send + /// --paste` — can frame a paste the way the GUI does, by asking the pane + /// rather than assuming. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bracketed_paste: Option, } fn default_term() -> String { @@ -892,6 +919,11 @@ pub enum ClientMsg { pane_id: u64, forward_id: u64, }, + SetForwardEnabled { + pane_id: u64, + forward_id: u64, + enabled: bool, + }, ListForwards { pane_id: u64, }, @@ -987,6 +1019,7 @@ mod kind { pub const ADD_FORWARD: u8 = 20; pub const REMOVE_FORWARD: u8 = 21; pub const LIST_FORWARDS: u8 = 22; + pub const SET_FORWARD_ENABLED: u8 = 23; pub const VERSION: u8 = 40; pub const QUERY_PROCS: u8 = 50; pub const ON_WORKSPACE: u8 = 52; @@ -1260,6 +1293,15 @@ impl ClientMsg { pane_id, forward_id, } => write_frame(w, kind::REMOVE_FORWARD, &to_json(&(pane_id, forward_id))?), + ClientMsg::SetForwardEnabled { + pane_id, + forward_id, + enabled, + } => write_frame( + w, + kind::SET_FORWARD_ENABLED, + &to_json(&(pane_id, forward_id, enabled))?, + ), ClientMsg::QueryProcs { pane_id } => { write_frame(w, kind::QUERY_PROCS, &to_json(pane_id)?) } @@ -1390,6 +1432,14 @@ impl ClientMsg { forward_id, } } + kind::SET_FORWARD_ENABLED => { + let (pane_id, forward_id, enabled) = from_json(&payload)?; + ClientMsg::SetForwardEnabled { + pane_id, + forward_id, + enabled, + } + } kind::LIST_FORWARDS => ClientMsg::ListForwards { pane_id: from_json(&payload)?, }, @@ -1758,6 +1808,7 @@ mod tests { target_host: "10.0.0.5".into(), target_port: 80, description: Some("web".into()), + enabled: true, }, }, ClientMsg::AddForward { @@ -1769,12 +1820,18 @@ mod tests { target_host: String::new(), target_port: 0, description: None, + enabled: false, }, }, ClientMsg::RemoveForward { pane_id: 7, forward_id: 3, }, + ClientMsg::SetForwardEnabled { + pane_id: 7, + forward_id: 3, + enabled: false, + }, ClientMsg::ListForwards { pane_id: 7 }, ClientMsg::Version, ]; @@ -1934,6 +1991,7 @@ mod tests { target_port: 80, description: Some("web".into()), status: ForwardStatus::Listening, + enabled: true, }, ManagedForward { id: 2, @@ -1945,6 +2003,7 @@ mod tests { target_port: 3000, description: None, status: ForwardStatus::Error("bind refused".into()), + enabled: false, }, ]), DaemonMsg::Version(DaemonVersion { @@ -2385,6 +2444,7 @@ mod tests { target_host: "127.0.0.1".into(), target_port: 80, description: Some("web".into()), + enabled: true, }], keepalive_interval_s: Some(30), keepalive_count_max: Some(3), @@ -2471,9 +2531,14 @@ mod tests { target_host: "127.0.0.1".into(), target_port: 5432, description: Some("db".into()), + enabled: true, }, }, WorkspaceOp::RemoveForward { forward_id: 4 }, + WorkspaceOp::SetForwardEnabled { + forward_id: 4, + enabled: true, + }, WorkspaceOp::ListForwards, WorkspaceOp::TeardownForwards, WorkspaceOp::SftpList { @@ -2702,4 +2767,39 @@ mod tests { "the session daemon must not advertise a dialect it cannot serve" ); } + + /// `enabled` crosses the same peers as `rejected` above and gets the same + /// treatment: a rule or a forward from a peer that predates the switch is + /// a live one, and a peer that predates it reads a frame carrying it. + #[test] + fn a_forward_enabled_flag_decodes_from_a_peer_that_never_sends_it() { + let rule: SshForwardRule = serde_json::from_str( + r#"{"kind":"local","bind_host":"127.0.0.1","bind_port":8080, + "target_host":"srv","target_port":80}"#, + ) + .unwrap(); + assert!(rule.enabled); + + let managed: ManagedForward = serde_json::from_str( + r#"{"id":1,"pane_id":7,"kind":"local","bind_host":"127.0.0.1", + "bind_port":8080,"status":"listening"}"#, + ) + .unwrap(); + assert!(managed.enabled); + + #[derive(Deserialize)] + struct LegacyManagedForward { + id: u64, + status: ForwardStatus, + } + let off = serde_json::to_string(&ManagedForward { + enabled: false, + status: ForwardStatus::Error("disabled".into()), + ..managed + }) + .unwrap(); + let legacy: LegacyManagedForward = serde_json::from_str(&off).unwrap(); + assert_eq!(legacy.id, 1); + assert_eq!(legacy.status, ForwardStatus::Error("disabled".into())); + } } diff --git a/crates/tty7-core/src/daemon/server.rs b/crates/tty7-core/src/daemon/server.rs index d2a43c73..de4c4f5f 100644 --- a/crates/tty7-core/src/daemon/server.rs +++ b/crates/tty7-core/src/daemon/server.rs @@ -105,6 +105,10 @@ impl crate::host::server::PaneDirectory for Registry { self.get(pane_id).map(|p| p.procs()).unwrap_or_default() } + fn hibernate_pane(&self, pane_id: u64) { + hibernate_pane(self, pane_id); + } + fn agent_states(&self) -> Vec { let panes: Vec> = self.panes.lock().unwrap().values().cloned().collect(); let mut states: Vec<_> = panes.iter().filter_map(|p| p.agent_state()).collect(); @@ -276,6 +280,22 @@ fn kill_pane(registry: &Registry, pane_id: u64) { crate::daemon::scrollback::forget(pane_id); } +/// Stop a pane whose tab is going to sleep, keeping its screen. +/// +/// [`kill_pane`] with the other half of the bargain: the screen is written one +/// last time, exactly, and left on disk rather than dropped, so the pane that +/// wakes in this one's place opens showing it — the same restore a daemon that +/// died unannounced gets, asked for the same way. The tree still names this +/// pane, which is what keeps the sweeps off the file until then. +fn hibernate_pane(registry: &Registry, pane_id: u64) { + let Some(pane) = registry.remove(pane_id) else { + return; + }; + let (segments, title, _) = pane.scrollback_snapshot(); + crate::daemon::scrollback::save(pane_id, &segments, title.as_deref()); + pane.kill(); +} + fn ssh_connection_for( registry: &Registry, pane_id: u64, @@ -1035,6 +1055,31 @@ fn handle_conn(stream: Stream, registry: Arc) -> anyhow::Result<()> { Ok(()) } + ClientMsg::SetForwardEnabled { + pane_id, + forward_id, + enabled, + } => { + let mut w = write_stream; + // Only switching on needs the connection; switching off has to + // work on a pane whose connection is already gone. + let conn = match forward_pane_connection(®istry, pane_id) { + Ok(conn) => Some(conn), + Err(e) if enabled => { + DaemonMsg::Error(e).encode(&mut w)?; + return Ok(()); + } + Err(_) => None, + }; + match crate::daemon::ssh::SshManager::global() + .set_forward_enabled(pane_id, conn, forward_id, enabled) + { + Ok(list) => DaemonMsg::ForwardList(list).encode(&mut w)?, + Err(e) => DaemonMsg::Error(e).encode(&mut w)?, + } + Ok(()) + } + ClientMsg::QueryProcs { pane_id } => { let mut w = write_stream; let procs = registry.get(pane_id).map(|p| p.procs()).unwrap_or_default(); diff --git a/crates/tty7-core/src/daemon/ssh/forward.rs b/crates/tty7-core/src/daemon/ssh/forward.rs index 726ff9f1..e6d055ef 100644 --- a/crates/tty7-core/src/daemon/ssh/forward.rs +++ b/crates/tty7-core/src/daemon/ssh/forward.rs @@ -257,17 +257,39 @@ fn bind_failed(rule: &SshForwardRule, e: io::Error) -> SharedStatus { )))) } +/// What a switched-off forward reports. An `Error` for the same reason as +/// `loop_exit_status`, so an older peer reading it draws a stopped forward +/// with a word of explanation; a current one reads `enabled` instead. +fn disabled_status() -> SharedStatus { + Arc::new(Mutex::new(ForwardStatus::Error("disabled".to_string()))) +} + +/// Whether two forwards would want the same listening socket: the same port, +/// on the same side of the connection. Local and dynamic forwards both listen +/// here; a remote forward listens on the far host. +fn same_socket(a: SshForwardKind, a_port: u16, b: SshForwardKind, b_port: u16) -> bool { + let far = |k| k == SshForwardKind::Remote; + a_port != 0 && a_port == b_port && far(a) == far(b) +} + struct ForwardEntry { id: u64, kind: SshForwardKind, bind_host: String, bind_port: u16, + /// The port the rule asked for, which `bind_port` is not when that was 0 + /// and the OS picked one. Switching the forward back on asks again for + /// this, not for whatever it happened to get last time. + requested_port: u16, target_host: String, target_port: u16, description: Option, status: SharedStatus, cancel: ForwardCancel, auto_local: bool, + /// Switched off: the entry, and the rule in it, stay listed, but nothing + /// is bound and `cancel` is `None`. + enabled: bool, } #[derive(Clone, PartialEq, Eq, Hash, Debug)] @@ -288,6 +310,19 @@ impl ForwardEntry { target_port: self.target_port, description: self.description.clone(), status: self.status.lock().unwrap().clone(), + enabled: self.enabled, + } + } + + fn rule(&self) -> SshForwardRule { + SshForwardRule { + kind: self.kind, + bind_host: self.bind_host.clone(), + bind_port: self.requested_port, + target_host: self.target_host.clone(), + target_port: self.target_port, + description: self.description.clone(), + enabled: self.enabled, } } } @@ -318,6 +353,23 @@ impl SshForwardRegistry { .await } + pub async fn set_enabled( + &self, + pane_id: u64, + conn: Option>, + forward_id: u64, + enabled: bool, + ) -> Result, String> { + self.set_enabled_owned( + &ForwardOwner::Pane(pane_id), + pane_id, + conn, + forward_id, + enabled, + ) + .await + } + pub async fn teardown_pane(&self, pane_id: u64) { self.teardown_owned(&ForwardOwner::Pane(pane_id)).await; } @@ -347,6 +399,24 @@ impl SshForwardRegistry { .await } + pub async fn set_enabled_workspace( + &self, + workspace: WorkspaceId, + view_pane: u64, + conn: Arc, + forward_id: u64, + enabled: bool, + ) -> Result, String> { + self.set_enabled_owned( + &ForwardOwner::Workspace(workspace), + view_pane, + Some(conn), + forward_id, + enabled, + ) + .await + } + pub async fn teardown_workspace(&self, workspace: WorkspaceId) { self.teardown_owned(&ForwardOwner::Workspace(workspace)) .await; @@ -360,22 +430,24 @@ impl SshForwardRegistry { rule: &SshForwardRule, ) -> ManagedForward { let id = self.next_id.fetch_add(1, Ordering::Relaxed); - let (bind_port, status, cancel) = match rule.kind { - SshForwardKind::Local => self.start_local(&conn, rule).await, - SshForwardKind::Dynamic => self.start_dynamic(&conn, rule).await, - SshForwardKind::Remote => self.start_remote(&conn, rule).await, + let (bind_port, status, cancel) = if rule.enabled { + self.start(&conn, rule).await + } else { + (rule.bind_port, disabled_status(), ForwardCancel::None) }; let entry = ForwardEntry { id, kind: rule.kind, bind_host: rule.bind_host.clone(), bind_port, + requested_port: rule.bind_port, target_host: rule.target_host.clone(), target_port: rule.target_port, description: rule.description.clone(), status, cancel, auto_local: false, + enabled: rule.enabled, }; let managed = entry.to_managed(view_pane); self.owners @@ -413,20 +485,145 @@ impl SshForwardRegistry { }) }; if let Some(entry) = removed { - Self::cancel_entry(entry).await; + Self::cancel(entry.cancel).await; } self.list_owned(owner, view_pane) } + /// Switch one forward off or back on, keeping its entry — and so its + /// place in the list and the rule it was made from — either way. + /// + /// Switching on refuses, rather than fails at bind time with whatever the + /// OS says, when another switched-on forward of the same owner already + /// holds the port: the answer then names that forward, which is the one + /// the user has to switch off first. A forward that cannot be started for + /// any other reason stays off, with the reason as the error. + async fn set_enabled_owned( + &self, + owner: &ForwardOwner, + view_pane: u64, + conn: Option>, + forward_id: u64, + enabled: bool, + ) -> Result, String> { + if !enabled { + let cancel = { + let mut owners = self.owners.lock().unwrap(); + let entry = Self::entry_mut(&mut owners, owner, forward_id)?; + entry.enabled = false; + entry.status = disabled_status(); + std::mem::replace(&mut entry.cancel, ForwardCancel::None) + }; + Self::cancel(cancel).await; + return Ok(self.list_owned(owner, view_pane)); + } + + let Some(rule) = self.enable_plan(owner, forward_id)? else { + return Ok(self.list_owned(owner, view_pane)); + }; + let Some(conn) = conn else { + return Err("the SSH connection is gone — reconnect and try again".to_string()); + }; + let (bound, status, cancel) = self.start(&conn, &rule).await; + if let Some(e) = bind_error(&status) { + Self::cancel(cancel).await; + return Err(e); + } + if let Some(cancel) = self.finish_enable(owner, forward_id, bound, status, cancel) { + // Removed while it was being started: nobody wants what was bound. + Self::cancel(cancel).await; + } + Ok(self.list_owned(owner, view_pane)) + } + + fn entry_mut<'a>( + owners: &'a mut HashMap>, + owner: &ForwardOwner, + forward_id: u64, + ) -> Result<&'a mut ForwardEntry, String> { + owners + .get_mut(owner) + .and_then(|entries| entries.iter_mut().find(|e| e.id == forward_id)) + .ok_or_else(|| format!("no forward {forward_id}")) + } + + /// The rule to start for switching `forward_id` on, `None` when it is on + /// already, or why it may not be switched on. + fn enable_plan( + &self, + owner: &ForwardOwner, + forward_id: u64, + ) -> Result, String> { + let mut owners = self.owners.lock().unwrap(); + let entry = Self::entry_mut(&mut owners, owner, forward_id)?; + if entry.enabled { + return Ok(None); + } + let rule = SshForwardRule { + enabled: true, + ..entry.rule() + }; + let holder = owners.get(owner).into_iter().flatten().find(|e| { + e.id != forward_id + && e.enabled + && same_socket(e.kind, e.bind_port, rule.kind, rule.bind_port) + }); + if let Some(h) = holder { + let what = match h.kind { + SshForwardKind::Dynamic => "a SOCKS forward".to_string(), + _ => format!("the forward to {}:{}", h.target_host, h.target_port), + }; + return Err(format!( + "port {} is already used by {what}; switch that one off first", + rule.bind_port + )); + } + Ok(Some(rule)) + } + + /// Record a started forward as switched on. Hands `cancel` back when the + /// entry is no longer there to take it. + fn finish_enable( + &self, + owner: &ForwardOwner, + forward_id: u64, + bound: u16, + status: SharedStatus, + cancel: ForwardCancel, + ) -> Option { + let mut owners = self.owners.lock().unwrap(); + let Ok(entry) = Self::entry_mut(&mut owners, owner, forward_id) else { + return Some(cancel); + }; + entry.enabled = true; + entry.bind_port = bound; + entry.status = status; + // Anything already in the slot is a second switch-on that raced this + // one; it is replaced, and has to be stopped rather than leaked. + Some(std::mem::replace(&mut entry.cancel, cancel)) + } + + async fn start( + &self, + conn: &Arc, + rule: &SshForwardRule, + ) -> (u16, SharedStatus, ForwardCancel) { + match rule.kind { + SshForwardKind::Local => self.start_local(conn, rule).await, + SshForwardKind::Dynamic => self.start_dynamic(conn, rule).await, + SshForwardKind::Remote => self.start_remote(conn, rule).await, + } + } + async fn teardown_owned(&self, owner: &ForwardOwner) { let entries = self.owners.lock().unwrap().remove(owner); for entry in entries.into_iter().flatten() { - Self::cancel_entry(entry).await; + Self::cancel(entry.cancel).await; } } - async fn cancel_entry(entry: ForwardEntry) { - match entry.cancel { + async fn cancel(cancel: ForwardCancel) { + match cancel { ForwardCancel::Task(handle) => { handle.abort(); let _ = handle.await; @@ -634,6 +831,7 @@ impl SshForwardRegistry { target_host: remote_host.to_string(), target_port: remote_port, description: Some(format!("localhost link → :{remote_port}")), + enabled: true, }; let id = self.next_id.fetch_add(1, Ordering::Relaxed); let (mut bind_port, mut status, mut cancel) = self.start_local(&conn, &rule).await; @@ -658,12 +856,14 @@ impl SshForwardRegistry { kind: SshForwardKind::Local, bind_host: rule.bind_host.clone(), bind_port, + requested_port: rule.bind_port, target_host: rule.target_host.clone(), target_port: rule.target_port, description: rule.description.clone(), status, cancel, auto_local: true, + enabled: true, }; self.owners .lock() @@ -735,6 +935,20 @@ impl SshManager { ) } + pub fn set_workspace_forward_enabled( + &self, + workspace: WorkspaceId, + view_pane: u64, + conn: Arc, + forward_id: u64, + enabled: bool, + ) -> Result, String> { + self.runtime.block_on( + self.forwards + .set_enabled_workspace(workspace, view_pane, conn, forward_id, enabled), + ) + } + pub fn list_workspace_forwards( &self, workspace: WorkspaceId, @@ -941,12 +1155,14 @@ mod tests { kind: SshForwardKind::Local, bind_host: "127.0.0.1".into(), bind_port: port, + requested_port: port, target_host: "h".into(), target_port: 80, description: None, status: Arc::new(Mutex::new(ForwardStatus::Listening)), cancel: ForwardCancel::Task(task), auto_local: false, + enabled: true, } }; { @@ -986,12 +1202,14 @@ mod tests { kind: SshForwardKind::Local, bind_host: "127.0.0.1".into(), bind_port: port, + requested_port: port, target_host: "h".into(), target_port: 80, description: None, status: Arc::new(Mutex::new(ForwardStatus::Listening)), cancel: ForwardCancel::Task(handle), auto_local: false, + enabled: true, } } @@ -1047,12 +1265,14 @@ mod tests { kind: SshForwardKind::Local, bind_host: "127.0.0.1".into(), bind_port: port, + requested_port: port, target_host: "127.0.0.1".into(), target_port: 3000, description: None, status: Arc::new(Mutex::new(ForwardStatus::Listening)), cancel: ForwardCancel::Task(handle), auto_local: true, + enabled: true, }; reg.owners .lock() @@ -1143,4 +1363,170 @@ mod tests { ); assert_eq!(table.lookup("", 0), None); } + + fn push_disabled( + reg: &SshForwardRegistry, + owner: ForwardOwner, + id: u64, + kind: SshForwardKind, + port: u16, + ) { + reg.owners + .lock() + .unwrap() + .entry(owner) + .or_default() + .push(ForwardEntry { + id, + kind, + bind_host: "127.0.0.1".into(), + bind_port: port, + requested_port: port, + target_host: "srv-b".into(), + target_port: 80, + description: None, + status: disabled_status(), + cancel: ForwardCancel::None, + auto_local: false, + enabled: false, + }); + } + + /// Switching a forward off has to release what it bound — that is the + /// point, so the port can be taken by another rule — while leaving the + /// entry, and the rule in it, exactly where it was. + #[tokio::test] + async fn switching_a_forward_off_frees_its_socket_and_keeps_its_rule() { + let reg = SshForwardRegistry::default(); + let guard = push_listener(®, ForwardOwner::Pane(7), 3).await; + let port = reg.list(7)[0].bind_port; + + let list = reg.set_enabled(7, None, 3, false).await.unwrap(); + assert_eq!(list.len(), 1, "the forward is still listed"); + assert_eq!(list[0].id, 3); + assert!(!list[0].enabled); + assert_eq!(list[0].bind_port, port); + assert_eq!( + (list[0].target_host.as_str(), list[0].target_port), + ("127.0.0.1", 3000) + ); + assert!(matches!(list[0].status, ForwardStatus::Error(_))); + assert_eq!(Arc::strong_count(&guard), 1, "…and its listener is gone"); + TcpListener::bind(("127.0.0.1", port)) + .await + .expect("the port is free for someone else"); + + // Twice is the same as once, and a forward nobody has is an error. + assert_eq!(reg.set_enabled(7, None, 3, false).await.unwrap(), list); + assert!(reg.set_enabled(7, None, 99, false).await.is_err()); + + reg.teardown_pane(7).await; + assert!(reg.list(7).is_empty()); + } + + /// Two rules on one local port, pointed at different targets, is the + /// whole use of the switch. Turning the second on while the first still + /// holds the port is refused by name, not left to fail at bind time. + #[tokio::test] + async fn switching_on_a_forward_whose_port_is_held_is_refused_by_name() { + let reg = SshForwardRegistry::default(); + push_listener(®, ForwardOwner::Pane(7), 0).await; + let port = reg.list(7)[0].bind_port; + push_disabled(®, ForwardOwner::Pane(7), 1, SshForwardKind::Local, port); + + let err = reg.set_enabled(7, None, 1, true).await.unwrap_err(); + assert!(err.contains(&port.to_string()), "{err}"); + assert!(err.contains("127.0.0.1:3000"), "names the holder: {err}"); + assert!(!reg.list(7)[1].enabled, "the refused forward stays off"); + + // A SOCKS forward listens here too, so it collides the same way. + push_disabled( + ®, + ForwardOwner::Pane(7), + 2, + SshForwardKind::Dynamic, + port, + ); + assert!(reg.set_enabled(7, None, 2, true).await.is_err()); + + // Once the holder is off, the only thing left in the way is the + // missing connection — which is said, and changes nothing. + reg.set_enabled(7, None, 0, false).await.unwrap(); + let err = reg.set_enabled(7, None, 1, true).await.unwrap_err(); + assert!(err.contains("connection"), "{err}"); + assert!(reg.list(7).iter().all(|m| !m.enabled)); + + // Another owner's forwards are not this one's business. + push_disabled(®, ForwardOwner::Pane(8), 0, SshForwardKind::Local, port); + assert!( + reg.enable_plan(&ForwardOwner::Pane(8), 0) + .unwrap() + .is_some() + ); + } + + #[test] + fn only_the_same_port_on_the_same_side_collides() { + use SshForwardKind::{Dynamic, Local, Remote}; + assert!(same_socket(Local, 8080, Local, 8080)); + assert!(same_socket(Local, 8080, Dynamic, 8080)); + assert!(same_socket(Remote, 8080, Remote, 8080)); + assert!(!same_socket(Local, 8080, Remote, 8080), "different hosts"); + assert!(!same_socket(Local, 8080, Local, 8081)); + assert!( + !same_socket(Local, 0, Local, 0), + "0 asks the OS for a fresh one" + ); + } + + /// Switching back on asks for the port the rule asked for, not the one + /// the OS last picked for it, and records what it got this time. + #[tokio::test] + async fn switching_on_starts_from_the_rule_and_records_the_new_listener() { + let reg = SshForwardRegistry::default(); + push_disabled(®, ForwardOwner::Pane(7), 0, SshForwardKind::Local, 0); + reg.owners + .lock() + .unwrap() + .get_mut(&ForwardOwner::Pane(7)) + .unwrap()[0] + .bind_port = 41000; + + let rule = reg + .enable_plan(&ForwardOwner::Pane(7), 0) + .unwrap() + .expect("an off forward has something to start"); + assert_eq!(rule.bind_port, 0); + assert!(rule.enabled); + assert_eq!((rule.target_host.as_str(), rule.target_port), ("srv-b", 80)); + + let guard = Arc::new(()); + let held = guard.clone(); + let task = tokio::spawn(async move { + let _held = held; + std::future::pending::<()>().await + }); + let status: SharedStatus = Arc::new(Mutex::new(ForwardStatus::Listening)); + let leftover = reg.finish_enable( + &ForwardOwner::Pane(7), + 0, + 42000, + status, + ForwardCancel::Task(task), + ); + assert!(matches!(leftover, Some(ForwardCancel::None))); + let now = ®.list(7)[0]; + assert!(now.enabled); + assert_eq!(now.bind_port, 42000); + assert_eq!(now.status, ForwardStatus::Listening); + assert!( + reg.enable_plan(&ForwardOwner::Pane(7), 0) + .unwrap() + .is_none(), + "an on forward has nothing to start" + ); + + reg.teardown_pane(7).await; + assert_eq!(Arc::strong_count(&guard), 1, "the new listener is owned"); + } } diff --git a/crates/tty7-core/src/daemon/ssh/mod.rs b/crates/tty7-core/src/daemon/ssh/mod.rs index 6cfddade..937c06ef 100644 --- a/crates/tty7-core/src/daemon/ssh/mod.rs +++ b/crates/tty7-core/src/daemon/ssh/mod.rs @@ -115,6 +115,19 @@ impl SshManager { .block_on(self.forwards.remove(pane_id, forward_id)) } + pub fn set_forward_enabled( + &self, + pane_id: u64, + conn: Option>, + forward_id: u64, + enabled: bool, + ) -> Result, String> { + self.runtime.block_on( + self.forwards + .set_enabled(pane_id, conn, forward_id, enabled), + ) + } + pub fn list_forwards(&self, pane_id: u64) -> Vec { self.forwards.list(pane_id) } diff --git a/crates/tty7-core/src/daemon/ssh/workspace.rs b/crates/tty7-core/src/daemon/ssh/workspace.rs index e022de4e..657faffb 100644 --- a/crates/tty7-core/src/daemon/ssh/workspace.rs +++ b/crates/tty7-core/src/daemon/ssh/workspace.rs @@ -33,6 +33,13 @@ pub fn handle(req: &WorkspaceRequest) -> DaemonMsg { WorkspaceOp::RemoveForward { forward_id } => { DaemonMsg::ForwardList(mgr.remove_workspace_forward(ws, view, *forward_id)) } + WorkspaceOp::SetForwardEnabled { + forward_id, + enabled, + } => match mgr.set_workspace_forward_enabled(ws, view, conn, *forward_id, *enabled) { + Ok(list) => DaemonMsg::ForwardList(list), + Err(e) => DaemonMsg::Error(e), + }, WorkspaceOp::ListForwards => DaemonMsg::ForwardList(mgr.list_workspace_forwards(ws, view)), WorkspaceOp::TeardownForwards => { mgr.teardown_workspace_forwards(ws); diff --git a/crates/tty7-core/src/host/server.rs b/crates/tty7-core/src/host/server.rs index 3e9d8584..6eb9bb9d 100644 --- a/crates/tty7-core/src/host/server.rs +++ b/crates/tty7-core/src/host/server.rs @@ -34,6 +34,10 @@ pub trait PaneDirectory: Send + Sync { /// is this process and not the client's own daemon: the client asks over /// the control link precisely because the processes are here. fn pane_procs(&self, pane_id: u64) -> crate::daemon::protocol::PaneProcs; + /// Stop a pane for a tab going to sleep: the processes go, the pane's last + /// screen stays on disk for the wake to open with. Unlike closing a pane, + /// which drops that screen because nobody will ask for it again. + fn hibernate_pane(&self, pane_id: u64); } #[derive(Clone, Default)] @@ -383,6 +387,12 @@ fn handshake( if services.machine.is_some() { features.push(feature::MACHINE_TREE.to_string()); } + // A sleeping tab is a mark in the tree *and* stopped panes; a peer that can + // do only half of that would leave a tab asleep with its shells running, + // or its shells stopped with nothing saying the tab is meant to wake. + if services.machine.is_some() && services.panes.is_some() { + features.push(feature::TAB_HIBERNATE.to_string()); + } // The pane daemon's features ride along, same as `protocol_version` above: // a pane connection answers exactly one message, so a client that wanted // them from `ClientMsg::Version` would pay a whole routed connection — for @@ -783,6 +793,34 @@ fn run_request( .workspace_set_groups(workspace, groups, conn.machine_origin)?; (ReplyOk::Unit, Vec::new()) } + ControlRequest::TabSetHibernated { + workspace, + tab, + hibernated, + } => { + let Some(directory) = conn.panes.as_ref() else { + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "this peer serves no panes, so it cannot put a tab to sleep", + )); + }; + let panes = conn.machine()?.tab_set_hibernated( + workspace, + tab, + hibernated, + conn.machine_origin, + )?; + // After the mark, not before: a pane stopped under a tab the tree + // does not yet call asleep is, to anyone reading the tree in + // between, a pane that died — and a restore reading it that way + // would spawn it straight back. + if hibernated { + for pane in &panes { + directory.hibernate_pane(*pane); + } + } + (ReplyOk::Panes(panes), Vec::new()) + } ControlRequest::PaneSplit { workspace, pane, @@ -1722,6 +1760,8 @@ mod aggregate_tests { } } + fn hibernate_pane(&self, _pane_id: u64) {} + fn agent_states(&self) -> Vec { vec![PaneAgentState { pane_id: 7, @@ -1902,6 +1942,151 @@ mod aggregate_tests { "whatever links exist are named; none are blank" ); } + + /// Records which panes were stopped for a sleeping tab, and in what order + /// relative to the tree: each entry carries whether the tree already + /// called the tab asleep when the pane was stopped. + struct SleepRecorder { + store: Arc, + stopped: std::sync::Mutex>, + } + + impl PaneDirectory for SleepRecorder { + fn pane_count(&self) -> u64 { + 0 + } + + fn panes(&self) -> Vec { + Vec::new() + } + + fn pane_procs(&self, _pane_id: u64) -> crate::daemon::protocol::PaneProcs { + Default::default() + } + + fn agent_states(&self) -> Vec { + Vec::new() + } + + fn hibernate_pane(&self, pane_id: u64) { + let asleep = self.store.machine().workspaces.iter().any(|w| { + w.tabs + .iter() + .any(|t| t.hibernated && t.root.contains(pane_id)) + }); + self.stopped.lock().unwrap().push((pane_id, asleep)); + } + } + + fn seed(pane: u64) -> machine::PaneSeed { + machine::PaneSeed { + pane, + cwd: Some("/repo".into()), + ssh_spec: None, + agent: None, + shell: None, + } + } + + /// Going to sleep stops every pane of the tab — after the tree says the + /// tab is asleep, so nobody reading the tree in between takes the stopped + /// panes for dead ones — and keeps them in the tree for the wake. Waking + /// only clears the mark: the client brings the panes back. + #[test] + fn a_tab_put_to_sleep_stops_its_panes_and_keeps_them_in_the_tree() { + let dir = tempfile::TempDir::new().unwrap(); + let store = MachineStore::open(dir.path().join(machine::MACHINE_FILE)); + let ws = store.workspace_create(None, None, None).unwrap(); + let tab = store.tab_create(ws.id, None, seed(7), None, None).unwrap(); + store + .pane_split( + ws.id, + 7, + machine::Axis::Horizontal, + 0.5, + seed(8), + false, + None, + ) + .unwrap(); + let other = store.tab_create(ws.id, None, seed(9), None, None).unwrap(); + let recorder = Arc::new(SleepRecorder { + store: Arc::clone(&store), + stopped: std::sync::Mutex::new(Vec::new()), + }); + let client = client_with(Services { + machine: Some(Arc::clone(&store)), + attachments: Arc::new(AttachRegistry::default()), + panes: Some(recorder.clone()), + }); + assert!(client.hello().has_feature(feature::TAB_HIBERNATE)); + + let ReplyOk::Panes(panes) = client + .call(ControlRequest::TabSetHibernated { + workspace: ws.id, + tab: tab.id, + hibernated: true, + }) + .unwrap() + else { + panic!("TabSetHibernated must answer with the tab's panes"); + }; + assert_eq!(panes, vec![7, 8]); + assert_eq!( + *recorder.stopped.lock().unwrap(), + vec![(7, true), (8, true)], + "both panes stopped, each after the tree marked the tab" + ); + let machine = store.machine(); + let asleep = &machine.workspaces[0].tabs; + assert!(asleep.iter().find(|t| t.id == tab.id).unwrap().hibernated); + assert!(!asleep.iter().find(|t| t.id == other.id).unwrap().hibernated); + for pane in [7, 8] { + assert!( + machine.panes.iter().any(|p| p.id == pane), + "pane {pane}'s record is what the wake restores from" + ); + } + + recorder.stopped.lock().unwrap().clear(); + let ReplyOk::Panes(_) = client + .call(ControlRequest::TabSetHibernated { + workspace: ws.id, + tab: tab.id, + hibernated: false, + }) + .unwrap() + else { + panic!("TabSetHibernated must answer with the tab's panes"); + }; + assert!(recorder.stopped.lock().unwrap().is_empty()); + assert!(!store.machine().workspaces[0].tabs[0].hibernated); + } + + /// A peer with a tree and no panes could mark a tab asleep and leave its + /// shells running. It does not offer to, and refuses if asked anyway — + /// before the mark, so the tree is not left claiming a sleep that never + /// happened. + #[test] + fn a_peer_serving_no_panes_cannot_put_a_tab_to_sleep() { + let dir = tempfile::TempDir::new().unwrap(); + let store = MachineStore::open(dir.path().join(machine::MACHINE_FILE)); + let ws = store.workspace_create(None, None, None).unwrap(); + let tab = store.tab_create(ws.id, None, seed(7), None, None).unwrap(); + let client = client_with(Services::with_machine(Arc::clone(&store))); + assert!(client.hello().has_feature(feature::MACHINE_TREE)); + assert!(!client.hello().has_feature(feature::TAB_HIBERNATE)); + assert!( + client + .call(ControlRequest::TabSetHibernated { + workspace: ws.id, + tab: tab.id, + hibernated: true, + }) + .is_err() + ); + assert!(!store.machine().workspaces[0].tabs[0].hibernated); + } } #[cfg(test)] diff --git a/docs/agents/overview.mdx b/docs/agents/overview.mdx index a3b446f7..649e7648 100644 --- a/docs/agents/overview.mdx +++ b/docs/agents/overview.mdx @@ -66,6 +66,41 @@ The key is your command's name; the value is one of the slugs above (`claude`, `droid`, `pi`, `auggie`, `hermes`, `vibe`, `antigravity`, `grok`, `qwen`, `omp`, `kimi`, `qodercli`, `crush`, `codebuddy`). +## Quick launch + +Every agent tty7 finds on your `PATH` is one palette command away: **Agent: +Claude Code**, **Agent: Codex**, and so on, most-used first. The New Tab menu's +**Launch Agent…** row opens the palette on that list, and **New Agent Tab** +(⌘ ⇧ A on macOS, unbound elsewhere) starts whichever agent you used +last. + +A launch always opens a new tab in the current tab's directory and types the +agent's command into its shell, so the agent is detected, tracked and resumed +exactly as if you had typed it — and quitting it leaves you at the prompt. Each +`Agent: …` command is bindable too, as `LaunchAgent:`. + +By default an agent launches as its bare binary (`claude`, `codex`, +`cursor-agent`, …). Give it a command line of its own with `agent_launch`: + +```json +{ + "agent_launch": { + "claude": "claude --dangerously-skip-permissions", + "codex": "cx --profile work" + } +} +``` + +A wrapper named there (`cx` above) is recognised as that agent without an +`agent_commands` entry. To keep the flags a running agent was started with, +right-click its pane and choose **Set Current Launch Args as Default**; tty7 +writes them into `agent_launch`, minus the session they resumed and any prompt. + +In a remote workspace tty7 cannot read the far machine's `PATH`, so it offers +the agents it has already seen running there: start one by hand once and it +joins the list. A wrapper named in your local `agent_launch` is only recognised +over there if the remote `config.json` names it too. + ## What you get for free Just by running an agent in a pane: diff --git a/docs/cli/reference.mdx b/docs/cli/reference.mdx index 4ef5bd03..9846cbb2 100644 --- a/docs/cli/reference.mdx +++ b/docs/cli/reference.mdx @@ -324,7 +324,7 @@ resolve it immediately before use. A full tab UUID also works: `@`. | Command | Effect | JSON | |---|---|---| -| `tab ls [WORKSPACE]` | Tabs of a workspace | `{"workspace","tabs":[{"ordinal","id","name","label","agent","group","panes":[…]}]}` | +| `tab ls [WORKSPACE]` | Tabs of a workspace | `{"workspace","tabs":[{"ordinal","id","name","label","agent","group","panes":[…],"hibernated"}]}` | | `tab new [WORKSPACE] [--cwd DIR]` | Add a tab with a fresh shell | `{"tab","pane"}` | | `tab new [WORKSPACE] --pane %PANE` | Add a tab around a pane already running | `{"tab","pane"}` | | `tab close @TAB` | Close the tab and every pane in it | `{"closed"}` | @@ -357,7 +357,7 @@ a stand-in. | Command | Effect | JSON | |---|---|---| -| `pane ls [WORKSPACE]` | Panes with their workspace, tab, cwd, live flag | `{"panes":[…]}` | +| `pane ls [WORKSPACE]` | Panes with their workspace, tab, cwd, live flag (`asleep` for a hibernated tab's panes) | `{"panes":[…]}` | | `pane ls --all` | The server's whole pane registry, including orphans | `{"panes":[…],"orphans":N}` | | `pane split …` | Identical to top-level `split` | `{"pane"}` | | `pane close [%PANE…]` | Close panes; their shells are hung up | `{"closed":[…]}` | diff --git a/docs/customization/settings.mdx b/docs/customization/settings.mdx index c3c75065..72e196d6 100644 --- a/docs/customization/settings.mdx +++ b/docs/customization/settings.mdx @@ -31,7 +31,8 @@ section something is in. Keyboard shortcuts, Option as Meta, mouse behavior, selection, and clipboard. - Tab position and placement, sidebar auto grouping, and diff previews. + Tab position and placement, sidebar auto grouping, diff previews, and what + SSH tabs are called. Hosts, connection defaults, security, authentication, proxies, and forwarding. @@ -69,6 +70,10 @@ Everything the Settings window writes goes to one file: | macOS / Linux | `~/.config/tty7/config.json` | | Windows | `%APPDATA%\tty7\config.json` | +The one exception is your saved SSH hosts, which live beside it in +`servers.json` — so `config.json` can be synced between machines without +carrying your servers. [servers.json →](/reference/configuration#ssh) + Set `TTY7_CONFIG_DIR` to point the whole directory — config, themes, state — somewhere else. diff --git a/docs/reference/configuration.mdx b/docs/reference/configuration.mdx index 27d2d6db..f7af4865 100644 --- a/docs/reference/configuration.mdx +++ b/docs/reference/configuration.mdx @@ -92,6 +92,7 @@ their id from the file name. [More about themes →](/customization/themes) |---|---|---|---| | `tab_bar_position` | enum | `"left"` | `left` (sidebar) or `top` (strip). | | `new_tab_position` | enum | `"after-current"` | Or `end`. | +| `ssh_tab_title` | enum | `"dynamic"` | What an SSH tab is called: `dynamic` (the title the remote side sets), `profile-name` (the saved host's name, a `~/.ssh/config` alias, or the address typed for a quick connect), or `hostname` (the address dialled). A tab you renamed keeps its name. | | `sidebar_auto_grouping` | bool | `true` | Groups unpinned tabs by repository (SSH tabs by host). Off: a flat list below the pinned groups, which show either way. Pinned groups and folds are stored with the workspace, not here. | | `sidebar_diff_preview` | bool | `true` | Clicking a row's `+N −M` opens the diff overlay. | | `sidebar_width` | number | `220` | Pixels (100–2000). | @@ -112,7 +113,7 @@ their id from the file name. [More about themes →](/customization/themes) | Key | Type | Default | | |---|---|---|---| | `shell` | object | — | `{"program": "fish", "args": ["-l"]}`. Unset uses the platform default. `args` you write are launched verbatim, which also turns off [shell integration](/reference/shell-integration) for that shell — leave them out to keep it. | -| `custom_shells` | array | `[]` | Extra entries for the new-tab menu: `[{"label": "Ubuntu", "program": "wsl.exe", "args": ["-d", "Ubuntu"]}]`. Launched exactly as written, listed after the detected shells. Give an entry arguments and tty7 also skips [shell integration](/reference/shell-integration) for it, so it has no prompt marks, working-directory tracking, or command-finished notifications; an entry with no arguments, on a shell tty7 recognizes, is integrated like the detected row beside it. An entry with no `program` is skipped; with no `label` it is named after its program. | +| `custom_shells` | array | `[]` | Extra entries for the new-tab menu: `[{"label": "Ubuntu", "program": "wsl.exe", "args": ["-d", "Ubuntu"]}]`. Launched exactly as written, listed after the detected shells — in the menu's **Other Shells…** list until you have opened it, and in the palette as *Shell:* followed by its label. Give an entry arguments and tty7 also skips [shell integration](/reference/shell-integration) for it, so it has no prompt marks, working-directory tracking, or command-finished notifications; an entry with no arguments, on a shell tty7 recognizes, is integrated like the detected row beside it. An entry with no `program` is skipped; with no `label` it is named after its program. | | `working_directory` | object | `{"strategy":"inherit"}` | `strategy` is `inherit`, `home`, or `custom`; `path` is used when custom. | | `env` | object | `{}` | Extra environment variables for every pane. | | `scrollback_limit` | number | `10000` | Lines per pane (100–100,000). New panes only. | @@ -177,6 +178,8 @@ angle brackets, and a tab: | Key | Type | Default | | |---|---|---|---| | `agent_commands` | object | `{}` | Map a wrapper command to an agent slug: `{"cc": "claude"}`. | +| `agent_launch` | object | `{}` | The command line quick launch types for an agent, by slug: `{"claude": "claude --dangerously-skip-permissions"}`. Unset agents launch as their bare binary. [More →](/agents/overview#quick-launch) | +| `agent_frecency` | object | `{}` | Written by tty7: how often and how recently each agent ran, to order quick launch. | | `restore_agent_sessions` | bool | `true` | Relaunch an agent conversation when a lost pane is restored. | | `install_cli_on_path` | bool | `true` | Put the bundled `tty7` command on PATH at launch. | @@ -186,10 +189,28 @@ angle brackets, and a tab: | Key | Type | Default | | |---|---|---|---| -| `ssh_profiles` | array | `[]` | Managed from **Settings → SSH**. Secrets live in the OS keychain, never here. | | `verify_host_keys` | bool | `true` | | | `ssh_warn_on_close` | bool | `false` | Confirm before closing a live connection. | +### servers.json + +The saved hosts are not in `config.json`: they live in `servers.json` in the +same directory, so `config.json` can be synced between machines without taking +your list of servers along. The file is written with mode `0600` and read as +forgivingly as `config.json`; if it cannot be parsed, tty7 keeps a copy at +`servers.json.corrupt` and saves nothing until it is repaired. + +| Key | Type | Default | | +|---|---|---|---| +| `ssh_profiles` | array | `[]` | Managed from **Settings → SSH**. Secrets live in the OS keychain, never here. | +| `ssh_profile_frecency` | object | `{}` | How often and how recently each host is used, for ranking. Written by the app. | + +An older `config.json` that still holds `ssh_profiles` is split the first time +a newer tty7 reads it: `servers.json` is written first, and only then are the +two keys removed from `config.json`, leaving the rest of it as it was. If both +files hold hosts, `servers.json` wins, and the copy in `config.json` is dropped +the next time settings are saved. + Each object in `ssh_profiles` can also set: | Key | Type | Default | | @@ -209,9 +230,9 @@ Each object in `ssh_profiles` can also set: ## Keys tty7 manages itself -`ssh_profile_frecency` and `command_frecency` record how often and how recently -you use a profile or command, so the pickers can rank them. They are written by -the app; there is no reason to edit them. +`shell_frecency` and `command_frecency` record how often and how recently you +open a shell or run a command, so the new-tab menu and the palette can rank +them. They are written by the app; there is no reason to edit them. If the file cannot be parsed, tty7 starts on defaults, keeps your original at diff --git a/docs/reference/keyboard-shortcuts.mdx b/docs/reference/keyboard-shortcuts.mdx index 8013f2b1..c0509050 100644 --- a/docs/reference/keyboard-shortcuts.mdx +++ b/docs/reference/keyboard-shortcuts.mdx @@ -19,6 +19,7 @@ it shows what *your* copy is bound to. This is the shipped default. | New Workspace | ⌘ ⇧ N | Ctrl ⇧ N | | Switch Workspace | ⌘ ⇧ O | Ctrl ⇧ O | | New Window | ⌘ N | — | +| New Agent Tab | ⌘ ⇧ A | — | ## Panes @@ -107,10 +108,10 @@ Keybindings**: | Group | Actions | |---|---| -| Tabs | `RenameTab` · `NewWorktreeTab` · `CloseOtherTabs` · `CloseTabsToTheRight` · `CopyWorkingDirectory` · `MarkTabUnread` · `ToggleTabSidebar` | +| Tabs | `RenameTab` · `NewWorktreeTab` · `CloseOtherTabs` · `CloseTabsToTheRight` · `CopyWorkingDirectory` · `MarkTabUnread` · `HibernateTab` · `ToggleTabSidebar` | | Panes | `ResizePaneLeft/Right/Up/Down` · `SwapPaneNext` · `SwapPanePrev` | | Workspaces | `SelectWorkspace1`…`SelectWorkspace9` · `RenameWorkspace` · `StopWorkspace` · `DeleteWorkspace` | -| Agents | `ForkAgentSession` (+ `Right` / `Left` / `Down` / `Up`) · `CopyAgentSessionId` | +| Agents | `ForkAgentSession` (+ `Right` / `Left` / `Down` / `Up`) · `CopyAgentSessionId` · `LaunchAgent:` (one per agent, e.g. `LaunchAgent:claude`) | | Git | `ScmStageAll` · `ScmUnstageAll` · `ScmDiscardAll` · `ScmCommitAmend` · `ScmRefresh` · `ScmSync` · `ScmPush` · `ScmPull` · `ScmFetch` · `ScmCheckoutBranch` · `ScmCreateBranch` · `ScmToggleGraph` · `ToggleDiffViewMode` | | Panels | `ShowRightPanelInfo` · `ShowRightPanelChanges` · `ShowRightPanelFiles` | | Documents | `ToggleDocumentFill` · `DocumentWidthThird` · `DocumentWidthHalf` · `DocumentWidthTwoThirds` · `ToggleDocumentPreview` · `ToggleDocumentWrap` | diff --git a/docs/reference/privacy.mdx b/docs/reference/privacy.mdx index 9b405502..c1de9221 100644 --- a/docs/reference/privacy.mdx +++ b/docs/reference/privacy.mdx @@ -55,6 +55,7 @@ Both of the above honour `http_proxy`. [Updates →](/reference/updates#proxies) | | | |---|---| | Settings, themes, window state | `~/.config/tty7/` (`%APPDATA%\tty7\` on Windows) | -| SSH passwords and key passphrases | The **OS keychain** — never `config.json`, never plain text on disk | +| Saved SSH hosts | `/servers.json`, mode `0600` on Unix. Addresses, users and key paths — no secrets. | +| SSH passwords and key passphrases | The **OS keychain** — never `config.json` or `servers.json`, never plain text on disk | | Pane scrollback tails | `/scrollback/*.bin`, mode `0600` on Unix and behind the config directory's ACL on Windows. 256 KiB per pane, kept only until something can no longer ask for it: closing a pane deletes its file at once, a restore consumes it, and a periodic pass collects the rest. | | Shell history | Your shell's own file, exactly as before — unless you turned on per-pane history, which merges back into it. | diff --git a/docs/remote/port-forwarding.mdx b/docs/remote/port-forwarding.mdx index 4002d9cb..1b4c424f 100644 --- a/docs/remote/port-forwarding.mdx +++ b/docs/remote/port-forwarding.mdx @@ -67,6 +67,24 @@ need it about as often as you need `ssh -R`. These live only as long as the session unless you save them into the profile. +## Switching a rule off + +Every forward in the Ports section has a switch. Off, the forward lets go of +its port but keeps its whole rule — kind, ports, target, description — so it +can be switched back on without retyping anything. That is how one local port +is pointed at different places: keep one rule per target on the same port, and +switch on the one you want. + +Only one of them can hold the port at a time. Switching one on while another +still has its port is refused, and the notice names the forward to switch off +first. + +Rules from a profile carry their switch into it, so the next connection opens +the same set. The same switch sits beside each rule under **Settings → SSH →** +a profile **→ Port forwarding**; a rule saved switched off is listed but not +opened. Rules added mid-session are still gone when the session ends, on or +off. + The ports section diff --git a/docs/remote/ssh.mdx b/docs/remote/ssh.mdx index 138d5b96..681e8bb2 100644 --- a/docs/remote/ssh.mdx +++ b/docs/remote/ssh.mdx @@ -75,8 +75,10 @@ on the tab it was opened on, not on whichever pane is focused. Saving one opens on the whole live connection — its proxy, keys and forwards as well as its address — so the host that lands is the one you were already on. -Passwords and key passphrases go in the **OS keychain**, never in -`config.json` and never on disk in plain text. **Forget Password** in a +Saved hosts are kept in `servers.json`, beside `config.json` rather than in +it, so a `config.json` you sync between machines does not carry them. +Passwords and key passphrases go in the **OS keychain**, never in either file +and never on disk in plain text. **Forget Password** in a profile's menu removes the stored one. Deleting a profile drops its keychain credentials and forgets the remote diff --git a/docs/window/command-palette.mdx b/docs/window/command-palette.mdx index a52aa348..ac098dca 100644 --- a/docs/window/command-palette.mdx +++ b/docs/window/command-palette.mdx @@ -20,7 +20,7 @@ Results are grouped, and the groups are the map of the app: | Group | Examples | |---|---| -| **Tabs & Panes** | New Tab · New Worktree Tab… · Split Right · Zoom Pane · Focus Pane Left · Resize Pane Up · Swap Pane Next · Reopen Closed Tab · Copy Working Directory · Fork Session | +| **Tabs & Panes** | New Tab · Shell: zsh · New Worktree Tab… · Split Right · Zoom Pane · Focus Pane Left · Resize Pane Up · Swap Pane Next · Reopen Closed Tab · Copy Working Directory · Fork Session | | **Workspaces** | New Workspace… · Switch Workspace… · Rename Workspace… · Stop Workspace… · Delete Workspace… | | **View** | Show/Hide Left Sidebar · Show/Hide Right Panel · Show Code Panel · Tab Bar: Move to Top · Right Panel: Info / Changes / Files · Change Theme… · Enter Full Screen · Toggle Unified / Side-by-Side Diff | | **Git** | Commit · Stage All Changes · Unstage All · Discard All · Create Branch… · Sync · Push · Pull · Fetch | diff --git a/docs/window/tabs-and-splits.mdx b/docs/window/tabs-and-splits.mdx index dea2382a..7a6ef61f 100644 --- a/docs/window/tabs-and-splits.mdx +++ b/docs/window/tabs-and-splits.mdx @@ -23,6 +23,12 @@ A new tab always opens in the current pane's directory. Where it lands in the list is **Settings → Window & Tabs → New tab position** — *After current* by default, or *At end*. +An SSH tab is named by whatever the remote shell titles itself, like any other. +To keep it on the host instead, set **Settings → Window & Tabs → SSH tab +title** to *Profile name* — the saved host's name, the `~/.ssh/config` alias, or +the address you typed — or *Hostname*. A tab you renamed keeps its name either +way, and a split tab still follows the pane in front. + Right-click a tab for the rest: rename, split right or down, a new worktree tab, copy the working directory, copy the session id, close · close others · close to the right, and — when a coding agent is running there — mark unread and fork its diff --git a/skills/tty7/SKILL.md b/skills/tty7/SKILL.md index 40ead057..dc818b0d 100644 --- a/skills/tty7/SKILL.md +++ b/skills/tty7/SKILL.md @@ -1,7 +1,7 @@ --- name: tty7 description: >- - Drive the tty7 terminal workbench from the shell with the `tty7` binary — list workspaces/tabs/panes, split a pane, send text or keystrokes into one, capture what is on a pane's screen, run a command in a real PTY and pass its exit code through, block until a pane finishes or needs input, see which coding agents are running and which ports a pane is listening on. Use this whenever tty7, panes, workspaces, or `%42`/`@7`/"the other pane"/"the other agent" come up; whenever you want to hand work to another agent and collect the result ("get Claude/Codex to do X", "派个活", "let another agent handle this", running several agents in parallel and merging what they produce); whenever you need to start something long-running or interactive (dev server, REPL, ssh session, `tail -f`, a TUI) that should not sit blocking your Bash tool; whenever a program needs a real terminal to behave the way the user sees it; and whenever you need to look at or report on what is running in some *other* terminal on this machine. Cheap to check: if `$TTY7_PANE` is set you are already inside tty7 and every command here works with no setup. + Drive the tty7 terminal workbench from the shell with the `tty7` binary — list workspaces/tabs/panes, split a pane, send text or keystrokes into one, capture what is on a pane's screen, run a command in a real PTY and pass its exit code through, run a command in a shell pane you already have and get back its output and exit code, block until a pane finishes or needs input, see which coding agents are running and which ports a pane is listening on. Use this whenever tty7, panes, workspaces, or `%42`/`@7`/"the other pane"/"the other agent" come up; whenever you want to hand work to another agent and collect the result ("get Claude/Codex to do X", "派个活", "let another agent handle this", running several agents in parallel and merging what they produce); whenever you need to start something long-running or interactive (dev server, REPL, ssh session, `tail -f`, a TUI) that should not sit blocking your Bash tool; whenever a program needs a real terminal to behave the way the user sees it; and whenever you need to look at or report on what is running in some *other* terminal on this machine. Cheap to check: if `$TTY7_PANE` is set you are already inside tty7 and every command here works with no setup. --- # Driving tty7 from the command line @@ -22,7 +22,7 @@ status hooks are installed, and whether `TTY7_CONFIG_DIR` / `TTY7_WS` / `TTY7_PANE` are set — i.e. whether you are running *inside* a tty7 pane. Being inside a pane matters for two reasons: the address-taking verbs -(`split`, `send`, `capture`, `procs`, `wait`, `pane close`) default to +(`split`, `send`, `exec`, `capture`, `procs`, `wait`, `pane close`) default to `$TTY7_PANE`, and `run --keep` files its pane into `$TTY7_WS`. Outside a tty7 shell you must name a target explicitly, and the error will say so rather than guessing. @@ -75,7 +75,7 @@ back in. Omitting the address inside a tty7 shell means "this pane" / "this workspace". An explicit address always wins over the environment. -## Running a command: two shapes +## Running a command: three shapes ### Blocking, with a real exit code @@ -95,6 +95,28 @@ and the JSON object last — the combined stream is *not* parseable as JSON, so read the last line. And the pane is 120 columns wide with no way to change it, so output that assumes a wider terminal wraps. +### Blocking, in a pane you already have + +```bash +tty7 exec "$PANE" --timeout 900 -- cargo test # prints its output, exits with its code +``` + +`exec` types the line at the pane's shell prompt — its cwd, its environment, +its history — waits for the prompt to come back, prints what the command +printed (as text, like `capture --plain`; `--raw` keeps the escapes) and exits +with the command's own code. It is how you run the next command in a pane you +set up earlier (a `cd`, an activated venv, an `ssh` session with tty7's shell +integration on the far end) without the send / wait / capture / guess dance. + +What it needs, and says when it doesn't have: the pane's shell must have tty7's +shell integration (a pane that has never sent a prompt mark is refused), and +the pane must be sitting at a prompt (a busy one is refused — `wait --until +free` first). The words after `--` become one shell line, so `&&`, pipes and +redirections work if you quote them: `tty7 exec %3 -- 'make && ./run'`. Always +give `--timeout` when nobody is watching: a line the shell never runs — an +unclosed quote waiting on `dquote>` — has no end to wait for. On timeout it +exits 124 and leaves the command running. + ### Non-blocking: a pane you talk to over time This is the one that makes tty7 worth reaching for. Get a pane, send it work, @@ -135,6 +157,18 @@ for Alt. Repeat it for a sequence; text and keys compose, text first. Typing `^C` as text does nothing — it arrives as two characters; `--key C-c` is the real interrupt. +Two things text on the command line cannot do. **Secrets**: `tty7 send %3 +"$TOKEN"` puts the token in `ps` for every local user and in your shell +history — pipe it instead, `printf %s "$TOKEN" | tty7 send %3 --stdin --enter` +(or `--from-file PATH`); the bytes go to the pane exactly as read and are never +echoed in `--json`. Echo on the far side is the program's business: `docker +login --password-stdin`, `ssh-add` and `gpg` turn it off themselves. +**Multi-line text**: a newline on a pty *is* Enter, so each line runs as it +arrives. `--paste` sends it the way a paste into the window does — framed in +bracketed paste when the pane has switched that on, so a shell or an agent's +input box takes the whole block as text; `--json` reports `"bracketed"` so you +know which it got. + **A brand-new pane can swallow the Enter.** A shell still working through its startup files — a prompt framework, `fastfetch`, anything that paints on login — takes the text you send but loses the carriage return that follows it, and the @@ -176,8 +210,9 @@ whole ring, which for a pane that was never resized is the same thing. `--plain` gives you the screen, and a screen is a rectangle: whatever scrolled past the top of a long build log is gone, and the exit code was never on screen -at all. So when what you want is the *answer* rather than the view, have the -shell write it somewhere clean: +at all. When the pane is a shell sitting at its prompt, `tty7 exec` (above) +hands back both. Otherwise — the pane is busy, or its shell has no tty7 +integration — have the shell write the answer somewhere clean: ```bash tty7 send "$PANE" 'cargo test > /tmp/t.log 2>&1; echo $? > /tmp/t.rc' --enter @@ -278,7 +313,7 @@ output on success but never suppresses errors. The panes on this machine are the user's real work, and some of them are other coding agents mid-task. Treat anything you did not create as read-only: -- **Never `send` into a pane you didn't open.** Keystrokes into another agent's +- **Never `send` or `exec` into a pane you didn't open.** Keystrokes into another agent's pane, or into a shell the user is typing in, land in the middle of whatever is happening there. Check `tty7 agents` before you touch a pane. This goes double for `--key`: a stray `C-c` kills somebody's work. diff --git a/skills/tty7/references/commands.md b/skills/tty7/references/commands.md index 4ed7ac13..9c42c734 100644 --- a/skills/tty7/references/commands.md +++ b/skills/tty7/references/commands.md @@ -30,7 +30,7 @@ Set inside every tty7 pane, inherited by anything you launch from one. | Variable | Meaning | |---|---| -| `TTY7_PANE` | This pane's id, e.g. `71` or `%71` (both forms are accepted). The default target of `split`, `send`, `capture`, `procs`, `wait`, `pane close`. | +| `TTY7_PANE` | This pane's id, e.g. `71` or `%71` (both forms are accepted). The default target of `split`, `send`, `exec`, `capture`, `procs`, `wait`, `pane close`. | | `TTY7_WS` | This pane's workspace id. The default for `run --keep`, `tab new`, `tab ls`, `ws tree`. | | `TTY7_CONFIG_DIR` | The server's config dir. How the CLI finds the right server's sockets — you never pass a socket path. | @@ -44,13 +44,13 @@ Outside a tty7 shell the address-taking verbs fail with | 0 | success | | 1 | the command failed; the reason is one line on stderr, prefixed `tty7:` | | 2 | usage error (clap) — unknown verb, missing argument, bad type | -| 124 | `tty7 wait` gave up — the `timeout(1)` convention, so "not yet" is distinguishable from "broken" | +| 124 | `tty7 wait` or `tty7 exec` gave up — the `timeout(1)` convention, so "not yet" is distinguishable from "broken" | | 141 | Unix only: the reader hung up (`| head -1`) and SIGPIPE ended it, exactly as it ends `cat`. Not a failure. Windows reports 0 for the same thing, having no signal to imitate. | -| *other* | only from `tty7 run`, which passes the child's exit code through | +| *other* | only from `tty7 run` and `tty7 exec`, which pass the command's exit code through | -If `run` cannot learn the child's code it prints a note to stderr and exits 1 -with `"exit_code_known": false` in the JSON — that is how you tell a real 1 -from a stand-in. +If `run` or `exec` cannot learn the command's code it prints a note to stderr +and exits 1 with `"exit_code_known": false` in the JSON — that is how you tell +a real 1 from a stand-in. ## Top-level verbs @@ -85,6 +85,41 @@ the child, so `tty7 run -- cargo test --keep` passes `--keep` to cargo. JSON: `{"pane","exit","exit_code_known","kept"}`, printed **after** the streamed output. The combined stream is not valid JSON; read the last line. +### `tty7 exec [%PANE] [--timeout SECS] [--raw] -- CMD...` +Runs `CMD` in an existing pane's shell and blocks until its prompt comes back: +the words after `--` are joined with spaces and typed at the prompt as one line +followed by Enter, so the shell interprets them — quote `&&`, `|` and `>` to +keep them from your own shell. Prints what the command printed and exits with +its exit code. The pane (default `$TTY7_PANE`) keeps its cwd, environment and +history, which is the difference from `run`: `run` makes a new pane for an +argv, `exec` uses a shell you already have. + +The shell integration's OSC 133 marks bound the command: the output is what the +pane printed between the command starting (`C`) and finishing (`D`, which also +carries the exit code). Refused, with nothing typed: + +- a pane whose shell has sent no prompt mark — no integration, so nothing would + ever say the command ended; `tty7 doctor`, or fall back to `send` + `wait + --until free` +- a pane that is not at a prompt — something already runs there (your own pane + always is: it is running `tty7 exec`) +- a line holding a control character (newline, tab, ESC), which the line editor + would act on rather than type — join commands with `;` / `&&` + +| Flag | | +|---|---| +| `--timeout SECS` | Give up after SECS, exit 124. The command is left running; `send %N --key C-c` stops it. With no timeout it waits for as long as the command runs | +| `--raw` | The output as the pane received it, escapes intact. By default it is replayed through a terminal to text, the way `capture --plain` reads it — which also erases zsh's PROMPT_SP, drawn before the finish mark | + +A line the shell never starts — an unclosed quote waiting on a continuation +prompt — never finishes; the timeout message says that is the likely cause. +A line the shell rejects outright (a syntax error) exits 1 with +`"exit_code_known": false` and the shell's complaint as the output. Output past +8 MiB is dropped from the front, with a note on stderr. + +JSON: `{"pane","command","exit","exit_code_known","timed_out","pane_exited","output","bytes","elapsed_ms"}` +— `output` is the printed form, `bytes` the raw length. + ### `tty7 new [PATH] [--open]` Creates a workspace plus its first tab and shell, at `PATH` if given. Prints the workspace id. JSON: `{"id","pane","opened"}`. @@ -100,7 +135,7 @@ pane below, `--h`/`--horizontal` to the right. `--ratio` (default 0.5) is the share kept by the *existing* pane, clamped to 0.05–0.95 — a `--ratio 70` silently becomes 0.95, not an error. Prints `%NN`. JSON: `{"pane"}`. -### `tty7 send [%PANE] [TEXT] [--enter] [--key KEY]…` +### `tty7 send [%PANE] [TEXT | --stdin | --from-file PATH] [--paste] [--enter] [--key KEY]…` Types `TEXT` into the pane as keystrokes; `--enter` is shorthand for `--key enter` — it appends CR to the text, or presses Enter on its own when there is none, so `tty7 send %42 --enter` runs whatever pane 42 already has typed. With @@ -115,7 +150,28 @@ that still doesn't parse (`%3x`) is an address error, never text for your own pane — while text that merely starts with `%` (`%s/foo/bar/`, `%!sort`) types as given, as does anything unmarked that is not a plain number (`3x`, `+5`). To type an address-shaped string, name the pane as well: `tty7 send %42 %3x`. -JSON: `{"pane","sent","enter","keys"}`. +JSON: `{"pane","sent","source","bytes","paste","bracketed","bracketed_paste_mode","enter","keys"}` +— `source` is `argument`, `stdin`, `file` or `none`. + +`--stdin` / `--from-file PATH` take the text from there instead of an argument +— for a secret, which as an argument sits in `ps` for every local user and in +your shell history. The bytes go to the pane exactly as read (no escape +interpretation, no newline conversion, not required to be UTF-8), and `sent` is +`null` in the JSON so they are not echoed into a log either. With either, the +one positional is the pane and must be a valid address. Use `printf %s`, not +`echo`, unless you mean the trailing newline — on a pty that newline is Enter. +Whether the far side echoes the secret is up to the program reading it, not +tty7: `docker login --password-stdin`, `ssh-add` and `gpg` turn echo off. + +`--paste` sends the text as the GUI sends a paste. When the pane has switched on +bracketed paste (mode 2004 — shells at their prompt and most TUIs do), the text +is wrapped in `ESC[200~ … ESC[201~` with CRLF folded to LF and every ESC byte +stripped, so the text cannot end the paste early; multi-line text then lands as +one block instead of running a line at a time. When the pane has not, the text +goes unframed with each line break sent as Enter — what a paste into such a pane +has always done — and a note goes to stderr. `bracketed` says which was sent; +`bracketed_paste_mode` is what the pane reported (`null` from a server too old +to say). `--enter` after `--paste` submits the pasted block. `--key` presses a key instead of typing characters — the arrow keys a permission prompt wants, the `escape` that closes a TUI, the `C-c` that stops a @@ -196,9 +252,13 @@ The process tree inside the pane, indented by depth, `*` on the foreground process — then a second table of ports those processes are listening on. Prints `nothing running in this pane` when both are empty. -JSON: `{"procs":[{"pid","name","depth","foreground"}],"ports":[{"port","pid","name","addr"}]}`, +JSON: `{"procs":[{"pid","name","depth","foreground"}],"ports":[{"port","pid","name","addr"}],"context":{…}}`, where `addr` is the address the socket is bound to (`*`, `0.0.0.0`, `127.0.0.1`, -`[::1]`, or a specific interface). +`[::1]`, or a specific interface). `context` is what the pane says about itself: +`local_pty`, `remote` (the host it is pointed at), `at_prompt` (from its shell +integration's marks; absent until the first one), `remote_prompt_seen`, and +`bracketed_paste` (whether the pane has mode 2004 on — what `send --paste` +reads). Nothing below the depth-0 shell means the foreground command has exited — but you rarely need to check that by hand, because that is exactly what diff --git a/src/core/actions.rs b/src/core/actions.rs index 836838a9..bf9fff64 100644 --- a/src/core/actions.rs +++ b/src/core/actions.rs @@ -30,12 +30,15 @@ actions!( RevealLinkUnderPointer, CopyLinkPathUnderPointer, MarkTabUnread, + HibernateTab, ForkAgentSession, ForkAgentSessionRight, ForkAgentSessionLeft, ForkAgentSessionDown, ForkAgentSessionUp, CopyAgentSessionId, + NewAgentTab, + SaveAgentLaunchArgs, SplitRight, SplitDown, FocusNextPane, @@ -125,3 +128,11 @@ actions!( Quit ] ); + +/// Open a new tab running one particular coding agent. The keymap names one of +/// these per agent (`LaunchAgent:claude`, …); see `ui::agent_launch`. +#[derive(Clone, Debug, PartialEq, gpui::Action)] +#[action(namespace = tty7, no_json)] +pub struct LaunchAgent { + pub agent: tty7_core::core::cli_agent::CLIAgent, +} diff --git a/src/core/agent_prompt.rs b/src/core/agent_prompt.rs index d1fb5e69..50e4187b 100644 --- a/src/core/agent_prompt.rs +++ b/src/core/agent_prompt.rs @@ -51,9 +51,8 @@ pub fn build_diff_review_prompt(diff: &str, cwd: Option<&str>) -> Option } pub fn submit_bytes(prompt: &str) -> Vec { - let mut bytes = b"\x1b[200~".to_vec(); - bytes.extend(prompt.bytes().filter(|&b| b != 0x1b)); - bytes.extend_from_slice(b"\x1b[201~\r"); + let mut bytes = tty7_core::core::paste::bracket(prompt.as_bytes()); + bytes.push(b'\r'); bytes } diff --git a/src/core/session.rs b/src/core/session.rs index 4cf3af92..ac897bbe 100644 --- a/src/core/session.rs +++ b/src/core/session.rs @@ -161,6 +161,24 @@ impl WorkspaceStore { store.views.save(); } + /// Note that `agent` ran in workspace `id` (see `WindowView::seen_agents`). + pub fn record_agent_seen( + cx: &mut gpui::App, + id: WorkspaceId, + agent: tty7_core::core::cli_agent::CLIAgent, + ) { + let Some(store) = Self::try_store(cx) else { + return; + }; + if store + .views + .get_mut(id) + .is_some_and(|view| view.saw_agent(agent.slug())) + { + store.views.save(); + } + } + pub fn host_of(cx: &gpui::App, id: WorkspaceId) -> HostId { host_for(Self::all(cx), id) } diff --git a/src/core/ssh_config.rs b/src/core/ssh_config.rs index c98b452f..101e5ea2 100644 --- a/src/core/ssh_config.rs +++ b/src/core/ssh_config.rs @@ -883,6 +883,7 @@ fn parse_forward_rule(kind: ForwardKind, value: &str) -> Option { bind, target, description: String::new(), + enabled: true, }) } diff --git a/src/main.rs b/src/main.rs index 8a9500df..7c9f73a7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -48,10 +48,13 @@ fn spawn_config_watcher(cx: &mut App) { if !tty7_core::host::is_content_change(&event.kind) { return; } - let hit = event - .paths - .iter() - .any(|p| p.file_name() == watched_file.file_name() || is_theme_file(p)); + // The saved SSH hosts live beside config.json in a file of their own + // (#911), and a hand edit there has to land the same way. + let hit = event.paths.iter().any(|p| { + p.file_name() == watched_file.file_name() + || p.file_name() == Some(std::ffi::OsStr::new(crate::core::config::SERVERS_FILE)) + || is_theme_file(p) + }); if hit { let _ = tx.try_send(()); } @@ -705,6 +708,7 @@ fn main() { .detach(); gpui_component::init(cx); register_bundled_fonts(cx); + crate::ui::prompt::install(cx); cx.activate(true); #[cfg(target_os = "macos")] set_dock_icon_for_bare_binary(); diff --git a/src/terminal/element.rs b/src/terminal/element.rs index 448aedc1..7424b570 100644 --- a/src/terminal/element.rs +++ b/src/terminal/element.rs @@ -884,8 +884,8 @@ fn segment_row(row: &[RenderCell]) -> Vec { thread_local! { static CHAR_STRINGS: RefCell> = RefCell::new(HashMap::new()); - /// Measured ink extents and the font size they were measured at. - static INK_EXTENTS: RefCell<(Pixels, HashMap<(gpui::FontId, char), Option>)> = + /// Measured ink bounds and the font size they were measured at. + static INK_BOUNDS: RefCell<(Pixels, HashMap<(gpui::FontId, char), Option>>)> = RefCell::new((px(0.), HashMap::new())); } @@ -1089,6 +1089,9 @@ fn seg_budget(solo: bool, measured: bool, cells: usize, room: bool, cell_width: } /// How much to shrink a segment so its glyph stops inside its budget. +/// +/// Never more than 1. A fallback icon is not held to this at all: +/// [`icon_fit`] sizes it, up or down. fn fit_scale(ink: Pixels, budget: Pixels) -> f32 { if ink <= budget || ink <= px(0.) { 1. @@ -1097,22 +1100,157 @@ fn fit_scale(ink: Pixels, budget: Pixels) -> f32 { } } -/// Where a segment's ink ends, measured from the left edge of its first cell. +/// Whether the platform's `typographic_bounds` is the glyph's ink. +/// +/// CoreText and DirectWrite answer with the ink box. cosmic-text answers with +/// the advance box pinned to the baseline, which says nothing about where an +/// icon's ink sits; sizing and centring on that would misplace it, so the +/// Linux build keeps the shrink-only rule for icons too. +const INK_BOUNDS_ARE_INK: bool = cfg!(any(target_os = "macos", target_os = "windows")); + +/// The most an icon is ever enlarged by. +/// +/// The cap keeps a glyph that is small by design — a dot, a bullet — from +/// being blown up to a cell-sized blob. +const MAX_ICON_GROWTH: f32 = 2.; + +/// Whether a character is an icon: a code point in one of Unicode's Private +/// Use Areas, where Nerd Fonts, Font Awesome, Codicons and Material Design +/// icons all live. +/// +/// Nothing in these ranges is text, so sizing one to its cells cannot change +/// how prose reads — the reason the rule is keyed on the code point and not +/// on the face: a CJK or emoji fallback keeps its own metrics. +/// +/// The Powerline separators (`U+E0B0`–`U+E0D7`) are not icons. They are +/// shapes meant to abut the cells around them, the solid ones are painted +/// natively, and the thin ones already reach the row's full height, so +/// fitting them to an em and centring them would only pull them away from +/// the segments they join. +fn is_icon(c: char) -> bool { + matches!( + c as u32, + 0xE000..=0xE0AF | 0xE0D8..=0xF8FF | 0xF_0000..=0xF_FFFD | 0x10_0000..=0x10_FFFD + ) +} + +/// Whether the cell after an icon can lend it room, the way kitty lets a +/// Private Use Area glyph followed by a space take two cells. +/// +/// Everything [`has_room_after`] lends, and one thing more: a blank that +/// paints the *same* background as the icon's own cell. That is the space +/// after every icon in a coloured prompt segment, and backgrounds are painted +/// before any glyph, so the icon lands on its own colour. Held back to one +/// cell there, a Nerd Font icon is shrunk to about two thirds of the text's +/// height, which is #866. +fn lends_to_icon(row: &[RenderCell], start: usize, cells: usize) -> bool { + if has_room_after(row, start, cells) { + return true; + } + let (Some(icon), Some(next)) = (row.get(start), row.get(start + cells)) else { + return false; + }; + is_blank(next) + && !next.selected + && icon.draw_bg + && next.draw_bg + && next.bg == icon.bg + && next.match_hit == icon.match_hit + && next.match_current == icon.match_current + && !GlyphStyle::of(next).draws_on_blanks() +} + +/// How to size an icon a fallback face supplied, and how many cells it spans. +/// +/// A fallback face is drawn at the primary's font size on its own metrics, so +/// what an icon looks like beside the text depends on how the two faces' +/// cells compare. Symbols Nerd Font Mono inks a full em per icon, which is +/// 1.6 cells of a 0.6em primary; a face with a narrower cell than the +/// primary's inks less than one. Neither is the icon at the text's size. +/// +/// So fit the ink, aspect ratio kept, to the cells it is given and one em of +/// height — the size the icon was drawn at in its own face, and never taller +/// than the text's line. That may shrink or grow it, growth capped at +/// [`MAX_ICON_GROWTH`] and ignored under 2%, which is not worth a reshape. +/// When `lend` says the next cell is free the icon may take it too, but only +/// if that lets it come out bigger: a narrow glyph that already fits one cell +/// keeps to it and stays beside what follows. +fn icon_fit( + ink: Bounds, + cell_width: Pixels, + cells: usize, + lend: bool, + height: Pixels, +) -> (f32, usize) { + let (w, h) = (ink.size.width.as_f32(), ink.size.height.as_f32()); + if !(w > 0. && h > 0.) { + return (1., cells); + } + let fit = |cells: usize| { + let scale = (cell_width.as_f32() * cells as f32 / w) + .min(height.as_f32() / h) + .min(MAX_ICON_GROWTH); + if (1. ..=1.02).contains(&scale) { + 1. + } else { + scale + } + }; + let own = fit(cells); + if lend && fit(cells + 1) > own { + (fit(cells + 1), cells + 1) + } else { + (own, cells) + } +} + +/// Where to paint a fitted icon, relative to its cell's top-left corner, so +/// its ink sits centred in `room` — vertically always, and horizontally only +/// when `centre_x`. An icon spilling into a borrowed cell stays where its +/// face put it instead, as it would unfitted, so the gap before the text +/// after it is the one its face drew. +/// +/// `ink` is the glyph's bounds at its native size (y up from the baseline); +/// `ascent` and `descent` are the line's as shaped at the fitted size, which +/// is what gpui centres the baseline in the row with. +fn icon_offset( + ink: Bounds, + scale: f32, + room: gpui::Size, + centre_x: bool, + ascent: Pixels, + descent: Pixels, +) -> Point { + let baseline = (room.height - ascent - descent) / 2. + ascent; + let ink_top = baseline - (ink.origin.y + ink.size.height) * scale; + let x = if centre_x { + (room.width - ink.size.width * scale) / 2. - ink.origin.x * scale + } else { + // Nudged only as far as it takes to keep the ink inside the room. + px(0.) + .min(room.width - (ink.origin.x + ink.size.width) * scale) + .max(-ink.origin.x * scale) + }; + point(x, (room.height - ink.size.height * scale) / 2. - ink_top) +} + +/// The ink bounds of a segment's first glyph, relative to its pen position, +/// y up from the baseline. /// /// Advance is the wrong yardstick: Apple Color Emoji advances 1.31em but only /// inks 1.25em, so going by advance shrinks glyphs that would have fit. Ask /// for the glyph's own bounds instead, cached per (face, char) because the /// lookup is a font query a screenful of CJK would otherwise repeat on every /// cell of every frame. -fn ink_extent( +fn ink_bounds( cx: &App, shaped: &gpui::ShapedLine, text: &str, font_size: Pixels, -) -> Option { +) -> Option> { let font_id = shaped.runs.first()?.font_id; let c = text.chars().next()?; - INK_EXTENTS.with(|cache| { + INK_BOUNDS.with(|cache| { let hit = { let mut cache = cache.borrow_mut(); if cache.0 != font_size { @@ -1127,13 +1265,12 @@ fn ink_extent( if let Some(extent) = hit { return extent; } - let extent = cx + let bounds = cx .text_system() .typographic_bounds(font_id, font_size, c) - .ok() - .map(|bounds| bounds.origin.x + bounds.size.width); - cache.borrow_mut().1.insert((font_id, c), extent); - extent + .ok(); + cache.borrow_mut().1.insert((font_id, c), bounds); + bounds }) } @@ -1234,7 +1371,7 @@ fn shape_piece( .shape_line(piece, font_size, run_buf, force_width) } -/// Whether [`ink_extent`]'s answer speaks for the whole segment. +/// Whether [`ink_bounds`]'s answer speaks for the whole segment. /// /// It measures the segment's first character in the run's first face. That is /// all of a one-character segment and only part of anything longer: a @@ -1284,6 +1421,9 @@ fn paint_glyphs( build_font(bold_font.unwrap_or(base_font), true, true), ]; + // Resolved only once an icon asks, so a pane without one pays nothing. + let mut primary_ids: [Option; 4] = [None; 4]; + let run_buf = &mut [TextRun { len: 0, font: base_font.clone(), @@ -1435,17 +1575,40 @@ fn paint_glyphs( .shape_line(text.clone(), font_size, run_buf, force_width); // Measured before the budget is set, because how far a solo glyph // may reach turns on whether its ink is known at all. - let ink = ink_extent(cx, &shaped, &text, font_size); + let bounds = ink_bounds(cx, &shaped, &text, font_size); + let ink = bounds.map(|b| b.origin.x + b.size.width); + let measured = ink_covers_segment(ink, &text); let budget = seg_budget( solo, - ink_covers_segment(ink, &text), + measured, cells, has_room_after(row_cells, start, cells), geom.cell_width, ); - if let Some(ink) = ink { - let scale = fit_scale(ink, budget); - if scale < 1. { + let mut origin = point(x, y); + let mut clip_width = budget; + if let (Some(ink), Some(bounds)) = (ink, bounds) { + // Only a lone icon that a fallback face supplied is fitted: + // the primary's own icons were drawn for its cell already. + let icon = INK_BOUNDS_ARE_INK + && measured + && text.chars().next().is_some_and(is_icon) + && shaped.runs.first().is_some_and(|r| { + r.font_id + != *primary_ids[face_ix].get_or_insert_with(|| { + window.text_system().resolve_font(&faces[face_ix]) + }) + }); + let (scale, room) = if icon { + let lend = lends_to_icon(row_cells, start, cells); + let height = font_size.min(geom.line_height); + let (scale, spans) = icon_fit(bounds, geom.cell_width, cells, lend, height); + let room = size(geom.cell_width * spans as f32, geom.line_height); + (scale, Some((room, spans == cells))) + } else { + (fit_scale(ink, budget), None) + }; + if scale != 1. { shaped = window.text_system().shape_line( text.clone(), font_size * scale, @@ -1453,18 +1616,16 @@ fn paint_glyphs( force_width, ); } + if let Some((room, centre_x)) = room { + origin += + icon_offset(bounds, scale, room, centre_x, shaped.ascent, shaped.descent); + clip_width = room.width; + } } - let clip = Bounds::new(point(x, y), size(budget, geom.line_height)); + let clip = Bounds::new(point(x, y), size(clip_width, geom.line_height)); window.with_content_mask(Some(ContentMask { bounds: clip }), |window| { - _ = shaped.paint( - point(x, y), - geom.line_height, - TextAlign::Left, - None, - window, - cx, - ); + _ = shaped.paint(origin, geom.line_height, TextAlign::Left, None, window, cx); }); } } @@ -2675,11 +2836,12 @@ mod tests { } #[test] - fn fit_scale_only_shrinks_glyphs_that_overflow_their_budget() { + fn fit_scale_shrinks_what_overflows_and_never_enlarges() { let budget = px(15.); assert_eq!(fit_scale(px(19.2), budget), 15. / 19.2); // Whatever already fits keeps its own size, including a glyph that - // lands exactly on the edge and a run that measured as empty. + // lands exactly on the edge and a run that measured as empty. Making + // a small glyph bigger is `icon_fit`'s rule, not this one's. assert_eq!(fit_scale(px(15.), budget), 1.); assert_eq!(fit_scale(px(12.), budget), 1.); assert_eq!(fit_scale(px(0.), budget), 1.); @@ -4452,4 +4614,176 @@ mod tests { Some(&['\u{FE0F}'][..]), ); } + + fn ink(x: f32, y: f32, w: f32, h: f32) -> Bounds { + Bounds::new(point(px(x), px(y)), size(px(w), px(h))) + } + + /// Menlo at 22px: a 13.2px cell in a 30.8px row (line height 1.4). + const CELL: f32 = 13.2; + const EM: f32 = 22.; + + fn fit(ink: Bounds, cells: usize, lend: bool) -> (f32, usize) { + icon_fit(ink, px(CELL), cells, lend, px(EM)) + } + + #[test] + fn only_private_use_code_points_count_as_icons() { + for c in [ + '\u{e000}', // first of the BMP Private Use Area + '\u{e0a0}', // Powerline branch + '\u{e0d8}', // first past the Powerline separators + '\u{f179}', // Font Awesome apple + '\u{f8ff}', // last of the BMP Private Use Area + '\u{f0001}', // Material Design icon, Supplementary PUA-A + '\u{10fffd}', // end of Supplementary PUA-B + ] { + assert!(is_icon(c), "U+{:04X}", c as u32); + } + // Text a fallback face supplies keeps its own metrics: CJK, emoji, + // Latin, and the drawing characters painted natively anyway. + for c in [ + '中', + 'あ', + '한', + '\u{1F600}', + '\u{2764}', + 'M', + '─', + '█', + '\u{f900}', + ] { + assert!(!is_icon(c), "U+{:04X}", c as u32); + } + // Nor do the Powerline separators, which are shapes, not icons. + for c in ['\u{e0b0}', '\u{e0b1}', '\u{e0b3}', '\u{e0d7}'] { + assert!(!is_icon(c), "U+{:04X}", c as u32); + } + } + + #[test] + fn a_nerd_font_icon_before_a_space_takes_two_cells_at_full_size() { + // Symbols Nerd Font Mono's apple, as CoreText measures it at 22px: a + // full em tall and 1.4 cells wide. Held to one cell it is shrunk to + // 0.71 of the text's size, which is what #866 saw; with the space + // after it lent, it keeps its whole em. + let apple = ink(1.76, -4.43, 18.48, 22.03); + let (held, cells) = fit(apple, 1, false); + assert_eq!(cells, 1); + assert!((apple.size.width.as_f32() * held - CELL).abs() < 1e-3); + let (lent, cells) = fit(apple, 1, true); + assert_eq!(cells, 2); + assert!((apple.size.height.as_f32() * lent - EM).abs() < 1e-3); + assert!(apple.size.width.as_f32() * lent <= 2. * CELL); + } + + #[test] + fn a_narrow_icon_keeps_to_its_own_cell_even_when_the_next_is_free() { + // The branch symbol is an em tall and 0.4em wide: one cell already + // holds it at full height, so borrowing would only push it off + // towards the text after it. + let branch = ink(6.52, -4.4, 8.96, 22.); + assert_eq!(fit(branch, 1, true), (1., 1)); + } + + #[test] + fn a_small_icon_from_a_narrow_face_grows_to_fill_its_cell() { + // A face with a narrower cell than the primary inks well under it. + let icon = ink(0.5, -1., 8.8, 9.); + let (scale, cells) = fit(icon, 1, false); + assert_eq!(cells, 1); + assert!(scale > 1.); + assert!((icon.size.width.as_f32() * scale - CELL).abs() < 1e-3); + // The same icon two cells wide may use both. + let (two, cells) = fit(icon, 2, false); + assert_eq!(cells, 2); + assert!(two > scale); + } + + #[test] + fn a_fitted_icon_is_centred_in_its_room() { + let icon = ink(0.4, -0.6, 5.3, 6.); + let room = size(px(CELL), px(30.8)); + let (scale, _) = fit(icon, 1, false); + let (ascent, descent) = (px(20.4 * scale), px(5.2 * scale)); + let at = icon_offset(icon, scale, room, true, ascent, descent); + // Spilling into a borrowed cell, it stays where its face put it, + // unless that would take its ink past either edge of the room. + assert_eq!( + icon_offset(icon, scale, room, false, ascent, descent).x, + px(0.) + ); + let lent = size(px(2. * CELL), px(30.8)); + let overhang = ink(-1., 0., 20., 20.); + assert_eq!( + icon_offset(overhang, 1., lent, false, ascent, descent).x, + px(1.) + ); + let pushed = ink(8., 0., 20., 20.); + let x = icon_offset(pushed, 1., lent, false, ascent, descent).x; + assert!((x.as_f32() - (2. * CELL - 28.)).abs() < 1e-4, "{x:?}"); + let left = at.x.as_f32() + icon.origin.x.as_f32() * scale; + let right = left + icon.size.width.as_f32() * scale; + assert!((left - (CELL - right)).abs() < 1e-3, "{left}..{right}"); + let baseline = at.y + (room.height - ascent - descent) / 2. + ascent; + let top = (baseline - (icon.origin.y + icon.size.height) * scale).as_f32(); + let bottom = (baseline - icon.origin.y * scale).as_f32(); + assert!((top - (30.8 - bottom)).abs() < 1e-3, "{top}..{bottom}"); + } + + #[test] + fn icon_fit_leaves_what_is_already_the_right_size_alone() { + // Within 2% of the cell: not worth shaping the glyph again. + assert_eq!(fit(ink(0., 0., 13., 13.), 1, false), (1., 1)); + // Nothing measured, nothing to scale by. + assert_eq!(fit(ink(0., 0., 0., 0.), 1, true), (1., 1)); + // A dot is small by design and grows no more than the cap. + assert_eq!(fit(ink(1., 2., 2., 2.), 1, false).0, MAX_ICON_GROWTH); + } + + fn icon_row(next: RenderCell) -> Vec { + let icon = RenderCell { + c: '\u{f179}', + ..RenderCell::default() + }; + vec![icon, next] + } + + #[test] + fn a_blank_in_the_same_colour_lends_an_icon_its_cell() { + let blank = RenderCell::default(); + assert!(lends_to_icon(&icon_row(blank.clone()), 0, 1)); + // Whatever `has_room_after` lends, the end of the row included. + assert!(lends_to_icon(&icon_row(blank.clone())[..1], 0, 1)); + // A prompt segment: icon and the space after it on one background. + let bg = Hsla::blue(); + let mut row = icon_row(RenderCell { + bg, + draw_bg: true, + ..RenderCell::default() + }); + row[0].bg = bg; + row[0].draw_bg = true; + assert!(lends_to_icon(&row, 0, 1)); + // The next segment's colour, a glyph or a selection do not. + row[1].bg = Hsla::red(); + assert!(!lends_to_icon(&row, 0, 1)); + let letter = RenderCell { + c: 'x', + ..RenderCell::default() + }; + assert!(!lends_to_icon(&icon_row(letter), 0, 1)); + let selected = RenderCell { + selected: true, + ..RenderCell::default() + }; + assert!(!lends_to_icon(&icon_row(selected), 0, 1)); + // Nor does a coloured blank after an icon on the default background. + let coloured = RenderCell { + bg, + draw_bg: true, + ..blank + }; + assert!(!lends_to_icon(&icon_row(coloured), 0, 1)); + } } diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index b319c5c2..9673e5d2 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -571,6 +571,10 @@ pub struct RemoteTerminal { pub exited: bool, size: TermSize, synced_size: bool, + /// This link reattached to a pane that was already running rather than + /// spawning a fresh one, so whatever it reports first — an agent in the + /// foreground included — predates this client. + reattached: bool, /// The `(cell_w, cell_h)` last sent to the daemon, in device pixels. Tracked /// alongside `size` so a display-scale change still reaches the child even /// when the grid dimensions are unchanged. @@ -1087,6 +1091,7 @@ impl RemoteTerminal { exited: false, size, synced_size: false, + reattached: awaiting_replay, synced_cell: (0, 0), resize_sent_at: None, link, @@ -1878,6 +1883,11 @@ impl RemoteTerminal { self.remote_context.lock().ok().and_then(|g| g.clone()) } + /// See the field: an attach to a running pane, not a fresh spawn. + pub fn reattached(&self) -> bool { + self.reattached + } + pub fn at_prompt(&self) -> bool { self.shell_state .lock() @@ -2296,6 +2306,29 @@ impl RemoteTerminal { .ok() } + /// The list after switching one forward on or off, or why it was not + /// switched — the daemon's own refusal, which names what is in the way. + pub fn set_forward_enabled( + pane_id: u64, + forward_id: u64, + enabled: bool, + ) -> anyhow::Result> { + let mut stream = connect()?; + ClientMsg::SetForwardEnabled { + pane_id, + forward_id, + enabled, + } + .encode(&mut stream)?; + match DaemonMsg::read(&mut stream)? { + DaemonMsg::ForwardList(list) => Ok(list), + DaemonMsg::Error(msg) => Err(anyhow::anyhow!(msg)), + other => Err(anyhow::anyhow!( + "unexpected reply to SetForwardEnabled: {other:?}" + )), + } + } + pub fn list_forwards(pane_id: u64) -> Vec { fn query(pane_id: u64) -> anyhow::Result> { let mut stream = connect()?; diff --git a/src/terminal/view.rs b/src/terminal/view.rs index d2f687e8..6ccac9f9 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -27,8 +27,8 @@ use super::typeahead::{RawInput, Typeahead}; use crate::core::actions::{ CloseActiveTab, CopyLinkPathUnderPointer, DecreaseFontSize, ForkAgentSessionDown, ForkAgentSessionLeft, ForkAgentSessionRight, ForkAgentSessionUp, IncreaseFontSize, NewTab, - OpenLinkUnderPointer, RevealLinkUnderPointer, SendBackTab, SendTab, SplitDown, SplitRight, - ToggleMaximizePane, + OpenLinkUnderPointer, RevealLinkUnderPointer, SaveAgentLaunchArgs, SendBackTab, SendTab, + SplitDown, SplitRight, ToggleMaximizePane, }; use crate::core::config::{BellMode, Config, LinkFileOpen, MouseZoomModifier, NotifyMode}; use crate::core::shell_quote::quote_for_shell; @@ -140,6 +140,12 @@ pub struct AgentSessionChanged; impl gpui::EventEmitter for TerminalView {} +/// A coding agent started running in this pane: its foreground went from no +/// agent to this one. What quick launch counts as the agent being used. +pub struct AgentDetected(pub crate::core::cli_agent::CLIAgent); + +impl gpui::EventEmitter for TerminalView {} + /// A file link the user clicked, on its way to whoever can show it. The /// terminal resolves the path — it is the only thing that knows the pane's /// directory and host — and the app opens it, because the editor and the file @@ -419,6 +425,15 @@ pub struct TerminalView { running_since: Option, running_title: String, running_agent: Option, + /// The foreground agent as of the last poll, so a new one is reported + /// once ([`AgentDetected`]) rather than on every frame it keeps running. + seen_agent: Option, + /// Whether an agent appearing here is news. A pane this view spawned is + /// armed from the start; one it reattached to — an app restart, a + /// workspace switched back to — may already be running an agent that was + /// counted when it started, so it arms only once its shell is seen back + /// at the prompt with no agent in front. + agent_detection_armed: bool, last_agent_status: Option, last_agent_session: (Option, Option>), agent_turn_started: Option, @@ -833,15 +848,7 @@ fn ring_system_bell() -> bool { } fn paste_bytes(text: &str, bracketed: bool) -> Vec { - if bracketed { - let text = text.replace("\r\n", "\n"); - let mut bytes = b"\x1b[200~".to_vec(); - bytes.extend(text.bytes().filter(|&b| b != 0x1b)); - bytes.extend_from_slice(b"\x1b[201~"); - bytes - } else { - text.replace("\r\n", "\r").replace('\n', "\r").into_bytes() - } + tty7_core::core::paste::paste_bytes(text.as_bytes(), bracketed) } /// A line the shell can be handed byte for byte, as if it had been typed at its @@ -1435,6 +1442,7 @@ impl TerminalView { .map(crate::core::config::gpui_font_features); let report_mouse = config.mouse_reporting; let prompt_editor = config.prompt_editor; + let agent_detection_armed = !terminal.reattached(); let mut font = gpui::font(font_family); font.fallbacks = Some(gpui::FontFallbacks::from_fonts(fallbacks.clone())); if let Some(features) = &font_features { @@ -1649,6 +1657,8 @@ impl TerminalView { running_since: None, running_title: String::new(), running_agent: None, + seen_agent: None, + agent_detection_armed, last_agent_status: None, last_agent_session: (None, None), agent_turn_started: None, @@ -1757,6 +1767,35 @@ impl TerminalView { stated_title(&self.title) } + /// The title this pane gives its tab: [`Self::stated_title`], unless this + /// is an SSH pane and Settings pins its tab to the host's name instead + /// (#726). The pane's own title is untouched either way — OSC 0/2 keep + /// landing in it, and it is back on the tab the moment the setting is. + /// + /// A pane that has ended still says so: the pinned name takes the same + /// suffix the pane's own title would have. + pub(crate) fn tab_title(&self, cx: &App) -> Option { + let pinned = self.ssh_spec.as_deref().and_then(|spec| { + let cfg = cx.try_global::()?; + crate::ui::ssh_connect::pinned_ssh_title(cfg.ssh_tab_title, spec, &cfg.ssh_profiles) + }); + match pinned { + Some(name) if self.terminal.exited => Some(self.ended_title(&name)), + Some(name) => Some(name), + None => self.stated_title().map(str::to_string), + } + } + + /// `name` with the suffix that says how this pane ended. + fn ended_title(&self, name: &str) -> String { + let key = if self.workspace().is_some() && !self.terminal.child_exited() { + L10nKey::PaneTitleDisconnected + } else { + L10nKey::PaneTitleProcessExited + }; + t_fmt(key, &[("title", name)]) + } + /// Sets how opaque the pane wants this terminal painted; the pane leaf /// calls this every frame while rendering, and the terminal element /// blends its colours toward the window background during paint (see @@ -2234,17 +2273,7 @@ impl TerminalView { self.pending_title = None; // The pane keeps answering to its own name (an SSH pane's // host, #438) — only the state suffix is localized (#602). - self.title = if self.workspace().is_some() && !self.terminal.child_exited() { - t_fmt( - L10nKey::PaneTitleDisconnected, - &[("title", &self.default_title)], - ) - } else { - t_fmt( - L10nKey::PaneTitleProcessExited, - &[("title", &self.default_title)], - ) - }; + self.title = self.ended_title(&self.default_title); if self.terminal.child_exited() { cx.emit(ChildExited); } @@ -3751,6 +3780,8 @@ impl TerminalView { _ => {} } + self.poll_agent_detection(at_prompt, cx); + let turn_finished = self.poll_agent_status(notify_allowed, window, cx); let session = self.terminal.agent_session(); @@ -4011,6 +4042,24 @@ impl TerminalView { ); } + /// Report an agent that has just started in this pane ([`AgentDetected`]), + /// once per start, and never one that was already running when this view + /// reattached to the pane. + fn poll_agent_detection(&mut self, at_prompt: bool, cx: &mut Context) { + let agent = self.terminal.foreground_agent(); + if agent != self.seen_agent { + self.seen_agent = agent; + if let Some(agent) = agent + && self.agent_detection_armed + { + cx.emit(AgentDetected(agent)); + } + } + if agent.is_none() && at_prompt { + self.agent_detection_armed = true; + } + } + fn poll_agent_status( &mut self, notify_allowed: bool, @@ -7305,6 +7354,12 @@ impl Render for TerminalView { let fork_ready = can_fork && view.remote_context().is_none() && view.agent_session().is_some_and(|s| s.session_id.is_some()); + // `None` over a pane with no agent; otherwise whether it knows + // the command line it was started with. + let launch_argv_known = view.agent().map(|_| { + view.agent_session() + .is_some_and(|s| s.launch_argv.is_some()) + }); let menu = match (can_fork, fork_ready) { (true, true) => { @@ -7338,6 +7393,21 @@ impl Render for TerminalView { (false, _) => menu, }; + // Only over a running agent, and only live when the pane knows + // the command line it was started with — there is nothing to + // save otherwise. Beside Fork when both are there. + let menu = match launch_argv_known { + Some(known) => { + let menu = if can_fork { menu } else { menu.separator() }; + menu.menu_with_disabled( + t(L10nKey::AppMenuSaveAgentLaunchArgs), + Box::new(SaveAgentLaunchArgs), + !known, + ) + } + None => menu, + }; + menu.separator() .menu(t(L10nKey::AppMenuSplitRight), Box::new(SplitRight)) .menu(t(L10nKey::AppMenuSplitDown), Box::new(SplitDown)) @@ -16208,6 +16278,96 @@ mod gpui_tests { }) .unwrap(); } + + /// Quick launch counts an agent each time one starts. Reattaching to a pane + /// whose agent was already running — every agent tab after an app restart + /// — is not a start, and counting it bumped every agent once per launch of + /// the app. Once that pane's agent exits, the next one is a start again. + #[gpui::test] + fn only_an_agent_that_starts_under_this_view_counts_as_detected(cx: &mut TestAppContext) { + use crate::core::cli_agent::CLIAgent; + + crate::core::config::pin_test_config_dir(); + let (window, _root_daemon) = harness(cx); + let detected = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let pane = |reattached: bool, cx: &mut TestAppContext| { + let (pane, daemon) = window + .update(cx, |_, window, cx| { + if reattached { + super::quiet_reattached_test_pane(2, window, cx) + } else { + super::quiet_test_pane(3, window, cx) + } + }) + .unwrap(); + let seen = detected.clone(); + cx.update(|cx| { + cx.subscribe(&pane, move |_, ev: &AgentDetected, _| { + seen.borrow_mut().push(ev.0) + }) + .detach() + }); + (pane, daemon) + }; + let report = |agent: Option, + at_prompt: bool, + pane: &Entity, + daemon: &mut Stream, + cx: &mut TestAppContext| { + DaemonMsg::Agent(agent).encode(daemon).unwrap(); + for _ in 0..200 { + if pane.read_with(cx, |p, _| p.terminal.foreground_agent()) == agent { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + pane.update(cx, |p, cx| p.poll_agent_detection(at_prompt, cx)); + cx.run_until_parked(); + }; + + let (restored, mut restored_daemon) = pane(true, cx); + report( + Some(CLIAgent::Claude), + false, + &restored, + &mut restored_daemon, + cx, + ); + report( + Some(CLIAgent::Claude), + false, + &restored, + &mut restored_daemon, + cx, + ); + assert_eq!( + *detected.borrow(), + vec![], + "already running when reattached" + ); + report(None, true, &restored, &mut restored_daemon, cx); + report( + Some(CLIAgent::Codex), + false, + &restored, + &mut restored_daemon, + cx, + ); + assert_eq!( + *detected.borrow(), + vec![CLIAgent::Codex], + "started after the reattach" + ); + + let (fresh, mut fresh_daemon) = pane(false, cx); + report(Some(CLIAgent::Claude), false, &fresh, &mut fresh_daemon, cx); + report(Some(CLIAgent::Claude), false, &fresh, &mut fresh_daemon, cx); + assert_eq!( + *detected.borrow(), + vec![CLIAgent::Codex, CLIAgent::Claude], + "a spawned pane counts its first agent, once" + ); + } } /// The window between the shell reporting a prompt and its line editor diff --git a/src/ui/agent_launch.rs b/src/ui/agent_launch.rs new file mode 100644 index 00000000..c7fd6210 --- /dev/null +++ b/src/ui/agent_launch.rs @@ -0,0 +1,449 @@ +//! Quick launch for the coding agents this machine can run (#955). +//! +//! Launching an agent never types into a pane that already exists. It opens a +//! new shell — a tab in the active tab's directory, or a split when the entry +//! point was asked for one — and types the agent's command line into *that* +//! shell once it is there. The agent then runs the way a hand-typed one does: +//! the daemon detects it from the pane's foreground, status and resume work as +//! usual, and quitting it leaves the shell behind. +//! +//! Which agents are offered: on this computer, those whose launch program is +//! on `PATH`. A remote workspace's `PATH` is not something the `Host` trait can +//! answer — it has no way to run a command or read the far shell's +//! environment — so there the list is the agents seen running in that +//! workspace before (`WindowView::seen_agents`). Either way the order is +//! frecency, recorded the way saved SSH hosts' is. + +use std::collections::HashMap; +use std::sync::LazyLock; + +use gpui::{App, Axis, Context, Window}; +use gpui_component::WindowExt as _; + +use crate::core::cli_agent::{CLIAgent, launch_program, program_on_path}; +use crate::core::config::{Config, ProfileUsage, unix_now}; +use crate::core::session::WorkspaceStore; +use crate::ui::app::{SpawnAs, SpawnWhere, Tty7App, join_shell_args}; +use crate::ui::i18n::{L10nKey, t_fmt}; +use crate::ui::pane::PaneSlot; + +/// What the New Tab menu's "Launch Agent…" row types into the palette: every +/// quick-launch command is titled `Agent: {name}` (the same word in every +/// language we ship), so this lands on the list with the cursor ready to +/// narrow it — the same seam `PALETTE_SSH_QUERY` is for hosts. +pub(crate) const PALETTE_AGENT_QUERY: &str = "agent"; + +/// How the keymap spells "launch this agent": `LaunchAgent:claude`. +const LAUNCH_ACTION_PREFIX: &str = "LaunchAgent:"; + +/// One keymap action name per agent, in [`CLIAgent::ALL`] order. +pub(crate) fn launch_action_names() -> &'static [(CLIAgent, String)] { + static NAMES: LazyLock> = LazyLock::new(|| { + CLIAgent::ALL + .into_iter() + .map(|agent| (agent, format!("{LAUNCH_ACTION_PREFIX}{}", agent.slug()))) + .collect() + }); + &NAMES +} + +pub(crate) fn launch_action_name(agent: CLIAgent) -> &'static str { + launch_action_names() + .iter() + .find(|(a, _)| *a == agent) + .map(|(_, name)| name.as_str()) + .expect("every agent has a launch action") +} + +pub(crate) fn agent_for_launch_action(action: &str) -> Option { + action + .strip_prefix(LAUNCH_ACTION_PREFIX) + .and_then(CLIAgent::from_slug) +} + +/// The agents whose launch program is on `path`, in [`CLIAgent::ALL`] order. +/// An agent with an `agent_launch` override is looked up by the override's +/// program, since that is what the launch will run. +pub(crate) fn installed_on( + path: &std::ffi::OsStr, + overrides: &HashMap, +) -> Vec { + CLIAgent::ALL + .into_iter() + .filter(|agent| { + launch_program(&agent.launch_command(overrides)) + .is_some_and(|program| program_on_path(&program, path)) + }) + .collect() +} + +/// `agents` most-used-first by frecency score; agents with the same score +/// keep the order they came in. +pub(crate) fn by_frecency( + agents: impl IntoIterator, + usage: &HashMap, + now: u64, +) -> Vec { + let score = |agent: &CLIAgent| usage.get(agent.slug()).map(|u| u.score(now)).unwrap_or(0.0); + let mut agents: Vec = agents.into_iter().collect(); + agents.sort_by(|a, b| { + score(b) + .partial_cmp(&score(a)) + .unwrap_or(std::cmp::Ordering::Equal) + }); + agents +} + +/// The agent "New Agent Tab" opens: of `offered`, the one launched or seen +/// most recently; the first offered when none has been used yet. +pub(crate) fn most_recent( + offered: &[CLIAgent], + usage: &HashMap, +) -> Option { + let last_used = |agent: &CLIAgent| usage.get(agent.slug()).map_or(0, |u| u.last_used); + offered + .iter() + .copied() + .filter(|agent| last_used(agent) > 0) + // `max_by_key` keeps the last of equals; reversed, that is the first. + .rev() + .max_by_key(last_used) + .or_else(|| offered.first().copied()) +} + +/// The `agent_launch` line "Set Current Launch Args as Default" writes for a +/// pane that `agent` is running in with `argv`. +pub(crate) fn launch_line_to_save(agent: CLIAgent, argv: &[String]) -> Option { + agent + .launch_argv_for_default(argv) + .map(|argv| join_shell_args(&argv)) +} + +/// Make `line` the command `agent` is launched with from now on. +pub(crate) fn remember_launch_line(cfg: &mut Config, agent: CLIAgent, line: String) { + // One entry per agent, whatever case an existing key was written in. + cfg.agent_launch + .retain(|slug, _| CLIAgent::from_slug(slug) != Some(agent)); + cfg.agent_launch.insert(agent.slug().to_string(), line); +} + +/// Type `command` into the shell `slot` holds — now if it is up, or the +/// moment it lands if it is still connecting. +fn run_when_ready(slot: &PaneSlot, command: String, cx: &mut App) { + match slot { + PaneSlot::Ready(view) => view.read(cx).run_command_line(&command), + PaneSlot::Connecting(pending) => { + pending.update(cx, |pending, _| pending.spawn.run_on_land = Some(command)); + } + } +} + +impl Tty7App { + /// The agents this window's quick launch offers, most likely first. + pub(crate) fn offered_agents(&self, cx: &App) -> Vec { + let cfg = cx.global::(); + let candidates = match WorkspaceStore::all(cx) + .get(self.workspace) + .filter(|view| view.is_remote()) + { + Some(view) => view + .seen_agents + .iter() + .filter_map(|slug| CLIAgent::from_slug(slug)) + .collect(), + None => installed_on( + &std::env::var_os("PATH").unwrap_or_default(), + &cfg.agent_launch, + ), + }; + by_frecency(candidates, &cfg.agent_frecency, unix_now()) + } + + /// Open a new shell for `agent` and start it there. + pub(crate) fn launch_agent( + &mut self, + agent: CLIAgent, + at: SpawnWhere, + window: &mut Window, + cx: &mut Context, + ) { + let command = agent.launch_command(&cx.global::().agent_launch); + let slot = match at { + SpawnWhere::NewTab => { + let cwd = self.tabs.get(self.active).and_then(|t| { + t.pane + .focused_or_first(window, cx) + .and_then(|leaf| leaf.read(cx).spawnable_cwd()) + }); + self.new_tab_slot(cwd, None, window, cx) + } + SpawnWhere::Split => { + self.split_slot(Axis::Horizontal, Some(SpawnAs::Shell(None)), window, cx) + } + }; + // Nothing opened (the spawn failed, or this workspace cannot host a + // shell right now), and the reason is already on screen. The command + // goes nowhere rather than into whatever pane was focused before. + let Some(slot) = slot else { + log::warn!("no pane opened for {}; not launching it", agent.slug()); + return; + }; + run_when_ready(&slot, command, cx); + // Recency only: the run is counted when the pane reports the agent + // actually running, so a launch and its detection are one use, not two. + self.update_config(cx, |cfg| { + cfg.agent_frecency + .entry(agent.slug().to_string()) + .or_default() + .last_used = unix_now(); + }); + } + + /// "New Agent Tab": launch the agent used most recently. + pub(crate) fn new_agent_tab(&mut self, window: &mut Window, cx: &mut Context) { + let offered = self.offered_agents(cx); + match most_recent(&offered, &cx.global::().agent_frecency) { + Some(agent) => self.launch_agent(agent, SpawnWhere::NewTab, window, cx), + None => { + let remote = WorkspaceStore::remote_ref(cx, self.workspace).is_some(); + window.push_notification( + crate::ui::i18n::t(if remote { + L10nKey::AppNoAgentSeenHere + } else { + L10nKey::AppNoAgentOnPath + }), + cx, + ); + } + } + } + + /// A pane in this window started running `agent`. + pub(crate) fn note_agent_detected(&mut self, agent: CLIAgent, cx: &mut Context) { + self.update_config(cx, |cfg| { + let entry = cfg + .agent_frecency + .entry(agent.slug().to_string()) + .or_default(); + entry.count = entry.count.saturating_add(1); + entry.last_used = unix_now(); + }); + if WorkspaceStore::remote_ref(cx, self.workspace).is_some() { + WorkspaceStore::record_agent_seen(cx, self.workspace, agent); + } + } + + /// "Set Current Launch Args as Default": make the focused pane's agent + /// launch the way this one was started. + pub(crate) fn save_agent_launch_args(&mut self, window: &mut Window, cx: &mut Context) { + let Some(view) = self + .tabs + .get(self.active) + .and_then(|t| t.pane.focused_or_first(window, cx)) + else { + return; + }; + let view = view.read(cx); + let Some(agent) = view.agent() else { + window.push_notification(crate::ui::i18n::t(L10nKey::AppPaneNoCodingAgent), cx); + return; + }; + let name = agent.display_name(); + let Some(line) = view + .agent_session() + .and_then(|s| s.launch_argv) + .and_then(|argv| launch_line_to_save(agent, &argv)) + else { + window.push_notification( + t_fmt(L10nKey::AppAgentLaunchArgsUnknown, &[("name", name)]), + cx, + ); + return; + }; + let saved = line.clone(); + self.update_config(cx, |cfg| remember_launch_line(cfg, agent, saved)); + window.push_notification( + t_fmt( + L10nKey::AppAgentLaunchSaved, + &[("name", name), ("command", &line)], + ), + cx, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn usage(pairs: &[(&str, u32, u64)]) -> HashMap { + pairs + .iter() + .map(|(slug, count, last_used)| { + ( + slug.to_string(), + ProfileUsage { + count: *count, + last_used: *last_used, + }, + ) + }) + .collect() + } + + fn argv(parts: &[&str]) -> Vec { + parts.iter().map(|s| s.to_string()).collect() + } + + const NOW: u64 = 1_800_000_000; + const DAY: u64 = 86_400; + + #[test] + fn agents_are_offered_most_used_first() { + let used = usage(&[ + ("codex", 3, NOW - DAY), + ("claude", 12, NOW - 2 * DAY), + // Used a lot, but so long ago that it has decayed below Codex. + ("gemini", 20, NOW - 200 * DAY), + ]); + let offered = by_frecency( + [ + CLIAgent::Aider, + CLIAgent::Gemini, + CLIAgent::Codex, + CLIAgent::Claude, + CLIAgent::Amp, + ], + &used, + NOW, + ); + assert_eq!( + offered, + vec![ + CLIAgent::Claude, + CLIAgent::Codex, + CLIAgent::Gemini, + // Never used: they keep the order they were found in. + CLIAgent::Aider, + CLIAgent::Amp, + ] + ); + } + + #[test] + fn new_agent_tab_opens_the_most_recently_used_agent() { + let offered = [CLIAgent::Claude, CLIAgent::Codex, CLIAgent::Gemini]; + // Claude is used more, but Codex was the last one run or launched. + let used = usage(&[ + ("claude", 40, NOW - DAY), + ("codex", 1, NOW - 60), + ("aider", 5, NOW), + ]); + assert_eq!(most_recent(&offered, &used), Some(CLIAgent::Codex)); + // Aider was more recent still, but it is not offered here. + // Nothing used yet: the first one offered. + assert_eq!( + most_recent(&offered, &HashMap::new()), + Some(CLIAgent::Claude) + ); + assert_eq!(most_recent(&[], &used), None); + // A launch that only stamped recency counts as the most recent. + let launched = usage(&[("claude", 40, NOW - DAY), ("gemini", 0, NOW)]); + assert_eq!(most_recent(&offered, &launched), Some(CLIAgent::Gemini)); + } + + #[test] + fn saved_launch_args_survive_a_round_trip_through_the_command_line() { + let run = argv(&[ + "/opt/homebrew/bin/claude", + "--append-system-prompt", + "be terse, don't guess", + "--add-dir", + "/Users/me/My Projects", + "--model", + "opus", + "--resume", + "abc-123", + ]); + let line = launch_line_to_save(CLIAgent::Claude, &run).unwrap(); + assert_eq!( + crate::ui::app::split_shell_args(&line).unwrap(), + argv(&[ + "claude", + "--append-system-prompt", + "be terse, don't guess", + "--add-dir", + "/Users/me/My Projects", + "--model", + "opus", + ]) + ); + // And the saved line is recognised as the agent it launches. + assert_eq!( + CLIAgent::detect_from_command_with(&line, &HashMap::new()), + Some(CLIAgent::Claude) + ); + } + + #[test] + fn set_current_launch_args_as_default_writes_the_agent_launch_entry() { + let mut cfg = Config::default(); + cfg.agent_launch + .insert("Claude".to_string(), "claude --old".to_string()); + cfg.agent_launch + .insert("codex".to_string(), "codex --model o3".to_string()); + let line = launch_line_to_save( + CLIAgent::Claude, + &argv(&["claude", "--dangerously-skip-permissions", "--continue"]), + ) + .unwrap(); + remember_launch_line(&mut cfg, CLIAgent::Claude, line); + + let json: serde_json::Value = serde_json::to_value(&cfg.0).unwrap(); + assert_eq!( + json["agent_launch"], + serde_json::json!({ + "claude": "claude --dangerously-skip-permissions", + "codex": "codex --model o3", + }) + ); + assert_eq!( + CLIAgent::Claude.launch_command(&cfg.agent_launch), + "claude --dangerously-skip-permissions" + ); + } + + #[test] + fn every_agent_has_a_bindable_launch_action() { + for agent in CLIAgent::ALL { + let name = launch_action_name(agent); + assert_eq!(agent_for_launch_action(name), Some(agent), "{name}"); + } + assert_eq!(agent_for_launch_action("LaunchAgent:nobody"), None); + assert_eq!(agent_for_launch_action("NewAgentTab"), None); + } + + #[test] + fn only_agents_whose_launch_program_is_on_path_are_offered() { + let dir = tempfile::TempDir::new().unwrap(); + for name in ["codex", "cc"] { + let path = dir.path().join(name); + std::fs::write(&path, "#!/bin/sh\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + } + let path = dir.path().as_os_str(); + assert_eq!(installed_on(path, &HashMap::new()), vec![CLIAgent::Codex]); + // With an override, the override's program is what has to be there. + let overrides: HashMap = [ + ("claude".to_string(), "cc --fast".to_string()), + ("codex".to_string(), "codex-nightly".to_string()), + ] + .into_iter() + .collect(); + assert_eq!(installed_on(path, &overrides), vec![CLIAgent::Claude]); + } +} diff --git a/src/ui/app.rs b/src/ui/app.rs index 92cd4c33..5d6907a1 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -451,6 +451,25 @@ pub struct Tab { /// the tab no longer holds are dropped as the next one is written, so a /// closed pane leaves nothing behind either. focus_origin: std::collections::HashMap<(gpui::EntityId, Dir), gpui::EntityId>, + /// Set while the tab is asleep (#762): its panes were stopped and `pane` + /// is `Pane::Empty`. What is kept instead is what it takes to bring them + /// back — see [`Asleep`]. + pub(crate) asleep: Option, +} + +/// A sleeping tab: nothing of it runs, and everything a wake needs is here. +/// +/// `layout` is the tab as the restore path reads a tab — pane ids, cwds, +/// shells, agent sessions — so waking is that restore, run on one tab: each +/// pane's id asks the daemon for the screen it stored when the pane was +/// stopped, and an agent with a known session is resumed the way a reboot +/// resumes it. `view` is what the tab is called in the meantime, read off +/// the panes as they went to sleep (or off the tree's records, after a +/// restart), since there is no terminal left to ask. +pub(crate) struct Asleep { + pub(crate) layout: SessionPane, + pub(crate) view: tty7_core::core::tab_view::TabView, + pub(crate) home: Option, } #[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] @@ -477,6 +496,7 @@ impl Tab { tree_id: std::cell::Cell::new(tty7_core::core::machine::TabId::new()), last_used: std::cell::Cell::new(0), focus_origin: Default::default(), + asleep: None, } } @@ -500,9 +520,25 @@ impl Tab { tree_id: std::cell::Cell::new(tree.id), last_used: std::cell::Cell::new(0), focus_origin: Default::default(), + asleep: None, } } + pub(crate) fn is_asleep(&self) -> bool { + self.asleep.is_some() + } + + /// The layout a sleeping tab will wake into; `None` for a tab that is awake. + pub(crate) fn asleep_layout(&self) -> Option<&SessionPane> { + self.asleep.as_ref().map(|a| &a.layout) + } + + /// The agent a sleeping tab was running when it went to sleep, so its row + /// still says what it is while nothing in it can. + pub(crate) fn asleep_agent(&self) -> Option { + self.asleep.as_ref().and_then(|a| a.view.agent) + } + fn focus_target(&self) -> Option { match self.last_focused { Some(id) => self.pane.leaf_matching_or_first(|l| l.entity_id() == id), @@ -601,6 +637,14 @@ impl Tab { Option, ) { let name = self.name.clone(); + if let Some(asleep) = &self.asleep { + let view = tty7_core::core::tab_view::TabView { + id: self.tree_id.get(), + name, + ..asleep.view.clone() + }; + return (view, asleep.home.clone()); + } let Some(leaf) = self.title_leaf(window, cx) else { return ( tty7_core::core::tab_view::TabView { @@ -628,7 +672,10 @@ impl Tab { // name tty7 chose for it deliberately, and `stated_title` hands // those up as the title the pane is showing. title: crate::terminal::view::DEFAULT_TITLE.to_string(), - osc_title: leaf.stated_title().map(str::to_string), + // On the title's rung, not a new one above it: a name given to the + // tab still beats an SSH host pinned by Settings (#726), and the + // pinned host beats everything the pane could say about itself. + osc_title: leaf.tab_title(cx), cwd: leaf.cwd().map(|p| p.display().to_string()), agent: leaf.agent(), status: leaf.agent_session().map(|s| s.status), @@ -1874,6 +1921,7 @@ impl Tty7App { self.tabs = tabs; self.active = active; self.maximized = None; + self.wake_active_if_asleep(window, cx); self.save_session(cx); crate::ui::windows::refresh_menu(cx); self.focus_active(window, cx); @@ -1921,6 +1969,7 @@ impl Tty7App { tree_id: std::cell::Cell::new(tty7_core::core::machine::TabId::new()), last_used: std::cell::Cell::new(0), focus_origin: Default::default(), + asleep: None, }, ); self.active = insert_at; @@ -3076,7 +3125,7 @@ impl Tty7App { cx: &mut Context, ) { let fields = self.managed_forward_fields(cx); - let Some(rule) = fields.collect() else { + let Some(mut rule) = fields.collect() else { // Add is disabled while the fields do not make a rule and the form // already says what is missing, so there is nothing to do here and // nothing left to explain. @@ -3084,6 +3133,10 @@ impl Tty7App { }; let route = self.forward_route(pane_id, cx); let previous = self.loopback_panel.mf_editing.clone(); + // Editing a switched-off rule edits it; it does not switch it on. + if let Some(old) = &previous { + rule.enabled = old.enabled; + } // A saved edit is a replace, and the rule being replaced has to come // out first: the ordinary edit keeps the bind port, and the far side // really does bind it, so adding first would collide with the very @@ -3187,10 +3240,14 @@ impl Tty7App { let Some(list) = route.add(rule) else { return PlaceOutcome::Unreachable(t(L10nKey::ForwardRequestFailed).to_string()); }; - let broken = added_forward(&before, &list).and_then(|added| match &added.status { - ForwardStatus::Error(msg) => Some((added.id, msg.clone())), - ForwardStatus::Listening => None, - }); + // A switched-off rule reports an error status because it binds + // nothing, and that is what it was asked to do. + let broken = added_forward(&before, &list) + .filter(|added| added.enabled) + .and_then(|added| match &added.status { + ForwardStatus::Error(msg) => Some((added.id, msg.clone())), + ForwardStatus::Listening => None, + }); self.loopback_panel.managed = list; let Some((id, msg)) = broken else { return PlaceOutcome::Placed; @@ -3278,6 +3335,84 @@ impl Tty7App { cx.notify(); } + /// Switch one forward on or off, and carry the choice into the saved host + /// the rule came from, if it came from one — so the next connection opens + /// the same set of rules instead of the ones that were saved before. + pub(crate) fn set_managed_forward_enabled( + &mut self, + pane_id: u64, + forward_id: u64, + enabled: bool, + window: &mut Window, + cx: &mut Context, + ) { + let route = self.forward_route(pane_id, cx); + let forward = self + .loopback_panel + .managed + .iter() + .find(|m| m.id == forward_id) + .cloned(); + match route.set_enabled(forward_id, enabled) { + Ok(list) => { + self.loopback_panel.managed = list; + if let Some(forward) = forward { + self.remember_forward_enabled(pane_id, &forward, enabled, cx); + } + } + Err(e) => { + window.push_notification( + t_fmt(L10nKey::ForwardSwitchFailed, &[("error", &e.to_string())]), + cx, + ); + // The refusal changed nothing, but the panel may be behind + // whatever did change — ask rather than guess. + self.loopback_panel.managed = route.list(); + } + } + cx.notify(); + } + + fn remember_forward_enabled( + &mut self, + pane_id: u64, + forward: &crate::daemon::protocol::ManagedForward, + enabled: bool, + cx: &mut Context, + ) { + let profile_id = self + .tabs + .iter() + .flat_map(|tab| tab.pane.terminals()) + .find_map(|leaf| { + let view = leaf.read(cx); + (view.pane_id == pane_id).then(|| view.ssh_spec()?.profile_id.clone())? + }) + .and_then(|id| uuid::Uuid::parse_str(&id).ok()); + let Some(profile_id) = profile_id else { + return; + }; + let saved = cx + .global::() + .ssh_profiles + .iter() + .find(|p| p.id == profile_id) + .and_then(|p| crate::ui::forwards::saved_rule_index(&p.forwards, forward, enabled)); + let Some(idx) = saved else { + return; + }; + self.update_config(cx, |cfg| { + if let Some(rule) = cfg + .ssh_profiles + .iter_mut() + .find(|p| p.id == profile_id) + .and_then(|p| p.forwards.get_mut(idx)) + { + rule.enabled = enabled; + } + }); + } + pub(crate) fn show_ssh_forwards(&mut self, window: &mut Window, cx: &mut Context) { // Whatever the panel would let this pane forward, which is a wider set // than "a connected native-ssh pane": a pane in a remote workspace @@ -3484,6 +3619,14 @@ impl Tty7App { self.update_config(cx, |cfg| cfg.sidebar_diff_preview = on); } + pub(crate) fn set_ssh_tab_title( + &mut self, + mode: crate::core::config::SshTabTitle, + cx: &mut Context, + ) { + self.update_config(cx, |cfg| cfg.ssh_tab_title = mode); + } + pub(crate) fn toggle_tab_sidebar(&mut self, cx: &mut Context) { let next = match cx.global::().tab_bar_position { TabBarPosition::Top => TabBarPosition::Left, @@ -3797,6 +3940,7 @@ impl Tty7App { spawn.agent_launch_argv.as_deref(), cx, ) + .or_else(|| spawn.run_on_land.clone()) }) .flatten(); let view = build_terminal_view(parts, font_size, window, cx); @@ -3911,8 +4055,21 @@ impl Tty7App { window: &mut Window, cx: &mut Context, ) { + self.new_tab_slot(cwd, shell, window, cx); + } + + /// [`Self::new_tab_with_cwd`], answering with the pane it opened — `None` + /// when nothing opened, so a caller about to type into the new pane has + /// nothing to type into rather than whatever was focused before. + pub(crate) fn new_tab_slot( + &mut self, + cwd: Option, + shell: Option, + window: &mut Window, + cx: &mut Context, + ) -> Option { if !self.guard_local_spawn(window, cx) { - return; + return None; } let group = self.spawn_group(cwd.as_deref(), cx); let tab = match new_terminal( @@ -3934,7 +4091,7 @@ impl Tty7App { self.startup_error = Some(gpui::SharedString::from(text.clone())); window.push_notification(text, cx); cx.notify(); - return; + return None; } }; // Something opened, so whatever the last failure was is stale. @@ -3942,13 +4099,14 @@ impl Tty7App { self.remember_active_pane(window, cx); self.maximized = None; let insert_at = self.new_tab_insert_at(cx); - let new_tab = Tab::new(Pane::leaf(tab)); + let new_tab = Tab::new(Pane::leaf(tab.clone())); group.seat(&new_tab); self.tabs.insert(insert_at, new_tab); self.active = insert_at; self.focus_active(window, cx); self.save_session(cx); cx.notify(); + Some(tab) } pub(crate) fn open_native_ssh_tab( @@ -4032,15 +4190,24 @@ impl Tty7App { window: &mut Window, cx: &mut Context, ) { - let Some(target) = self + self.split_slot(axis, spawn, window, cx); + } + + /// [`Self::split_into`], answering with the pane it placed, or `None` + /// when nothing was placed. + pub(crate) fn split_slot( + &mut self, + axis: Axis, + spawn: Option, + window: &mut Window, + cx: &mut Context, + ) -> Option { + let target = self .tabs .get(self.active) - .and_then(|t| t.pane.focused_or_first(window, cx)) - else { - return; - }; + .and_then(|t| t.pane.focused_or_first(window, cx))?; if !self.guard_local_spawn(window, cx) { - return; + return None; } let cwd = target.read(cx).spawnable_cwd(); let spawn = match spawn { @@ -4068,7 +4235,7 @@ impl Tty7App { ), cx, ); - return; + return None; } } } @@ -4090,22 +4257,23 @@ impl Tty7App { t_fmt(L10nKey::AppSplitPaneFailed, &[("error", &e.to_string())]), cx, ); - return; + return None; } } } }; - if let Some(tab) = self.tabs.get_mut(self.active) { - 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); - cx.notify(); - } + let tab = self.tabs.get_mut(self.active)?; + if !tab + .pane + .split_leaf(target.entity_id(), axis, false, new.clone()) + { + return None; } + self.maximized = None; + self.focus_leaf(&new, window, cx); + self.save_session(cx); + cx.notify(); + Some(new) } fn close_pane(&mut self, window: &mut Window, cx: &mut Context) { @@ -4721,6 +4889,13 @@ impl Tty7App { } pub(crate) fn activate(&mut self, index: usize, window: &mut Window, cx: &mut Context) { + // A tab is woken by being looked at, the way a browser reloads a + // discarded tab when it is selected: a tab on screen is always a + // running one, so there is no third state to draw. A wake that fails + // leaves the tab asleep and the window where it was. + if self.tabs.get(index).is_some_and(Tab::is_asleep) && !self.wake_tab(index, window, cx) { + return; + } if index < self.tabs.len() && index != self.active { self.remember_active_pane(window, cx); // Zoom rides with its tab (#599): stash the outgoing tab's zoom @@ -4753,6 +4928,146 @@ impl Tty7App { } } + /// Whether tab `index` can be put to sleep right now (#762). + /// + /// Not while it is connecting: a pane still on its way has no id the + /// machine knows, so it would be lost rather than stopped. Not with a + /// native SSH pane in a remote window either — that pane runs on this + /// computer, beyond the reach of the machine asked to stop the tab. Not on + /// a machine whose server cannot do it. And not the one tab left awake: + /// the window has to have something on screen, and looking at a tab is + /// what wakes it. + pub(crate) fn can_hibernate_tab(&self, index: usize, cx: &App) -> bool { + let Some(tab) = self.tabs.get(index) else { + return false; + }; + if tab.is_asleep() || !crate::ui::tree_sync::can_hibernate_on(cx, self.workspace) { + return false; + } + let remote = WorkspaceStore::all(cx) + .get(self.workspace) + .is_some_and(|w| w.is_remote()); + let leaves = tab.pane.leaves(); + let all_hibernatable = !leaves.is_empty() + && leaves.iter().all(|leaf| match leaf { + PaneSlot::Ready(view) => !(remote && view.read(cx).ssh_spec().is_some()), + PaneSlot::Connecting(_) => false, + }); + all_hibernatable && self.awake_tab_besides(index).is_some() + } + + /// The tab to show instead of `index` when that one goes to sleep: the + /// awake tab used most recently, the nearest one on a tie. + fn awake_tab_besides(&self, index: usize) -> Option { + (0..self.tabs.len()) + .filter(|&i| i != index && !self.tabs[i].is_asleep()) + .max_by_key(|&i| { + ( + self.tabs[i].last_used.get(), + std::cmp::Reverse(i.abs_diff(index)), + ) + }) + } + + /// Put tab `index` to sleep: stop its panes, keep its place (#762). + /// + /// The window lets go of the panes first and the machine stops them after, + /// from the sync this ends with. That order is what keeps a stopped pane + /// from reading as one that died: by the time its shell exits there is no + /// view left here to take the exit as a reason to close the pane, or the + /// tab with it. The machine, for its part, marks the tab before it stops + /// anything, so a restore reading the tree in between never takes the + /// panes for dead ones and spawns them straight back. + pub(crate) fn hibernate_tab( + &mut self, + index: usize, + window: &mut Window, + cx: &mut Context, + ) { + if !self.can_hibernate_tab(index, cx) { + return; + } + if index == self.active { + let Some(next) = self.awake_tab_besides(index) else { + return; + }; + self.activate(next, window, cx); + } + self.put_to_sleep(index, window, cx); + } + + /// The part of [`Self::hibernate_tab`] that does not ask whether it may: + /// the tab lets go of its panes and keeps what a wake needs instead. + fn put_to_sleep(&mut self, index: usize, window: &mut Window, cx: &mut Context) { + let Some(tab) = self.tabs.get(index).filter(|t| !t.is_asleep()) else { + return; + }; + let (mut view, home) = tab.label_view(Some(window), cx); + view.live = false; + view.status = None; + let layout = pane_to_session(&tab.pane, cx); + let tab = &mut self.tabs[index]; + let panes = std::mem::replace(&mut tab.pane, Pane::Empty); + tab.zoomed = None; + tab.last_focused = None; + tab.focus_origin.clear(); + tab.asleep = Some(Asleep { layout, view, home }); + drop(panes); + self.save_session(cx); + cx.notify(); + } + + /// Bring a sleeping tab back, through the restore every pane takes after a + /// reboot: each pane is spawned in its old one's place, opens on the screen + /// that one left behind, and resumes its agent session when it had one. + /// Returns whether the tab is awake now; a tab none of whose panes could + /// be started stays asleep, and says so. + pub(crate) fn wake_tab( + &mut self, + index: usize, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let Some(asleep) = self.tabs.get_mut(index).and_then(|t| t.asleep.take()) else { + return true; + }; + let pane_ws = self.window_workspace(cx); + let layout = redial_native_ssh(&asleep.layout); + // No listing: every pane in here was stopped on purpose, so the attach + // each one tries first fails and falls through to the fresh spawn — + // unless the stop never landed, in which case attaching to the pane + // that is still running is exactly right. + let pane = session_to_pane( + pane_ws.as_ref(), + self.workspace, + &layout, + None, + self.font_size, + window, + cx, + ); + let tab = &mut self.tabs[index]; + let Some(pane) = pane else { + tab.asleep = Some(asleep); + window.push_notification(t(L10nKey::TabWakeFailed), cx); + return false; + }; + tab.pane = pane; + tab.last_focused = None; + self.save_session(cx); + cx.notify(); + true + } + + /// Wake the tab on screen if it is asleep — for the paths that land on a + /// tab without going through [`Self::activate`]: a neighbour taking over + /// from a closed tab, a window restored onto a sleeping one. + pub(crate) fn wake_active_if_asleep(&mut self, window: &mut Window, cx: &mut Context) { + if self.tabs.get(self.active).is_some_and(Tab::is_asleep) { + self.wake_tab(self.active, window, cx); + } + } + /// Whether tab `index` has a pane zoomed over hidden siblings — what the /// chrome marks so the state is readable without toggling it (#752). /// @@ -4857,6 +5172,7 @@ impl Tty7App { } else if index < self.active { self.active -= 1; } + self.wake_active_if_asleep(window, cx); self.focus_active(window, cx); self.save_session(cx); cx.notify(); @@ -5437,6 +5753,31 @@ impl Tty7App { }, ); + // Every shell the window's machine has, named alike in every language + // so the New Tab menu's "Other Shells…" row can land on exactly these + // by typing one word. Listed in the order that menu uses. + { + let default_shell = self.default_shell_label(cx); + let usage = &cx.global::().shell_frecency; + let now = crate::core::config::unix_now(); + for s in crate::ui::tab_strip::shells_by_frecency( + &self.shells.shells, + &default_shell, + usage, + now, + ) { + let mut cmd = Command::new( + t_fmt(L10nKey::AppCmdShellTitle, &[("title", &s.label)]), + CommandKind::OpenShell(s.label.clone()), + ) + .in_group(CommandGroup::TabsPanes); + if s.label == default_shell { + cmd = cmd.with_subtitle(t(L10nKey::ShellDefault)); + } + commands.push(cmd); + } + } + // Offered only where it would do something. A connection opened from a // saved host has nothing to save, and a pane that is not an SSH one has // no connection at all — either would be a row that quietly did nothing @@ -5495,6 +5836,20 @@ impl Tty7App { ); } + for agent in self.offered_agents(cx) { + commands.push( + Command::new( + t_fmt( + L10nKey::AppCmdAgentLaunchTitle, + &[("name", agent.display_name())], + ), + CommandKind::LaunchAgent(agent), + ) + .with_subtitle(agent.launch_command(&cx.global::().agent_launch)) + .in_group(CommandGroup::Agents), + ); + } + for (i, tab) in self.tabs.iter().enumerate() { if i == self.active { continue; @@ -5678,7 +6033,15 @@ 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), + HibernateTab => self.hibernate_tab(self.active, window, cx), ForkAgentSession => self.fork_active_pane_session(ForkPlacement::NewTab, window, cx), + NewAgentTab => self.new_agent_tab(window, cx), + // Picked from the palette with ⌥ held, the way a New Tab menu row + // is: a split beside the focused pane instead of a tab. + LaunchAgent(agent) => { + let at = SpawnWhere::from_modifiers(window.modifiers()); + self.launch_agent(agent, at, 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), @@ -5720,6 +6083,11 @@ impl Tty7App { } OpenSshConnect(input) => self.open_typed_ssh_connect(&input, window, cx), ConnectSavedProfile(id) => self.connect_ssh_profile(id, window, cx), + // ⌥ held as the row is taken splits, as it does on the menu's row. + OpenShell(label) => { + let at = SpawnWhere::from_modifiers(window.modifiers()); + self.open_listed_shell(&label, at, window, cx) + } EditSavedProfile(id) => self.open_ssh_profile_in_settings(id, window, cx), QuickConnect(target) => { if let Some(qc) = crate::core::ssh_profile::parse_quick_connect(&target) { @@ -5864,6 +6232,7 @@ impl Tty7App { L10nKey::SettingsDiffPreviewFromCounts => { self.set_sidebar_diff_preview(defaults.sidebar_diff_preview, cx) } + L10nKey::SettingsSshTabTitle => self.set_ssh_tab_title(defaults.ssh_tab_title, cx), L10nKey::SettingsNotifyOnCommandFinish => { self.set_notify_mode(defaults.notify_on_command_finish, cx) } @@ -8266,6 +8635,31 @@ impl ForwardRoute { } } + pub(crate) fn set_enabled( + &self, + forward_id: u64, + enabled: bool, + ) -> anyhow::Result> { + let Some(req) = + self.workspace_op(crate::daemon::protocol::WorkspaceOp::SetForwardEnabled { + forward_id, + enabled, + }) + else { + return crate::terminal::RemoteTerminal::set_forward_enabled( + self.pane_id, + forward_id, + enabled, + ); + }; + match crate::terminal::RemoteTerminal::on_workspace(req)? { + crate::daemon::protocol::DaemonMsg::ForwardList(list) => Ok(list), + other => Err(anyhow::anyhow!( + "unexpected reply to SetForwardEnabled: {other:?}" + )), + } + } + pub(crate) fn remove( &self, forward_id: u64, @@ -8976,6 +9370,9 @@ impl Render for Tty7App { .on_action(cx.listener(|this, _: &MarkTabUnread, _window, cx| { this.mark_tab_unread(this.active, cx) })) + .on_action(cx.listener(|this, _: &HibernateTab, window, cx| { + this.hibernate_tab(this.active, window, cx) + })) .on_action(cx.listener(|this, _: &ForkAgentSession, window, cx| { this.fork_active_pane_session(ForkPlacement::NewTab, window, cx) })) @@ -8994,6 +9391,15 @@ impl Render for Tty7App { .on_action(cx.listener(|this, _: &CopyAgentSessionId, window, cx| { this.copy_agent_session_id(this.active, window, cx) })) + .on_action( + cx.listener(|this, _: &NewAgentTab, window, cx| this.new_agent_tab(window, cx)), + ) + .on_action(cx.listener(|this, action: &LaunchAgent, window, cx| { + this.launch_agent(action.agent, SpawnWhere::NewTab, window, cx) + })) + .on_action(cx.listener(|this, _: &SaveAgentLaunchArgs, window, cx| { + this.save_agent_launch_args(window, cx) + })) .on_action(cx.listener(|this, _: &ShowKeyboardShortcuts, window, cx| { this.open_settings_section(SettingsSection::Keybindings, window, cx) })) @@ -9084,10 +9490,15 @@ fn mru_order(stamps: &[u64], active: usize) -> Vec { fn tab_to_session(tab: &Tab, cx: &App) -> SessionTab { SessionTab { name: tab.name.clone(), - pane: pane_to_session(&tab.pane, cx), + pane: match tab.asleep_layout() { + Some(layout) => layout.clone(), + None => pane_to_session(&tab.pane, cx), + }, group: tab.group.get(), last_auto: tab.auto_group.borrow().clone(), tree_id: None, + hibernated: false, + asleep_view: None, } } @@ -9262,7 +9673,19 @@ fn tabs_from_session( let alive = alive_panes_on(&crate::terminal::PaneRoute::for_workspace(workspace)); let mut tabs: Vec = Vec::with_capacity(session.tabs.len()); let mut dropped = 0usize; - for st in &session.tabs { + let home = crate::ui::path_display::home_for_host( + cx, + workspace.map_or(HostId::LOCAL, |w| w.target.host_id()), + ); + for (index, st) in session.tabs.iter().enumerate() { + // Asleep stays asleep: nothing is spawned or attached for it, which is + // the point. The one exception is the tab the window opens onto — a + // tab on screen is awake, so that one is woken here, by the same + // restore every other tab is getting. + if st.hibernated && index != session.active { + tabs.push(asleep_tab(st, home.clone())); + continue; + } let Some(pane) = session_to_pane( workspace, owner, @@ -9298,12 +9721,97 @@ fn tabs_from_session( ), last_used: std::cell::Cell::new(0), focus_origin: Default::default(), + asleep: None, }); } let active = session.active.min(tabs.len().saturating_sub(1)); (tabs, active, dropped) } +/// A tab restored asleep: its place, its name and its group, and nothing +/// running behind them. +fn asleep_tab(st: &SessionTab, home: Option) -> Tab { + let mut tab = Tab::new(Pane::Empty); + tab.name = st.name.clone(); + tab.group.set(st.group); + *tab.auto_group.borrow_mut() = st.last_auto.clone(); + if let Some(id) = st.tree_id { + tab.tree_id.set(id); + } + tab.asleep = Some(Asleep { + layout: st.pane.clone(), + view: st + .asleep_view + .clone() + .unwrap_or_else(|| view_of_layout(tab.tree_id.get(), &st.pane)), + home, + }); + tab +} + +/// What to call a sleeping tab when the tree had nothing better to say: the +/// first directory in its layout, and the agent in it if there was one. +fn view_of_layout( + id: tty7_core::core::machine::TabId, + layout: &SessionPane, +) -> tty7_core::core::tab_view::TabView { + fn leaves<'a>(pane: &'a SessionPane, out: &mut Vec<&'a SessionPane>) { + match pane { + SessionPane::Leaf { .. } => out.push(pane), + SessionPane::Split { a, b, .. } => { + leaves(a, out); + leaves(b, out); + } + } + } + let mut all = Vec::new(); + leaves(layout, &mut all); + let cwd = all.iter().find_map(|leaf| match leaf { + SessionPane::Leaf { cwd, .. } => cwd.as_ref().map(|p| p.display().to_string()), + SessionPane::Split { .. } => None, + }); + let agent = all.iter().find_map(|leaf| match leaf { + SessionPane::Leaf { agent, .. } => *agent, + SessionPane::Split { .. } => None, + }); + tty7_core::core::tab_view::TabView { + id, + name: None, + title: String::new(), + osc_title: None, + cwd, + agent, + status: None, + live: false, + panes: all.len(), + } +} + +/// A sleeping layout as a wake should read it: a native SSH pane dials its +/// host again rather than standing on its old id. That id belongs to a pane +/// whose connection is gone, and the restore reading it would put a local +/// shell in its place. +fn redial_native_ssh(layout: &SessionPane) -> SessionPane { + match layout { + SessionPane::Leaf { + ssh_spec: Some(_), .. + } => { + let mut leaf = layout.clone(); + if let SessionPane::Leaf { pane_id, .. } = &mut leaf { + *pane_id = None; + } + leaf + } + SessionPane::Leaf { .. } => layout.clone(), + SessionPane::Split { axis, ratio, a, b } => SessionPane::Split { + axis: *axis, + ratio: *ratio, + a: Box::new(redial_native_ssh(a)), + b: Box::new(redial_native_ssh(b)), + }, + } +} + fn leaf_shares_the_window_daemon(window_is_remote: bool, leaf_is_native_ssh: bool) -> bool { !(window_is_remote && leaf_is_native_ssh) } @@ -9434,6 +9942,7 @@ pub(crate) fn new_terminal( agent: None, agent_session_id: None, agent_launch_argv: None, + run_on_land: None, owner, font_size, }; @@ -9513,6 +10022,14 @@ fn build_terminal_view( }, ) .detach(); + cx.subscribe_in( + &view, + window, + |app, _view, ev: &crate::terminal::view::AgentDetected, _window, cx| { + app.note_agent_detected(ev.0, cx); + }, + ) + .detach(); cx.subscribe_in( &view, window, @@ -10643,6 +11160,7 @@ mod tests { agent: Some(CLIAgent::Claude), agent_session_id: Some("sid-abc".to_string()), agent_launch_argv: Some(vec!["claude".to_string()]), + run_on_land: None, owner: None, font_size: 14.0, }, @@ -11096,6 +11614,7 @@ mod ssh_rebuild_gpui_tests { group: None, last_auto: None, root: PaneNode::Leaf { pane: 1 }, + hibernated: false, }; app.apply_layout_delta( &LayoutDelta::TabRestructured { tab, pane: None }, @@ -11900,6 +12419,7 @@ mod managed_forward_gpui_tests { target_port: 80, description: None, status: ForwardStatus::Listening, + enabled: true, } } @@ -12259,6 +12779,7 @@ mod tab_focus_memory_tests { agent: None, agent_session_id: None, agent_launch_argv: None, + run_on_land: None, owner: None, font_size: 14., }, @@ -12607,3 +13128,112 @@ mod tab_focus_memory_tests { }); } } + +#[cfg(test)] +mod hibernate_gpui_tests { + use gpui::TestAppContext; + + use crate::core::session::SessionPane; + use crate::ui::app::test_window::harness_with_tabs; + use crate::ui::pane::Pane; + + fn leaf_pane_id(pane: &SessionPane) -> Option { + match pane { + SessionPane::Leaf { pane_id, .. } => *pane_id, + SessionPane::Split { .. } => None, + } + } + + /// Asleep, a tab holds nothing running and still holds its place: the + /// pane id the wake restores from, the name it had on screen, and a seat in + /// the sync that tells the machine it is asleep rather than gone. + #[gpui::test] + fn a_sleeping_tab_keeps_its_place_its_pane_and_its_name(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 2); + + app.update_in(&mut vcx, |app, window, cx| { + let pane_id = app.tabs[1] + .pane + .first_leaf() + .and_then(|slot| slot.terminal().cloned()) + .expect("tab 1 has a pane") + .read(cx) + .pane_id; + let (before, _) = app.tabs[1].label_view(Some(window), cx); + + app.put_to_sleep(1, window, cx); + + assert_eq!(app.tabs.len(), 2, "sleeping is not closing"); + let tab = &app.tabs[1]; + assert!(tab.is_asleep()); + assert!(matches!(tab.pane, Pane::Empty)); + assert!(tab.pane.terminals().is_empty(), "nothing is left running"); + assert_eq!(tab.asleep_layout().and_then(leaf_pane_id), Some(pane_id)); + let (after, _) = tab.label_view(Some(window), cx); + assert_eq!( + format!("{:?}", after.label()), + format!("{:?}", before.label()), + "it is called what it was called while awake" + ); + assert!(!after.live); + + let (desired, _, held) = crate::ui::tree_sync::desired_tabs(app, cx); + assert!(held.is_empty(), "a sleeping tab is not a tab gone missing"); + assert_eq!(desired.len(), 2); + assert!(!desired[0].hibernated); + assert!(desired[1].hibernated); + match &desired[1].root { + crate::ui::tree_sync::DesiredNode::Leaf { pane, .. } => assert_eq!(*pane, pane_id), + other => panic!("expected the one leaf, got {other:?}"), + } + }); + } + + /// Looking at a tab is what wakes it, so the window has to keep one awake + /// to look at; the tab shown in a sleeping one's place is the awake tab + /// used last. + #[gpui::test] + fn the_last_awake_tab_has_nothing_to_hand_the_window_to(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 3); + + app.update_in(&mut vcx, |app, window, cx| { + app.tabs[1].last_used.set(5); + app.tabs[2].last_used.set(9); + assert_eq!(app.awake_tab_besides(0), Some(2), "the one used last"); + + app.put_to_sleep(2, window, cx); + assert_eq!(app.awake_tab_besides(0), Some(1)); + + app.put_to_sleep(1, window, cx); + assert_eq!(app.awake_tab_besides(0), None); + assert!( + !app.can_hibernate_tab(0, cx), + "the last awake tab stays awake" + ); + assert!( + !app.can_hibernate_tab(1, cx), + "nor is a sleeping tab put to sleep twice" + ); + }); + } + + /// Closing a sleeping tab closes it like any other, and what Reopen Closed + /// Tab gets back is the layout it slept with — not the empty stand-in. + #[gpui::test] + fn a_sleeping_tab_closes_into_what_it_would_have_woken_as(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 2); + + app.update_in(&mut vcx, |app, window, cx| { + app.put_to_sleep(1, window, cx); + let layout_id = app.tabs[1].asleep_layout().and_then(leaf_pane_id); + assert!(layout_id.is_some()); + + app.close_tab(1, window, cx); + + assert_eq!(app.tabs.len(), 1); + let closed = app.closed.last().expect("the closed tab is remembered"); + assert_eq!(leaf_pane_id(&closed.pane), layout_id); + assert!(!closed.hibernated, "reopening brings it back awake"); + }); + } +} diff --git a/src/ui/assets.rs b/src/ui/assets.rs index 787c49bc..73ffca40 100644 --- a/src/ui/assets.rs +++ b/src/ui/assets.rs @@ -48,6 +48,9 @@ fn agent_icon(path: &str) -> Option<&'static [u8]> { "icons/machine-local.svg" => include_bytes!("../../assets/icons/machine-local.svg"), "icons/machine-remote.svg" => include_bytes!("../../assets/icons/machine-remote.svg"), "icons/refresh.svg" => include_bytes!("../../assets/icons/refresh.svg"), + "icons/power.svg" => include_bytes!("../../assets/icons/power.svg"), + "icons/list-tree.svg" => include_bytes!("../../assets/icons/list-tree.svg"), + "icons/list-flat.svg" => include_bytes!("../../assets/icons/list-flat.svg"), "icons/agents/claude.svg" => include_bytes!("../../assets/icons/agents/claude.svg"), "icons/agents/codex.svg" => include_bytes!("../../assets/icons/agents/codex.svg"), "icons/agents/traecli.svg" => include_bytes!("../../assets/icons/agents/traecli.svg"), @@ -115,6 +118,8 @@ mod tests { "icons/git-branch.svg", "icons/git-sync.svg", "icons/git-commit.svg", + "icons/list-tree.svg", + "icons/list-flat.svg", ] { assert!( Assets.load(path).unwrap().is_some(), diff --git a/src/ui/diff_overlay.rs b/src/ui/diff_overlay.rs index 61e746e7..8f394962 100644 --- a/src/ui/diff_overlay.rs +++ b/src/ui/diff_overlay.rs @@ -3363,6 +3363,7 @@ mod selection_gpui_tests { agent: None, agent_session_id: None, agent_launch_argv: None, + run_on_land: None, owner: None, font_size: 14.0, }, diff --git a/src/ui/document_column.rs b/src/ui/document_column.rs index 447f7389..23738f23 100644 --- a/src/ui/document_column.rs +++ b/src/ui/document_column.rs @@ -569,6 +569,7 @@ mod gpui_tests { agent: None, agent_session_id: None, agent_launch_argv: None, + run_on_land: None, owner: None, font_size: 14.0, }, diff --git a/src/ui/forwards.rs b/src/ui/forwards.rs index 3257016d..0b00cb10 100644 --- a/src/ui/forwards.rs +++ b/src/ui/forwards.rs @@ -3,6 +3,7 @@ use gpui_component::button::{Button, ButtonVariants as _}; use gpui_component::input::Input; use gpui_component::{ActiveTheme as _, Disableable as _, IconName, Sizable as _, h_flex, v_flex}; +use crate::core::ssh_profile::ForwardRule; use crate::daemon::protocol::{ForwardStatus, ManagedForward, SshForwardKind, SshForwardRule}; use crate::terminal::view::TerminalView; use crate::ui::app::{CONTENT_INSET, Tty7App}; @@ -70,6 +71,7 @@ impl ForwardFields { target_host, target_port, description: (!description.is_empty()).then(|| description.to_string()), + enabled: true, }) } @@ -93,6 +95,7 @@ impl ForwardFields { // left over from a trip through the advanced form is not something // this rule was given. description: None, + enabled: true, }) } @@ -140,9 +143,34 @@ pub(crate) fn rule_of(forward: &ManagedForward) -> SshForwardRule { target_host: forward.target_host.clone(), target_port: forward.target_port, description: forward.description.clone(), + enabled: forward.enabled, } } +/// Which of a saved host's rules a live forward was opened from, among +/// those not already switched to `enabled` — the one a switch in the panel +/// has to be written back to. +/// +/// Matched on the whole mapping, since a live forward carries no reference to +/// the rule it came from. A saved rule always names its port, so the port the +/// forward bound is the one the rule asked for. A forward added in the panel +/// matches nothing and stays as temporary as it always was. +pub(crate) fn saved_rule_index( + rules: &[ForwardRule], + forward: &ManagedForward, + enabled: bool, +) -> Option { + rules.iter().position(|r| { + let saved = crate::ui::ssh_connect::map_forward(r); + saved.enabled != enabled + && saved.kind == forward.kind + && saved.bind_host == forward.bind_host + && saved.bind_port == forward.bind_port + && saved.target_host == forward.target_host + && saved.target_port == forward.target_port + }) +} + impl Tty7App { pub(crate) fn render_ssh_status_strip( &self, @@ -254,18 +282,23 @@ impl Tty7App { SshForwardKind::Remote => "R", SshForwardKind::Dynamic => "D", }; - let errored = matches!(forward.status, ForwardStatus::Error(_)); + // A switched-off forward has an error status only because it binds + // nothing; it is drawn faded, not red. + let off = !forward.enabled; + let errored = !off && matches!(forward.status, ForwardStatus::Error(_)); let bind = if matches!(forward.bind_host.as_str(), "127.0.0.1" | "localhost" | "") { forward.bind_port.to_string() } else { format!("{}:{}", forward.bind_host, forward.bind_port) }; + let mapping = match forward.kind { + SshForwardKind::Dynamic => "SOCKS".to_string(), + _ => format!("→ {}:{}", forward.target_host, forward.target_port), + }; let tail = match &forward.status { + _ if off => mapping, ForwardStatus::Error(msg) => msg.clone(), - ForwardStatus::Listening => match forward.kind { - SshForwardKind::Dynamic => "SOCKS".to_string(), - _ => format!("→ {}:{}", forward.target_host, forward.target_port), - }, + ForwardStatus::Listening => mapping, }; let pane_id = forward.pane_id; let forward_id = forward.id; @@ -295,6 +328,7 @@ impl Tty7App { .flex_1() .min_w_0() .gap(px(1.)) + .when(off, |col| col.opacity(0.5)) .child( h_flex() .items_center() @@ -326,6 +360,33 @@ impl Tty7App { ) }), ) + // Always shown, unlike Remove beside it: whether a forward is on is + // state to read at a glance, not an action to go looking for. + .child( + div() + .flex_shrink_0() + .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .child( + crate::ui::theme::switch( + ("panel-forward-enabled", forward_id as usize), + cx, + ) + .checked(forward.enabled) + .xsmall() + .tooltip(if off { + t(L10nKey::ForwardTooltipTurnOn) + } else { + t(L10nKey::ForwardTooltipTurnOff) + }) + .on_click(cx.listener( + move |this, on: &bool, window, cx| { + this.set_managed_forward_enabled( + pane_id, forward_id, *on, window, cx, + ) + }, + )), + ), + ) .child( div() .flex_shrink_0() @@ -586,6 +647,7 @@ mod tests { target_port: 80, description: Some("the staging box".to_string()), status: ForwardStatus::Listening, + enabled: true, } } @@ -718,4 +780,43 @@ mod tests { "nothing was added, so there is nothing to point at" ); } + + #[test] + fn a_switched_off_forward_keeps_its_switch_through_an_edit() { + let mut off = managed(3, 8080); + off.enabled = false; + assert!(!rule_of(&off).enabled); + assert!(rule_of(&managed(3, 8080)).enabled); + } + + /// The panel's switch is written back to the saved host only for the rule + /// the forward was opened from, and a rule added in the panel — which no + /// saved rule describes — is left as temporary as it always was. + #[test] + fn a_switch_is_written_back_to_the_saved_rule_it_came_from() { + use crate::core::ssh_profile::{ForwardKind, HostPort}; + let saved = |target: &str, enabled: bool| ForwardRule { + kind: ForwardKind::Local, + bind: HostPort::new("127.0.0.1", 8080), + target: HostPort::new(target, 80), + description: "the staging box".to_string(), + enabled, + }; + let rules = vec![saved("10.0.0.9", true), saved("10.0.0.5", true)]; + let live = managed(3, 8080); + assert_eq!(saved_rule_index(&rules, &live, false), Some(1)); + assert_eq!( + saved_rule_index(&rules, &live, true), + None, + "already on: nothing to write" + ); + + let mut elsewhere = managed(3, 8081); + elsewhere.description = None; + assert_eq!(saved_rule_index(&rules, &elsewhere, false), None); + + let mut remote = managed(3, 8080); + remote.kind = SshForwardKind::Remote; + assert_eq!(saved_rule_index(&rules, &remote, false), None); + } } diff --git a/src/ui/home.rs b/src/ui/home.rs index e6d350d3..e3fcac18 100644 --- a/src/ui/home.rs +++ b/src/ui/home.rs @@ -496,6 +496,8 @@ mod tests { tree_id: None, group: None, last_auto: None, + hibernated: false, + asleep_view: None, pane: leaf(Some("/work/getty")), }; assert_eq!(closed_tab_label(&tab).as_deref(), Some("build")); @@ -508,6 +510,8 @@ mod tests { tree_id: None, group: None, last_auto: None, + hibernated: false, + asleep_view: None, pane: leaf(Some("/work/getty")), }; assert_eq!(closed_tab_label(&tab).as_deref(), Some("getty")); @@ -517,6 +521,8 @@ mod tests { tree_id: None, group: None, last_auto: None, + hibernated: false, + asleep_view: None, pane: leaf(Some("/work/getty")), }; assert_eq!(closed_tab_label(&tab).as_deref(), Some("getty")); @@ -529,6 +535,8 @@ mod tests { tree_id: None, group: None, last_auto: None, + hibernated: false, + asleep_view: None, pane: SessionPane::Split { axis: crate::core::session::SessionAxis::Horizontal, ratio: 0.5, @@ -546,6 +554,8 @@ mod tests { tree_id: None, group: None, last_auto: None, + hibernated: false, + asleep_view: None, pane: leaf(None), }; assert_eq!(closed_tab_label(&unnamed), None); @@ -554,6 +564,8 @@ mod tests { tree_id: None, group: None, last_auto: None, + hibernated: false, + asleep_view: None, pane: leaf(Some("/")), }; assert_eq!(closed_tab_label(&root), None); @@ -566,6 +578,8 @@ mod tests { tree_id: None, group: None, last_auto: None, + hibernated: false, + asleep_view: None, pane: leaf(None), }; let label = closed_tab_label(&tab).unwrap(); diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index c3682fed..cd1991b9 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -620,6 +620,13 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsDiffPreviewFromCountsDesc => { "Click a row's +N −N to open the working-tree diff in an overlay. Off leaves the counts visible, just not clickable." } + L10nKey::SettingsSshTabTitle => "SSH tab title", + L10nKey::SettingsSshTabTitleDesc => { + "What an SSH tab is called. Dynamic follows the title the remote side sets; the other two pin the tab to the host. A tab you renamed keeps its name." + } + L10nKey::SettingsSshTabTitleDynamic => "Dynamic", + L10nKey::SettingsSshTabTitleProfileName => "Profile name", + L10nKey::SettingsSshTabTitleHostname => "Hostname", L10nKey::DocumentDock => "Dock beside terminal", L10nKey::DocumentFill => "Fill window", L10nKey::SettingsNotifications => "Notifications", @@ -940,6 +947,9 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsSearchSidebarGroupingKeywords => { "tabs group grouping auto repo repository git ssh host pinned pin ungrouped header sidebar flat folder" } + L10nKey::SettingsSearchSshTabTitleKeywords => { + "ssh tab title name host hostname profile alias pin fixed osc remote" + } L10nKey::SettingsSearchSmartSelectionKeywords => { "double click word url path select semantic bracket email" } @@ -1019,6 +1029,10 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SshEditProfile => "Edit connection…", L10nKey::ForwardTooltipAdd => "Add forward", L10nKey::ForwardTooltipRemove => "Remove", + L10nKey::ForwardTooltipTurnOn => "Turn on", + L10nKey::ForwardTooltipTurnOff => "Turn off — the rule is kept", + L10nKey::ForwardSwitchFailed => "Couldn't switch the forward — {error}", + L10nKey::SettingsFwdEnabled => "Open this rule with the connection", L10nKey::ForwardLocal => "Local", L10nKey::ForwardRemote => "Remote", L10nKey::ForwardDynamic => "Dynamic", @@ -1214,6 +1228,10 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::ScmShowMore => "Show more", L10nKey::ScmShowLess => "Show less", L10nKey::ScmCommitNotFound => "This commit is not in this repository.", + L10nKey::ScmFilterChanges => "Filter changed files…", + L10nKey::ScmViewAsTree => "View as Tree", + L10nKey::ScmViewAsList => "View as List", + L10nKey::ScmNoMatchingChanges => "No changed files match the filter.", L10nKey::ScmTooManyChanges => "Showing the first {shown} of {total} changes.", L10nKey::ScmOpenChanges => "Open Changes", L10nKey::ScmDiscardAllConfirm => { @@ -1509,9 +1527,15 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::CmdCopyWorkingDirectory => "Copy Working Directory", L10nKey::CmdCopySessionId => "Copy Session ID", L10nKey::CmdCopySessionIdSubtitle => "the coding agent's own session id", + L10nKey::CmdNewAgentTab => "New Agent Tab", + L10nKey::CmdNewAgentTabSubtitle => "open the coding agent you used last in a new tab", L10nKey::CmdForkSession => "Fork Session", L10nKey::CmdForkSessionSubtitle => "branch this agent session into a new tab", L10nKey::CmdMarkTabAsUnread => "Mark Tab as Unread", + L10nKey::CmdHibernateTab => "Hibernate Tab", + L10nKey::CmdHibernateTabSubtitle => { + "stop its processes to free memory; selecting it wakes it" + } L10nKey::CmdClosePaneTab => "Close Pane / Tab", L10nKey::CmdCloseWindow => "Close Window", L10nKey::CmdCloseWindowSubtitle => "shells keep running", @@ -1670,6 +1694,16 @@ pub fn translate_en(key: L10nKey) -> &'static str { "No uncommitted changes in {cwd} (or not a git repository)." } L10nKey::AppCmdSshProfileTitle => "SSH: {title}", + L10nKey::AppCmdAgentLaunchTitle => "Agent: {name}", + L10nKey::AppNoAgentOnPath => "No coding agent was found on this machine's PATH", + L10nKey::AppNoAgentSeenHere => { + "No coding agent has run in this workspace yet — start one by hand once and it will be offered here" + } + L10nKey::AppAgentLaunchSaved => "{name} will now launch as: {command}", + L10nKey::AppAgentLaunchArgsUnknown => { + "{name} did not report the arguments it was started with" + } + L10nKey::AppCmdShellTitle => "Shell: {title}", L10nKey::AppCmdSwitchToTab => "Switch to Tab: {label}", L10nKey::AppPlaceholderDescription => "description", L10nKey::AppPlaceholderSshQuickConnect => "user@host or user@host:port", @@ -1828,6 +1862,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::AppMenuCopyWorkingDirectory => "Copy Working Directory", L10nKey::AppMenuCopySessionId => "Copy Session ID", L10nKey::AppMenuForkSession => "Fork Session", + L10nKey::AppMenuSaveAgentLaunchArgs => "Set Current Launch Args as Default", L10nKey::AppMenuClosePaneTab => "Close", L10nKey::AppMenuCloseOtherTabs => "Close Other Tabs", L10nKey::AppMenuCloseTabsRight => "Close Tabs to the Right", @@ -1886,6 +1921,8 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::TabMenuLocalShells => "Local", L10nKey::TabMenuAddHost => "Add SSH Host…", L10nKey::TabMenuAllHosts => "All SSH Hosts…", + L10nKey::TabMenuLaunchAgent => "Launch Agent…", + L10nKey::TabMenuOtherShells => "Other Shells…", L10nKey::TabMenuSplitHint => "Hold {key} to split", L10nKey::TabUnnamedShell => "Shell {n}", L10nKey::ShellDefault => "default", @@ -1912,6 +1949,10 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsNoneLower => "none", L10nKey::SettingsSearchCommandLineToolTitle => "Command line tool", L10nKey::TabContextMarkUnread => "Mark as Unread", + L10nKey::TabContextHibernate => "Hibernate", + L10nKey::TabContextWake => "Wake", + L10nKey::TabTooltipAsleep => "Hibernated — select to wake", + L10nKey::TabWakeFailed => "Could not wake the tab: none of its panes could be started", } } diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index c9427e5c..a2502336 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -628,6 +628,13 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsDiffPreviewFromCountsDesc => { "行の +N −N をクリックすると、オーバーレイでワーキングツリーの Diff を開きます。オフならカウントは表示されたまま、クリックだけできません" } + L10nKey::SettingsSshTabTitle => "SSH タブのタイトル", + L10nKey::SettingsSshTabTitleDesc => { + "SSH タブに表示する名前。「動的」はリモート側が設定したタイトルに従い、ほかの二つはタブをホストに固定します。名前を変更したタブはその名前のまま" + } + L10nKey::SettingsSshTabTitleDynamic => "動的", + L10nKey::SettingsSshTabTitleProfileName => "プロファイル名", + L10nKey::SettingsSshTabTitleHostname => "ホスト名", L10nKey::DocumentDock => "ターミナルの隣にドック", L10nKey::DocumentFill => "ウィンドウ全体", L10nKey::SettingsNotifications => "通知", @@ -1002,6 +1009,9 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsSearchSidebarGroupingKeywords => { "タブ グループ 自動 リポジトリ git ssh ホスト 固定 未分類 サイドバー sidebar auto grouping tabs repo repository pinned pin host ungrouped" } + L10nKey::SettingsSearchSshTabTitleKeywords => { + "SSH タブ タイトル 名前 ホスト ホスト名 プロファイル エイリアス 固定 ssh tab title name host hostname profile alias pin" + } L10nKey::SettingsSearchSmartSelectionKeywords => { "ダブルクリック 単語 url パス 選択 セマンティック 括弧 メール smart selection double click word url path bracket email" } @@ -1085,6 +1095,10 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SshEditProfile => "接続を編集…", L10nKey::ForwardTooltipAdd => "フォワードを追加", L10nKey::ForwardTooltipRemove => "削除", + L10nKey::ForwardTooltipTurnOn => "オンにする", + L10nKey::ForwardTooltipTurnOff => "オフにする — ルールは残ります", + L10nKey::ForwardSwitchFailed => "フォワードを切り替えられませんでした — {error}", + L10nKey::SettingsFwdEnabled => "接続時にこのルールを開く", L10nKey::ForwardLocal => "ローカル", L10nKey::ForwardRemote => "リモート", L10nKey::ForwardDynamic => "ダイナミック", @@ -1288,6 +1302,10 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::ScmShowMore => "続きを表示", L10nKey::ScmShowLess => "折りたたむ", L10nKey::ScmCommitNotFound => "このリポジトリにそのコミットはありません。", + L10nKey::ScmFilterChanges => "変更ファイルを絞り込む…", + L10nKey::ScmViewAsTree => "ツリーで表示", + L10nKey::ScmViewAsList => "リストで表示", + L10nKey::ScmNoMatchingChanges => "条件に一致する変更ファイルはありません。", L10nKey::ScmTooManyChanges => { "変更が多いため、{total} 件のうち先頭 {shown} 件のみ表示しています。" } @@ -1573,9 +1591,13 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::CmdCopyWorkingDirectory => "作業ディレクトリをコピー", L10nKey::CmdCopySessionId => "セッション ID をコピー", L10nKey::CmdCopySessionIdSubtitle => "コーディングエージェント自身のセッション ID", + L10nKey::CmdNewAgentTab => "新しいエージェントタブ", + L10nKey::CmdNewAgentTabSubtitle => "最後に使ったコーディングエージェントを新しいタブで開く", L10nKey::CmdForkSession => "セッションをフォーク", L10nKey::CmdForkSessionSubtitle => "このエージェントのセッションを新しいタブにフォーク", L10nKey::CmdMarkTabAsUnread => "タブを未読としてマーク", + L10nKey::CmdHibernateTab => "タブを休止", + L10nKey::CmdHibernateTabSubtitle => "プロセスを停止してメモリを解放。選択すると復帰", L10nKey::CmdClosePaneTab => "ペイン / タブを閉じる", L10nKey::CmdCloseWindow => "ウィンドウを閉じる", L10nKey::CmdCloseWindowSubtitle => "シェルは実行を継続", @@ -1742,6 +1764,14 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { "{cwd} に未コミットの変更はありません(または git リポジトリではありません)" } L10nKey::AppCmdSshProfileTitle => "SSH: {title}", + L10nKey::AppCmdAgentLaunchTitle => "Agent: {name}", + L10nKey::AppNoAgentOnPath => "このマシンの PATH にコーディングエージェントが見つかりません", + L10nKey::AppNoAgentSeenHere => { + "このワークスペースではまだコーディングエージェントが実行されていません — 一度手動で起動すると、ここに表示されます" + } + L10nKey::AppAgentLaunchSaved => "{name} は今後このコマンドで起動します: {command}", + L10nKey::AppAgentLaunchArgsUnknown => "{name} は起動時の引数を報告していません", + L10nKey::AppCmdShellTitle => "Shell: {title}", L10nKey::AppCmdSwitchToTab => "タブに切り替え: {label}", L10nKey::AppPlaceholderDescription => "説明", L10nKey::AppPlaceholderSshQuickConnect => "user@host または user@host:port", @@ -1904,6 +1934,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::AppMenuCopyWorkingDirectory => "作業ディレクトリをコピー", L10nKey::AppMenuCopySessionId => "セッション ID をコピー", L10nKey::AppMenuForkSession => "セッションをフォーク", + L10nKey::AppMenuSaveAgentLaunchArgs => "現在の起動引数をデフォルトに設定", L10nKey::AppMenuClosePaneTab => "閉じる", L10nKey::AppMenuCloseOtherTabs => "他のタブを閉じる", L10nKey::AppMenuCloseTabsRight => "右側のタブを閉じる", @@ -1962,6 +1993,8 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::TabMenuLocalShells => "ローカル", L10nKey::TabMenuAddHost => "SSH ホストを追加…", L10nKey::TabMenuAllHosts => "すべての SSH ホスト…", + L10nKey::TabMenuLaunchAgent => "エージェントを起動…", + L10nKey::TabMenuOtherShells => "その他のシェル…", L10nKey::TabMenuSplitHint => "{key} を押しながら選ぶと分割", L10nKey::TabUnnamedShell => "シェル {n}", L10nKey::ShellDefault => "デフォルト", @@ -1988,6 +2021,10 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsNoneLower => "なし", L10nKey::SettingsSearchCommandLineToolTitle => "コマンドラインツール", L10nKey::TabContextMarkUnread => "未読としてマーク", + L10nKey::TabContextHibernate => "休止", + L10nKey::TabContextWake => "復帰", + L10nKey::TabTooltipAsleep => "休止中 — 選択すると復帰", + L10nKey::TabWakeFailed => "タブを復帰できませんでした:どのペインも起動できません", }) } diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index a169b8a5..4e8eaf92 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -498,6 +498,11 @@ l10n_keys! { SettingsSidebarGroupingDesc, SettingsDiffPreviewFromCounts, SettingsDiffPreviewFromCountsDesc, + SettingsSshTabTitle, + SettingsSshTabTitleDesc, + SettingsSshTabTitleDynamic, + SettingsSshTabTitleProfileName, + SettingsSshTabTitleHostname, DocumentDock, DocumentFill, SettingsNotifications, @@ -682,6 +687,7 @@ l10n_keys! { SettingsSearchScrollbackKeywords, SettingsSearchShowTrayIconKeywords, SettingsSearchSidebarGroupingKeywords, + SettingsSearchSshTabTitleKeywords, SettingsSearchSmartSelectionKeywords, SettingsSearchStartInKeywords, SettingsSearchSyncWithSystemKeywords, @@ -736,6 +742,10 @@ l10n_keys! { SshEditProfile, ForwardTooltipAdd, ForwardTooltipRemove, + ForwardTooltipTurnOn, + ForwardTooltipTurnOff, + ForwardSwitchFailed, + SettingsFwdEnabled, ForwardLocal, ForwardRemote, ForwardDynamic, @@ -911,6 +921,10 @@ l10n_keys! { ScmShowLess, ScmCommitNotFound, ScmTooManyChanges, + ScmFilterChanges, + ScmViewAsTree, + ScmViewAsList, + ScmNoMatchingChanges, ScmOpenChanges, ScmDiscardAllConfirm, ScmAmendConfirm, @@ -998,6 +1012,7 @@ l10n_keys! { AppMenuCopyWorkingDirectory, AppMenuCopySessionId, AppMenuForkSession, + AppMenuSaveAgentLaunchArgs, AppMenuClosePaneTab, AppMenuCloseOtherTabs, AppMenuCloseTabsRight, @@ -1057,6 +1072,8 @@ l10n_keys! { TabMenuLocalShells, TabMenuAddHost, TabMenuAllHosts, + TabMenuLaunchAgent, + TabMenuOtherShells, TabMenuSplitHint, TabUnnamedShell, ShellDefault, @@ -1076,6 +1093,10 @@ l10n_keys! { TabContextCloseTab, TabContextCloseTabsBelow, TabContextMarkUnread, + TabContextHibernate, + TabContextWake, + TabTooltipAsleep, + TabWakeFailed, RemoteStripDisconnected, RemoteStripConnecting, RemoteStripReconnecting, @@ -1222,9 +1243,13 @@ l10n_keys! { CmdCopyWorkingDirectory, CmdCopySessionId, CmdCopySessionIdSubtitle, + CmdNewAgentTab, + CmdNewAgentTabSubtitle, CmdForkSession, CmdForkSessionSubtitle, CmdMarkTabAsUnread, + CmdHibernateTab, + CmdHibernateTabSubtitle, CmdClosePaneTab, CmdCloseWindow, CmdCloseWindowSubtitle, @@ -1351,6 +1376,12 @@ l10n_keys! { AppPaneNoKnownDirectory, AppNoUncommittedChanges, AppCmdSshProfileTitle, + AppCmdAgentLaunchTitle, + AppNoAgentOnPath, + AppNoAgentSeenHere, + AppAgentLaunchSaved, + AppAgentLaunchArgsUnknown, + AppCmdShellTitle, AppCmdSwitchToTab, AppPlaceholderDescription, AppPlaceholderSshQuickConnect, @@ -1611,6 +1642,7 @@ mod tests { L10nKey::Ok, // Pure templates: every word in them is a placeholder. L10nKey::AppCmdSshProfileTitle, + L10nKey::AppCmdShellTitle, L10nKey::HostOpsError, L10nKey::SftpTransferProgress, // Product names. @@ -1652,6 +1684,9 @@ mod tests { // translation here would be less clear, not more. L10nKey::SettingsShell, L10nKey::CmdGroupAgents, + // The quick-launch rows, which the New Tab menu finds by typing + // this one word into the palette in every locale. + L10nKey::AppCmdAgentLaunchTitle, ]; for &key in KEPT_IN_ENGLISH { diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 4b35c298..092a77dd 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -549,6 +549,13 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsDiffPreviewFromCountsDesc => { "点击行上的 +N −N 在浮层中打开 worktree diff。关闭后计数仍显示,只是不可点击。" } + L10nKey::SettingsSshTabTitle => "SSH 标签页标题", + L10nKey::SettingsSshTabTitleDesc => { + "SSH 标签页显示的名字。“动态”跟随远端设置的标题;另外两项把标签页固定为主机。手动重命名过的标签页保留其名字。" + } + L10nKey::SettingsSshTabTitleDynamic => "动态", + L10nKey::SettingsSshTabTitleProfileName => "配置名称", + L10nKey::SettingsSshTabTitleHostname => "主机名", L10nKey::DocumentDock => "停靠在终端旁", L10nKey::DocumentFill => "铺满窗口", L10nKey::SettingsNotifications => "通知", @@ -893,6 +900,9 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsSearchSidebarGroupingKeywords => { "自动分组 标签页 分组 仓库 git ssh 主机 固定 未分组 侧栏 sidebar auto grouping tabs repo repository pinned pin host ungrouped" } + L10nKey::SettingsSearchSshTabTitleKeywords => { + "SSH 标签页 标题 名称 主机 主机名 配置 别名 固定 ssh tab title name host hostname profile alias pin" + } L10nKey::SettingsSearchSmartSelectionKeywords => { "智能选择 双击 选择 单词 URL 路径 邮箱 括号 smart selection double click" } @@ -974,6 +984,10 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SshEditProfile => "编辑连接…", L10nKey::ForwardTooltipAdd => "添加转发", L10nKey::ForwardTooltipRemove => "移除", + L10nKey::ForwardTooltipTurnOn => "开启", + L10nKey::ForwardTooltipTurnOff => "关闭——规则会保留", + L10nKey::ForwardSwitchFailed => "无法切换这条转发——{error}", + L10nKey::SettingsFwdEnabled => "连接时开启这条规则", L10nKey::ForwardLocal => "本地", L10nKey::ForwardRemote => "远程", L10nKey::ForwardDynamic => "动态", @@ -1154,6 +1168,10 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::ScmShowMore => "展开", L10nKey::ScmShowLess => "收起", L10nKey::ScmCommitNotFound => "本仓库中没有这个提交。", + L10nKey::ScmFilterChanges => "筛选变更文件…", + L10nKey::ScmViewAsTree => "以树形显示", + L10nKey::ScmViewAsList => "以列表显示", + L10nKey::ScmNoMatchingChanges => "没有匹配筛选的变更文件。", L10nKey::ScmTooManyChanges => "改动过多,仅显示前 {shown} 项(共 {total} 项)。", L10nKey::ScmOpenChanges => "查看改动", L10nKey::ScmDiscardAllConfirm => { @@ -1416,9 +1434,13 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::CmdCopyWorkingDirectory => "复制工作目录", L10nKey::CmdCopySessionId => "复制会话 ID", L10nKey::CmdCopySessionIdSubtitle => "编码 agent 自身的会话 ID", + L10nKey::CmdNewAgentTab => "新建 Agent 标签页", + L10nKey::CmdNewAgentTabSubtitle => "在新标签页中打开上次使用的编码 agent", L10nKey::CmdForkSession => "Fork 会话", L10nKey::CmdForkSessionSubtitle => "将此 agent 会话 fork 到新标签页", L10nKey::CmdMarkTabAsUnread => "将标签页标记为未读", + L10nKey::CmdHibernateTab => "休眠标签页", + L10nKey::CmdHibernateTabSubtitle => "停止其中的进程以释放内存,选中即可唤醒", L10nKey::CmdClosePaneTab => "关闭窗格/标签页", L10nKey::CmdCloseWindow => "关闭窗口", L10nKey::CmdCloseWindowSubtitle => "shell 保持运行", @@ -1571,6 +1593,14 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::AppPaneNoKnownDirectory => "此窗格没有已知的目录。", L10nKey::AppNoUncommittedChanges => "{cwd} 中没有未提交的更改(或不是 git 仓库)。", L10nKey::AppCmdSshProfileTitle => "SSH:{title}", + L10nKey::AppCmdAgentLaunchTitle => "Agent:{name}", + L10nKey::AppNoAgentOnPath => "在本机 PATH 中未找到编码 agent", + L10nKey::AppNoAgentSeenHere => { + "此工作区还没有运行过编码 agent——手动启动一次后,这里就会列出它" + } + L10nKey::AppAgentLaunchSaved => "{name} 以后将以此命令启动:{command}", + L10nKey::AppAgentLaunchArgsUnknown => "{name} 未报告其启动参数", + L10nKey::AppCmdShellTitle => "Shell:{title}", L10nKey::AppCmdSwitchToTab => "切换到标签页:{label}", L10nKey::AppPlaceholderDescription => "描述", L10nKey::AppPlaceholderSshQuickConnect => "user@host 或 user@host:port", @@ -1723,6 +1753,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::AppMenuCopyWorkingDirectory => "复制工作目录", L10nKey::AppMenuCopySessionId => "复制会话 ID", L10nKey::AppMenuForkSession => "Fork 会话", + L10nKey::AppMenuSaveAgentLaunchArgs => "将当前启动参数设为默认", L10nKey::AppMenuClosePaneTab => "关闭", L10nKey::AppMenuCloseOtherTabs => "关闭其他标签页", L10nKey::AppMenuCloseTabsRight => "关闭右侧标签页", @@ -1781,6 +1812,8 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::TabMenuLocalShells => "本地", L10nKey::TabMenuAddHost => "添加 SSH 主机…", L10nKey::TabMenuAllHosts => "所有 SSH 主机…", + L10nKey::TabMenuLaunchAgent => "启动 Agent…", + L10nKey::TabMenuOtherShells => "其他 Shell…", L10nKey::TabMenuSplitHint => "按住 {key} 可分屏打开", L10nKey::TabUnnamedShell => "终端 {n}", L10nKey::ShellDefault => "默认", @@ -1807,6 +1840,10 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsNoneLower => "无", L10nKey::SettingsSearchCommandLineToolTitle => "命令行工具", L10nKey::TabContextMarkUnread => "标记为未读", + L10nKey::TabContextHibernate => "休眠", + L10nKey::TabContextWake => "唤醒", + L10nKey::TabTooltipAsleep => "已休眠 — 选中即可唤醒", + L10nKey::TabWakeFailed => "无法唤醒标签页:其中的窗格都无法启动", }) } diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs index 6d3ec02f..08296cd1 100644 --- a/src/ui/keymap.rs +++ b/src/ui/keymap.rs @@ -301,6 +301,24 @@ fn per_platform(mac: &'static str, other: &'static str) -> &'static str { } pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> { + let mut bindings = shipped_bindings(); + // One slot per agent, unbound: the palette's `Agent: …` rows are what these + // run, and each takes a key the same way any other action does. Right + // after the other agent actions, so the Keybindings page keeps them together. + let at = bindings + .iter() + .position(|(action, _)| *action == "NewAgentTab") + .map_or(bindings.len(), |i| i + 1); + bindings.splice( + at..at, + crate::ui::agent_launch::launch_action_names() + .iter() + .map(|(_, name)| (name.as_str(), "")), + ); + bindings +} + +fn shipped_bindings() -> Vec<(&'static str, &'static str)> { vec![ ("NewTab", per_platform("secondary-t", "secondary-shift-t")), ("NewWorkspace", "secondary-shift-n"), @@ -325,12 +343,18 @@ pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> { ("CloseTabsToTheRight", ""), ("CopyWorkingDirectory", ""), ("MarkTabUnread", ""), + ("HibernateTab", ""), ("ForkAgentSession", ""), ("ForkAgentSessionRight", ""), ("ForkAgentSessionLeft", ""), ("ForkAgentSessionDown", ""), ("ForkAgentSessionUp", ""), ("CopyAgentSessionId", ""), + // ⌘⇧A for "agent" on macOS, where nothing else in the table or the + // terminal holds it. Off macOS Ctrl+Shift+A is select-all in every + // Linux terminal and in this prompt editor's Ctrl+A family, so it ships + // unbound there rather than on a chord nobody would guess. + ("NewAgentTab", per_platform("secondary-shift-a", "")), ("StopWorkspace", ""), ("DeleteWorkspace", ""), ("RenameWorkspace", ""), @@ -578,6 +602,15 @@ fn authored_entry(action: &str) -> Option<(CommandGroup, String)> { t_fmt(L10nKey::KeybindGoToTab, &[("n", n)]), )); } + if let Some(agent) = crate::ui::agent_launch::agent_for_launch_action(action) { + return Some(( + CommandGroup::Agents, + t_fmt( + L10nKey::AppCmdAgentLaunchTitle, + &[("name", agent.display_name())], + ), + )); + } if let Some(n) = action.strip_prefix("SelectWorkspace") { return Some(( CommandGroup::Workspaces, @@ -614,6 +647,10 @@ fn authored_entry(action: &str) -> Option<(CommandGroup, String)> { CommandGroup::TabsPanes, t(L10nKey::CmdMarkTabAsUnread).to_string(), ), + "HibernateTab" => ( + CommandGroup::TabsPanes, + t(L10nKey::CmdHibernateTab).to_string(), + ), "ReopenClosedTab" => ( CommandGroup::TabsPanes, t(L10nKey::CmdReopenClosedTab).to_string(), @@ -826,6 +863,7 @@ fn authored_entry(action: &str) -> Option<(CommandGroup, String)> { CommandGroup::Agents, t(L10nKey::CmdCopySessionId).to_string(), ), + "NewAgentTab" => (CommandGroup::Agents, t(L10nKey::CmdNewAgentTab).to_string()), "TogglePalette" => ( CommandGroup::Application, t(L10nKey::AppMenuCommandPalette).to_string(), @@ -1343,6 +1381,9 @@ fn action_context(action: &str) -> Option<&'static str> { } fn make_binding(action: &str, keystroke: &str) -> Option { + if let Some(agent) = crate::ui::agent_launch::agent_for_launch_action(action) { + return Some(KeyBinding::new(keystroke, LaunchAgent { agent }, None)); + } Some(match action { "NewTab" => KeyBinding::new(keystroke, NewTab, None), "NewWorkspace" => KeyBinding::new(keystroke, NewWorkspace, None), @@ -1359,12 +1400,14 @@ 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), + "HibernateTab" => KeyBinding::new(keystroke, HibernateTab, 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), + "NewAgentTab" => KeyBinding::new(keystroke, NewAgentTab, None), "SplitRight" => KeyBinding::new(keystroke, SplitRight, None), "SplitDown" => KeyBinding::new(keystroke, SplitDown, None), "FocusNextPane" => KeyBinding::new(keystroke, FocusNextPane, None), @@ -2315,6 +2358,50 @@ mod tests { let ks = Keystroke::parse("secondary").unwrap(); assert_eq!(spec_from_keystroke(&ks), None); } + + #[test] + fn new_agent_tab_ships_a_chord_only_where_one_is_free() { + let mut effective: Vec<(String, String)> = default_bindings() + .into_iter() + .map(|(a, k)| (a.to_string(), k.to_string())) + .collect(); + let default = effective + .iter() + .find(|(action, _)| action == "NewAgentTab") + .map(|(_, key)| key.clone()) + .expect("NewAgentTab has to be listed or it cannot be bound"); + if cfg!(target_os = "macos") { + assert_eq!(default, "secondary-shift-a"); + assert_eq!( + dispatched(&effective, &default, "Terminal"), + vec![NewAgentTab::name_for_type()], + "{default} must reach NewAgentTab, and nothing else may answer it" + ); + } else { + assert_eq!(default, "", "Ctrl+Shift+A is select-all off macOS"); + } + + // Each agent's own launch is a slot of its own, and a key bound to it + // dispatches that agent. + let name = crate::ui::agent_launch::launch_action_name( + tty7_core::core::cli_agent::CLIAgent::Codex, + ); + effective + .iter_mut() + .find(|(action, _)| action == name) + .expect("every agent has a keymap slot") + .1 = "ctrl-alt-shift-c".to_string(); + let mut keymap = gpui::Keymap::default(); + keymap.add_bindings(action_bindings(&effective)); + let typed = [Keystroke::parse("ctrl-alt-shift-c").unwrap()]; + let context = [gpui::KeyContext::parse("Terminal").unwrap()]; + let hits = keymap.bindings_for_input(&typed, &context).0; + assert_eq!(hits.len(), 1); + assert!(hits[0].action().partial_eq(&LaunchAgent { + agent: tty7_core::core::cli_agent::CLIAgent::Codex, + })); + assert_eq!(action_entry(name).0, CommandGroup::Agents); + } } #[cfg(test)] diff --git a/src/ui/local_link.rs b/src/ui/local_link.rs index 8756fb6f..57e57c69 100644 --- a/src/ui/local_link.rs +++ b/src/ui/local_link.rs @@ -49,6 +49,15 @@ impl LocalLink { link.client.as_ref().filter(|c| c.is_connected()).cloned() } + /// Whether the local daemon advertised `feature` on its control hello — + /// [`HostLinks::peer_supports`](crate::ui::remote_connect::HostLinks::peer_supports) + /// for this machine. `false` while the link is down, for the same reason. + pub fn supports(cx: &App, feature: &str) -> bool { + cx.try_global::() + .and_then(|link| link.client.as_ref()) + .is_some_and(|c| c.is_connected() && c.hello().has_feature(feature)) + } + /// Drops the cached client without waiting for its reader to notice. /// /// `ControlClient::is_connected` only flips once the reader sees EOF, so diff --git a/src/ui/machine_mirror.rs b/src/ui/machine_mirror.rs index e3574f03..405a427d 100644 --- a/src/ui/machine_mirror.rs +++ b/src/ui/machine_mirror.rs @@ -969,6 +969,7 @@ mod tests { a: Box::new(PaneNode::Leaf { pane: 1 }), b: Box::new(PaneNode::Leaf { pane: 2 }), }, + hibernated: false, }; assert!(apply( &mut machine, diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 4ec0d0af..a286cb19 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,3 +1,4 @@ +pub mod agent_launch; pub mod app; pub mod assets; pub mod code_editor; @@ -27,6 +28,7 @@ pub mod pending_pane; pub mod perf; pub mod prefill; pub mod presets; +pub mod prompt; pub mod remote_connect; pub mod remote_workspace; pub mod reorder; diff --git a/src/ui/palette.rs b/src/ui/palette.rs index 4906768f..bc7d16ff 100644 --- a/src/ui/palette.rs +++ b/src/ui/palette.rs @@ -35,8 +35,11 @@ pub enum CommandKind { CloseTabsToTheRight, CopyWorkingDirectory, MarkTabUnread, + HibernateTab, ForkAgentSession, CopyAgentSessionId, + NewAgentTab, + LaunchAgent(crate::core::cli_agent::CLIAgent), ResetFontSize, NextPane, PrevPane, @@ -108,6 +111,9 @@ pub enum CommandKind { ActivateTab(usize), ConnectSavedProfile(Uuid), EditSavedProfile(Uuid), + /// Open the shell the window's inventory lists under this label, as the + /// New Tab menu's row for it would. + OpenShell(String), SaveSshSessionAsHost, QuickConnect(String), SaveQuickConnect(String), @@ -145,8 +151,10 @@ impl CommandKind { CloseTabsToTheRight => "close-tabs-right", CopyWorkingDirectory => "copy-cwd", MarkTabUnread => "mark-tab-unread", + HibernateTab => "hibernate-tab", ForkAgentSession => "fork-agent-session", CopyAgentSessionId => "copy-agent-session-id", + NewAgentTab => "new-agent-tab", ResetFontSize => "reset-font-size", NextPane => "next-pane", PrevPane => "prev-pane", @@ -224,8 +232,10 @@ impl CommandKind { | ActivateTab(_) | ConnectSavedProfile(_) | EditSavedProfile(_) + | OpenShell(_) | QuickConnect(_) - | SaveQuickConnect(_) => return None, + | SaveQuickConnect(_) + | LaunchAgent(_) => return None, }) } @@ -238,6 +248,12 @@ impl CommandKind { CutText => return inline("secondary-x"), PasteText => return inline("secondary-v"), SelectAllText => return inline("secondary-a"), + LaunchAgent(agent) => { + return crate::ui::keymap::effective_key( + crate::ui::agent_launch::launch_action_name(*agent), + cx, + ); + } _ => {} } let action = match self { @@ -257,8 +273,10 @@ impl CommandKind { CloseTabsToTheRight => "CloseTabsToTheRight", CopyWorkingDirectory => "CopyWorkingDirectory", MarkTabUnread => "MarkTabUnread", + HibernateTab => "HibernateTab", ForkAgentSession => "ForkAgentSession", CopyAgentSessionId => "CopyAgentSessionId", + NewAgentTab => "NewAgentTab", ResetFontSize => "ResetFontSize", NextPane => "FocusNextPane", PrevPane => "FocusPrevPane", @@ -340,9 +358,11 @@ impl CommandKind { | ActivateTab(_) | ConnectSavedProfile(_) | EditSavedProfile(_) + | OpenShell(_) | SaveSshSessionAsHost | QuickConnect(_) - | SaveQuickConnect(_) => return None, + | SaveQuickConnect(_) + | LaunchAgent(_) => return None, }; crate::ui::keymap::effective_key(action, cx) } @@ -480,6 +500,8 @@ impl Command { Command::localized(L10nKey::CmdForkSession, ForkAgentSession) .with_subtitle(t(L10nKey::CmdForkSessionSubtitle)), Command::localized(L10nKey::CmdMarkTabAsUnread, MarkTabUnread), + Command::localized(L10nKey::CmdHibernateTab, HibernateTab) + .with_subtitle(t(L10nKey::CmdHibernateTabSubtitle)), Command::localized(L10nKey::CmdClosePaneTab, ClosePane), Command::localized(L10nKey::CmdCloseOtherTabs, CloseOtherTabs), Command::localized(L10nKey::CmdCloseTabsToTheRight, CloseTabsToTheRight), @@ -590,6 +612,8 @@ impl Command { ]; let agents = [ + Command::localized(L10nKey::CmdNewAgentTab, NewAgentTab) + .with_subtitle(t(L10nKey::CmdNewAgentTabSubtitle)), Command::localized(L10nKey::CmdAgentSendSelection, SendSelectionToAgent) .with_subtitle(t(L10nKey::CmdAgentSendSelectionSubtitle)), Command::localized(L10nKey::CmdAgentSendGitDiffForReview, SendGitDiffToAgent) diff --git a/src/ui/pending_pane.rs b/src/ui/pending_pane.rs index 93ed11c2..0428c1d5 100644 --- a/src/ui/pending_pane.rs +++ b/src/ui/pending_pane.rs @@ -20,6 +20,10 @@ pub struct PendingSpawn { pub agent: Option, pub agent_session_id: Option, pub agent_launch_argv: Option>, + /// A command line to type into the shell once the pane exists, for a pane + /// opened to run something (a quick-launched agent). Typed only into this + /// pane, and only when it is a fresh shell rather than a restored one. + pub run_on_land: Option, pub owner: Option, pub font_size: f32, } diff --git a/src/ui/prompt.rs b/src/ui/prompt.rs new file mode 100644 index 00000000..80c6459e --- /dev/null +++ b/src/ui/prompt.rs @@ -0,0 +1,180 @@ +//! The confirmation dialog on platforms with no native one. +//! +//! macOS and Windows answer `window.prompt` with NSAlert and TaskDialog. Linux +//! has neither, so gpui falls back to a renderer of its own — and that one sets +//! the message and the detail as single unbreakable lines inside a fixed-width +//! box that clips its overflow. Any sentence longer than the box is cut off +//! mid-word, which for the quit-and-stop warning is exactly the part that says +//! what will be lost (#920). This is the same dialog laid out as a card that +//! wraps its text, in the app's own surfaces, with Return and Escape answering +//! the way they do on the native dialogs. + +use gpui::{ + App, Context, EventEmitter, FocusHandle, Focusable, PromptButton, PromptHandle, PromptLevel, + PromptResponse, RenderablePromptHandle, Window, div, prelude::*, px, relative, +}; +use gpui_component::button::{Button, ButtonVariants as _}; +use gpui_component::{ActiveTheme as _, Sizable as _, h_flex, v_flex}; + +/// Route every `window.prompt` through [`TextPrompt`] where the platform has +/// no dialog of its own to show. +pub(crate) fn install(cx: &mut App) { + if cfg!(any(target_os = "macos", target_os = "windows")) { + return; + } + cx.set_prompt_builder(build); +} + +fn build( + _level: PromptLevel, + message: &str, + detail: Option<&str>, + answers: &[PromptButton], + handle: PromptHandle, + window: &mut Window, + cx: &mut App, +) -> RenderablePromptHandle { + let prompt = cx.new(|cx| TextPrompt::new(message, detail, answers, cx)); + handle.with_view(prompt, window, cx) +} + +pub(crate) struct TextPrompt { + message: String, + detail: Option, + answers: Vec, + focus: FocusHandle, +} + +impl TextPrompt { + fn new( + message: &str, + detail: Option<&str>, + answers: &[PromptButton], + cx: &mut Context, + ) -> Self { + Self { + message: message.to_string(), + detail: detail.map(str::to_string), + answers: answers.to_vec(), + focus: cx.focus_handle(), + } + } + + /// The answer a key picks. Return is answer 0 — `confirm_answers` puts the + /// action there for exactly this — and Escape is the one marked cancel; + /// a prompt with no cancel answer has nothing Escape can safely mean. + fn answer_for_key(&self, key: &str) -> Option { + match key { + "enter" if !self.answers.is_empty() => Some(0), + "escape" => self.answers.iter().position(PromptButton::is_cancel), + _ => None, + } + } +} + +impl EventEmitter for TextPrompt {} + +impl Focusable for TextPrompt { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus.clone() + } +} + +impl Render for TextPrompt { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let muted = cx.theme().muted_foreground; + // Answer 0 goes rightmost, where the native dialogs put it and where + // `confirm_answers` expects it to land. + let buttons = self.answers.iter().enumerate().rev().map(|(ix, answer)| { + Button::new(("prompt-answer", ix)) + .label(answer.label().clone()) + .small() + .when(ix == 0 && !answer.is_cancel(), |b| b.primary()) + .on_click(cx.listener(move |_, _, _, cx| { + cx.emit(PromptResponse(ix)); + cx.stop_propagation(); + })) + }); + + let card = v_flex() + .occlude() + .w(px(420.)) + .max_w(relative(0.9)) + .gap_3() + .p_5() + .map(|card| crate::ui::theme::floating_surface(card, cx)) + .child( + div() + .text_sm() + .font_weight(gpui::FontWeight::SEMIBOLD) + .child(self.message.clone()), + ) + .children( + self.detail + .clone() + .map(|detail| div().text_sm().text_color(muted).child(detail)), + ) + .child( + h_flex() + .pt_2() + .justify_end() + .flex_wrap() + .gap_2() + .children(buttons), + ); + + div() + .id("text-prompt") + .track_focus(&self.focus) + .size_full() + .cursor_default() + .bg(crate::ui::presets::scrim_fill(cx)) + .on_key_down(cx.listener(|this, ev: &gpui::KeyDownEvent, _, cx| { + if let Some(ix) = this.answer_for_key(&ev.keystroke.key) { + cx.emit(PromptResponse(ix)); + cx.stop_propagation(); + } + })) + .flex() + .items_center() + .justify_center() + .child(card) + } +} + +#[cfg(test)] +mod tests { + use super::TextPrompt; + use gpui::{AppContext as _, PromptButton, TestAppContext}; + + fn prompt(cx: &mut TestAppContext, answers: &[PromptButton]) -> gpui::Entity { + cx.update(|cx| { + cx.new(|cx| { + TextPrompt::new( + "Quit and stop tty7 server?", + Some("a detail long enough to need more than one line"), + answers, + cx, + ) + }) + }) + } + + #[gpui::test] + fn return_confirms_and_escape_takes_the_cancel_answer(cx: &mut TestAppContext) { + let p = prompt(cx, &crate::ui::confirm_answers("Quit", "Cancel")); + p.read_with(cx, |p, _| { + assert_eq!(p.answer_for_key("enter"), Some(0)); + assert_eq!(p.answer_for_key("escape"), Some(1)); + assert_eq!(p.answer_for_key("a"), None); + }); + } + + #[gpui::test] + fn escape_does_nothing_without_a_cancel_answer(cx: &mut TestAppContext) { + let p = prompt(cx, &[PromptButton::ok("OK"), PromptButton::new("Retry")]); + p.read_with(cx, |p, _| { + assert_eq!(p.answer_for_key("escape"), None); + }); + } +} diff --git a/src/ui/right_panel.rs b/src/ui/right_panel.rs index 994f83c0..aabd3795 100644 --- a/src/ui/right_panel.rs +++ b/src/ui/right_panel.rs @@ -112,8 +112,11 @@ pub(crate) const ROW_INSET: f32 = 6.; /// Local forwards only, and only those aimed at the far host's own loopback: /// a forward to some third machine happens to carry the same number, and /// pairing it with the port row would claim it leads somewhere it does not. +/// Nor does a switched-off forward: it leads nowhere until it is switched back +/// on, and its own row is where that switch is. pub(crate) fn forwards_port(m: &ManagedForward, port: u16) -> bool { - m.kind == crate::daemon::protocol::SshForwardKind::Local + m.enabled + && m.kind == crate::daemon::protocol::SshForwardKind::Local && m.target_port == port && crate::daemon::protocol::PortEntry::reaches_loopback(&m.target_host) } @@ -729,6 +732,18 @@ impl Tty7App { input: &gpui::Entity, cx: &mut Context, ) -> AnyElement { + self.panel_search_with(input, None, cx) + } + + /// [`Self::panel_search`] with a tile at its far end, inset the way the + /// branch row's tile is so the two stack in one column. + pub(crate) fn panel_search_with( + &self, + input: &gpui::Entity, + trailing: Option, + cx: &mut Context, + ) -> AnyElement { + let has_trailing = trailing.is_some(); h_flex() .flex_none() .items_center() @@ -753,6 +768,8 @@ impl Tty7App { // still reads as one line of chrome. .child(Input::new(input).appearance(false).xsmall().cleanable(true)), ) + .when(has_trailing, |row| row.pr(px(tile_trailing_inset_sm()))) + .children(trailing) .into_any_element() } @@ -1339,8 +1356,26 @@ impl Tty7App { }), ); if let Some(f) = forward { - tiles_wide += 1; + tiles_wide += 2; let forward_id = f.id; + // Switched off, the forward stops speaking for this row and + // comes out as a row of its own with the switch to turn it + // back on. + actions = actions.child( + crate::ui::tab_strip::chrome_tile_sized( + Button::new(("panel-port-switch-off", i)) + .icon(Icon::empty().path("icons/power.svg")), + TILE_SIZE_XS, + TILE_GLYPH_XS, + false, + cx, + ) + .rounded(px(4.)) + .tooltip(t(L10nKey::ForwardTooltipTurnOff)) + .on_click(cx.listener(move |this, _, window, cx| { + this.set_managed_forward_enabled(pane_id, forward_id, false, window, cx) + })), + ); actions = actions.child( self.info_tile( ("panel-port-unforward", i), @@ -1956,6 +1991,7 @@ mod tests { target_port, description: None, status: ForwardStatus::Listening, + enabled: true, } } @@ -1987,6 +2023,15 @@ mod tests { ); } + /// A switched-off forward reaches nothing, so it must not take over the + /// port row — its own row is the only place its switch is. + #[test] + fn a_switched_off_forward_is_not_the_way_to_a_port() { + let mut off = forward(SshForwardKind::Local, "localhost", 3000); + off.enabled = false; + assert!(!forwards_port(&off, 3000)); + } + fn diff(added: u32, removed: u32, open: bool) -> InfoRow { InfoRow { label: "changes", diff --git a/src/ui/scm/actions.rs b/src/ui/scm/actions.rs index 3d5ea2e1..bdede8b6 100644 --- a/src/ui/scm/actions.rs +++ b/src/ui/scm/actions.rs @@ -71,6 +71,16 @@ impl Tty7App { cx.notify(); } + /// Switch the changed-file list between flat and tree, and remember it. + /// + /// Global like `diff_view`: someone who reads changes by directory reads + /// every repository's that way. + pub(crate) fn scm_toggle_changes_tree(&mut self, cx: &mut Context) { + let next = !cx.global::().scm_changes_tree; + self.update_config(cx, |cfg| cfg.scm_changes_tree = next); + cx.notify(); + } + /// Flip the diff overlay between side-by-side and unified. /// /// Global rather than per-overlay, matching `diffEditor.renderSideBySide`: diff --git a/src/ui/scm/mod.rs b/src/ui/scm/mod.rs index 50f4f4e9..39529ce4 100644 --- a/src/ui/scm/mod.rs +++ b/src/ui/scm/mod.rs @@ -14,6 +14,7 @@ pub(crate) mod path; pub(crate) mod state; #[allow(dead_code)] pub(crate) mod status; +pub(crate) mod tree; pub(crate) use actions::ScmIntent; pub(crate) use state::{GraphState, ScmPanelState}; diff --git a/src/ui/scm/panel.rs b/src/ui/scm/panel.rs index 63013637..1bd1c430 100644 --- a/src/ui/scm/panel.rs +++ b/src/ui/scm/panel.rs @@ -39,6 +39,7 @@ use crate::ui::scm::ScmIntent; use crate::ui::scm::path::split_display_path; use crate::ui::scm::state::{RepoKey, ScmGroup}; use crate::ui::scm::status::{status_color, status_glyph}; +use crate::ui::scm::tree::{TreeRow, normalize_query, path_matches, tree_rows}; /// Shared compact pitch for working-tree and commit-detail file rows. pub(super) const ROW_H: f32 = 26.; @@ -52,6 +53,10 @@ pub(super) const ROW_H: f32 = 26.; /// different numbers. const BADGE_W: f32 = 14.; +/// One level of the tree view, the file tree's step: the Files panel and this +/// one draw the same directories, and the same depth should read the same. +const TREE_INDENT: f32 = 14.; + /// The key context the message box installs, and the one `ScmCommit` is /// bound inside. The two are the same string on purpose: a binding whose /// context nothing attaches is a binding that never fires. @@ -236,7 +241,12 @@ impl Tty7App { Some(t(L10nKey::PanelNoChangesHint)), cx, ), - None => self.scm_groups(&repo, &status, cx), + None => { + // Pinned with the rest, so the filter stays in reach however + // far down the list has been scrolled. + pinned.push(self.scm_filter_row(window, cx)); + self.scm_groups(&repo, &status, cx) + } }; let history = self.render_graph_section(&repo, window, cx); self.scm_shell_full(title, pinned, body, history) @@ -1291,24 +1301,63 @@ impl Tty7App { status: &Arc, cx: &mut Context, ) -> AnyElement { + let query = self.scm_filter_query(cx); + let tree = cx.global::().scm_changes_tree; + let mut matched = 0; let mut list = v_flex().px(px(CONTENT_INSET - ROW_INSET)).py(px(2.)); for group in ScmGroup::ORDER { + // Filtered before anything else reads it, so the header's count + // and its stage/discard-all act on what is on screen — a + // "discard all" that also reached the files the filter hid would + // be the one surprise this panel cannot take back. let entries: Vec<&StatusEntry> = status .entries .iter() .filter(|e| in_group(e, group)) + .filter(|e| { + query + .as_deref() + .is_none_or(|q| path_matches(e.path.as_str(), q)) + }) .collect(); if entries.is_empty() { continue; } + matched += entries.len(); let collapsed = self.scm.group_collapsed(group, entries.len()); list = list.child(self.scm_group_header(repo, group, &entries, collapsed, cx)); if collapsed { continue; } let shown = entries.len().min(MAX_RENDERED_FILES); - for entry in entries.iter().take(shown) { - list = list.child(self.scm_file_row(repo, group, entry, cx)); + if tree { + // A filter opens every directory: a match folded away under + // a closed row is a match the filter failed to show. + let filtering = query.is_some(); + let folded = &self.scm.folded_dirs; + let rows = tree_rows(entries[..shown].iter().map(|e| e.path.as_str()), |key| { + !filtering && folded.contains(&(group, key.to_string())) + }); + for row in rows { + list = list.child(match row { + TreeRow::Dir { + key, + label, + depth, + files, + collapsed, + } => self.scm_dir_row( + group, key, label, depth, files, collapsed, !filtering, cx, + ), + TreeRow::File { index, depth } => { + self.scm_file_row(repo, group, entries[index], Some(depth), cx) + } + }); + } + } else { + for entry in entries.iter().take(shown) { + list = list.child(self.scm_file_row(repo, group, entry, None, cx)); + } } if entries.len() > shown { list = list.child(self.scm_note( @@ -1317,6 +1366,9 @@ impl Tty7App { )); } } + if query.is_some() && matched == 0 { + list = list.child(self.scm_note(t(L10nKey::ScmNoMatchingChanges).to_string(), cx)); + } if status.truncated { list = list.child(self.scm_note( t_fmt( @@ -1332,6 +1384,142 @@ impl Tty7App { list.into_any_element() } + /// The filter over the changed files, with the list/tree switch at its + /// end. + /// + /// A standing field, like the Files panel's, rather than one behind a + /// tile like the history's: the list is what this panel is for, and a + /// run that touched sixty files is exactly when nobody wants to go + /// looking for how to narrow it. + fn scm_filter_row(&mut self, window: &mut Window, cx: &mut Context) -> AnyElement { + let input = match self.scm.filter.clone() { + Some(input) => input, + None => { + let input = cx.new(|cx| { + InputState::new(window, cx).placeholder(t(L10nKey::ScmFilterChanges)) + }); + // An `InputState` is its own entity; without this the list + // would never hear about the text typed into it. + self.scm.filter_sub = + Some( + cx.subscribe_in(&input, window, |_this, _input, ev, _window, cx| { + if matches!(ev, gpui_component::input::InputEvent::Change) { + cx.notify(); + } + }), + ); + self.scm.filter = Some(input.clone()); + input + } + }; + let tree = cx.global::().scm_changes_tree; + // The tile shows where it goes, not where you are — the rows under it + // already say which view this is. + let (glyph, tip) = match tree { + true => ("icons/list-flat.svg", t(L10nKey::ScmViewAsList)), + false => ("icons/list-tree.svg", t(L10nKey::ScmViewAsTree)), + }; + let toggle = crate::ui::tab_strip::chrome_tile_sized( + Button::new("scm-changes-view").icon(Icon::empty().path(glyph)), + crate::ui::app::TILE_SIZE_SM, + crate::ui::app::TILE_GLYPH_SM, + false, + cx, + ) + .rounded_md() + .tooltip(tip) + .on_click(cx.listener(|this, _, _window, cx| this.scm_toggle_changes_tree(cx))) + .into_any_element(); + self.panel_search_with(&input, Some(toggle), cx) + } + + /// The filter box's text, if it has any. + fn scm_filter_query(&self, cx: &gpui::App) -> Option { + normalize_query(&self.scm.filter.as_ref()?.read(cx).value()) + } + + /// A directory in the tree view: a chevron in the status letters' + /// column, the (possibly compacted) name, and how many changed files + /// are under it. + #[allow(clippy::too_many_arguments)] + fn scm_dir_row( + &self, + group: ScmGroup, + key: String, + label: String, + depth: usize, + files: usize, + collapsed: bool, + foldable: bool, + cx: &mut Context, + ) -> AnyElement { + let sf = cx.global::().sidebar; + let mono = cx.theme().mono_font_family.clone(); + let muted = cx.theme().muted_foreground; + let id = SharedString::from(format!("scm-dir-{group:?}-{key}")); + let selector = id.clone(); + let toggle = cx.listener({ + let key = key.clone(); + move |this, _, _window, cx| { + this.scm.toggle_dir(group, key.clone()); + cx.notify(); + } + }); + h_flex() + .id(id) + .debug_selector(move || selector.to_string()) + .items_center() + .gap(px(8.)) + .min_h(rems(ROW_H / 16.)) + .w_full() + .min_w_0() + .px(px(ROW_INSET)) + .pl(px(ROW_INSET + depth as f32 * TREE_INDENT)) + .rounded(px(5.)) + .when(foldable, |row| { + row.cursor_pointer() + .hover(|s| s.bg(gpui::rgb(sf.hover))) + .on_click(toggle) + }) + .tooltip(move |window, cx| { + gpui_component::tooltip::Tooltip::new(key.clone()).build(window, cx) + }) + .child( + div() + .flex_none() + .w(px(BADGE_W)) + .flex() + .justify_center() + .text_color(muted) + .child( + Icon::new(if collapsed { + IconName::ChevronRight + } else { + IconName::ChevronDown + }) + .xsmall(), + ), + ) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_size(rems(crate::ui::right_panel::TEXT)) + .text_color(gpui::rgb(sf.text_resting)) + .child(label), + ) + .child( + div() + .flex_none() + .text_size(rems(META_MONO)) + .font_family(mono) + .text_color(muted) + .child(files.to_string()), + ) + .into_any_element() + } + /// "…and 40 more", and the note about a status git had to truncate. /// /// Secondary: it is prose about the list rather than a row of it, and the @@ -1413,11 +1601,15 @@ impl Tty7App { .into_any_element() } + /// One changed file. `depth` is its level in the tree view, and `None` in + /// the flat list — where the row carries its directory beside the name, + /// since there is no directory row above it to say it. fn scm_file_row( &self, repo: &RepoKey, group: ScmGroup, entry: &StatusEntry, + depth: Option, cx: &mut Context, ) -> AnyElement { let sf = cx.global::().sidebar; @@ -1438,8 +1630,10 @@ impl Tty7App { cx, ); + let selector = id.clone(); h_flex() .id(id.clone()) + .debug_selector(move || selector.to_string()) .group(id) .relative() .items_center() @@ -1448,6 +1642,9 @@ impl Tty7App { .w_full() .min_w_0() .px(px(ROW_INSET)) + .when_some(depth, |row, depth| { + row.pl(px(ROW_INSET + depth as f32 * TREE_INDENT)) + }) .py(px(3.)) .rounded(px(5.)) .cursor_pointer() @@ -1503,7 +1700,7 @@ impl Tty7App { // Cap the secondary directory while the filename fills the rest. // A rem cap also gives short paths their intrinsic width inside // the context-menu wrapper's flex layout. - .when(!dir.is_empty(), |this| { + .when(!dir.is_empty() && depth.is_none(), |this| { this.child( div() .flex_none() @@ -3012,6 +3209,80 @@ mod render_idle_gpui_tests { assert_eq!(draws_while_idle(&mut vcx), 0); let _ = std::fs::remove_dir_all(&root); } + + /// The tree view and the filter, end to end over a real repository: the + /// rows that come out are the ones the pure helpers promise, drawn. + #[gpui::test] + fn the_changes_list_folds_into_a_tree_and_filters_by_path(cx: &mut TestAppContext) { + let _serial = serial(); + crate::core::config::pin_test_config_dir(); + let root = scratch("tree"); + git(&root, &["init", "--quiet"]); + std::fs::create_dir_all(root.join("src/ui")).unwrap(); + std::fs::create_dir_all(root.join("docs")).unwrap(); + std::fs::write(root.join("src/ui/a.rs"), "a\n").unwrap(); + std::fs::write(root.join("src/ui/b.rs"), "b\n").unwrap(); + std::fs::write(root.join("docs/readme.md"), "r\n").unwrap(); + + // Keyed off the root the panel resolved, not off `root`: on Windows + // `scratch` hands back a `\\?\` verbatim path that never compares + // equal to git's spelling of the same directory — see the note on + // `a_settled_source_control_panel_reaches_render_idle`. + let (app, mut vcx, _pane) = scm_panel_on(cx, &root, |app, cx| { + app.scm.repo.as_ref().is_some_and(|r| { + crate::terminal::git_data::status_of(cx, r.host, &r.root) + .is_some_and(|s| s.untracked().count() == 3) + }) + }); + let drawn = |vcx: &mut VisualTestContext, what: &'static str| { + app.update_in(vcx, |_, _, cx| cx.notify()); + vcx.run_until_parked(); + vcx.debug_bounds(what).is_some() + }; + let a = "scm-row-Untracked-src/ui/a.rs"; + let readme = "scm-row-Untracked-docs/readme.md"; + let src_ui = "scm-dir-Untracked-src/ui"; + + // The flat list is the default, and it has no directory rows. + assert!(drawn(&mut vcx, a)); + assert!(!drawn(&mut vcx, src_ui)); + + // `src` holds nothing but `ui`, so the two share one row. + app.update(&mut vcx, |app, cx| app.scm_toggle_changes_tree(cx)); + assert!(vcx.update(|_, cx| cx.global::().scm_changes_tree)); + assert!(drawn(&mut vcx, src_ui)); + assert!(!drawn(&mut vcx, "scm-dir-Untracked-src")); + assert!(drawn(&mut vcx, "scm-dir-Untracked-docs")); + assert!(drawn(&mut vcx, a)); + + // Folding the directory keeps its row and hides what is under it. + app.update(&mut vcx, |app, cx| { + app.scm.toggle_dir(ScmGroup::Untracked, "src/ui".into()); + cx.notify(); + }); + assert!(drawn(&mut vcx, src_ui)); + assert!(!drawn(&mut vcx, a)); + assert!(drawn(&mut vcx, readme)); + + // A filter drops what does not match — and opens the folded + // directory the match is in. + app.update_in(&mut vcx, |app, window, cx| { + let input = app.scm.filter.clone().expect("the filter row was drawn"); + input.update(cx, |state, cx| state.set_value("UI A", window, cx)); + }); + assert!(drawn(&mut vcx, a)); + assert!(!drawn(&mut vcx, "scm-row-Untracked-src/ui/b.rs")); + assert!(!drawn(&mut vcx, readme)); + assert!(!drawn(&mut vcx, "scm-dir-Untracked-docs")); + + // The same filter narrows the flat list too. + app.update(&mut vcx, |app, cx| app.scm_toggle_changes_tree(cx)); + assert!(drawn(&mut vcx, a)); + assert!(!drawn(&mut vcx, readme)); + assert!(!drawn(&mut vcx, src_ui)); + + let _ = std::fs::remove_dir_all(&root); + } } /// The action strip is the one place in the panel where a hover reveal sits diff --git a/src/ui/scm/state.rs b/src/ui/scm/state.rs index a201385f..ef44bef5 100644 --- a/src/ui/scm/state.rs +++ b/src/ui/scm/state.rs @@ -96,6 +96,14 @@ pub(crate) struct ScmPanelState { /// opening one by hand has to outlast the next file landing in it. pub(crate) collapsed: HashSet, pub(crate) toggled: HashSet, + /// Directories folded shut in the tree view, per group — the same + /// directory can be open under Changes and shut under Staged. Keyed by + /// the full repo-relative path of the (possibly compacted) row. + pub(crate) folded_dirs: HashSet<(ScmGroup, String)>, + /// The filter over the changed files. Created on first render like + /// `commit_input`, with the subscription that turns typing into a repaint. + pub(crate) filter: Option>, + pub(crate) filter_sub: Option, /// Working directory → the repository root containing it, or `None` when /// there is none, with when the answer was given. The root is what every /// write runs from and what every cache is keyed by, so it is resolved @@ -165,6 +173,14 @@ impl ScmPanelState { } } + /// Fold a tree directory shut, or open it again. + pub(crate) fn toggle_dir(&mut self, group: ScmGroup, key: String) { + let entry = (group, key); + if !self.folded_dirs.remove(&entry) { + self.folded_dirs.insert(entry); + } + } + pub(crate) fn set_group_collapsed(&mut self, group: ScmGroup, collapsed: bool) { self.toggled.insert(group); if collapsed { diff --git a/src/ui/scm/tree.rs b/src/ui/scm/tree.rs new file mode 100644 index 00000000..c0ed93b4 --- /dev/null +++ b/src/ui/scm/tree.rs @@ -0,0 +1,298 @@ +//! The changed-file list as a tree, and the filter both views share. +//! +//! Pure, and over plain paths rather than `StatusEntry`: the panel hands in +//! one group's paths and gets back rows that point into that same slice, so +//! the file rows it draws are the ones the flat list would have drawn — same +//! letter, same click, same context menu — only indented under a directory. +//! +//! Everything here comes out of the status the host already sent. A remote +//! repository gets the same tree without a single extra read of its disk. + +use std::collections::BTreeMap; + +/// One line of the tree view. +#[derive(Clone, PartialEq, Eq, Debug)] +pub(crate) enum TreeRow { + /// A directory, possibly several compacted into one: `src/ui/scm` when + /// `src` and `ui` hold nothing but the next directory down. `key` is the + /// full repo-relative path, which is what the fold state is keyed by; + /// `label` is what the row reads, relative to its parent row. + Dir { + key: String, + label: String, + depth: usize, + /// Every file underneath, at any depth — what a folded row still + /// owes the reader. + files: usize, + collapsed: bool, + }, + /// A file, by its position in the paths the tree was built from. + File { index: usize, depth: usize }, +} + +#[derive(Default)] +struct Node<'a> { + dirs: BTreeMap<&'a str, Node<'a>>, + files: Vec<(&'a str, usize)>, +} + +impl Node<'_> { + fn file_count(&self) -> usize { + self.files.len() + self.dirs.values().map(Node::file_count).sum::() + } +} + +/// Group `paths` by directory, directories before files at every level and +/// each run in byte order — the order git itself lists paths in. +/// +/// A directory whose only content is one more directory is folded into it, +/// the way VS Code's compact folders do: an agent's change three levels down +/// `crates/tty7-core/src` should cost one row to reach, not three. +/// +/// `collapsed` is asked once per directory row with its full path. A folded +/// directory still gets its own row; only what is under it is left out. +pub(crate) fn tree_rows<'a>( + paths: impl IntoIterator, + collapsed: impl Fn(&str) -> bool, +) -> Vec { + let mut root = Node::default(); + for (index, path) in paths.into_iter().enumerate() { + // An untracked directory arrives as `dir/`. It is a leaf here, the + // same as it is a single row in the flat list. + let trimmed = path.strip_suffix('/').unwrap_or(path); + let (dir, name) = match trimmed.rsplit_once('/') { + Some((dir, name)) => (Some(dir), name), + None => (None, trimmed), + }; + let mut node = &mut root; + for part in dir.into_iter().flat_map(|d| d.split('/')) { + node = node.dirs.entry(part).or_default(); + } + node.files.push((name, index)); + } + let mut rows = Vec::new(); + emit(&root, "", 0, &collapsed, &mut rows); + rows +} + +fn emit( + node: &Node<'_>, + prefix: &str, + depth: usize, + collapsed: &impl Fn(&str) -> bool, + rows: &mut Vec, +) { + for (name, child) in &node.dirs { + let mut label = (*name).to_string(); + let mut child = child; + while child.files.is_empty() && child.dirs.len() == 1 { + let (next, grandchild) = child.dirs.iter().next().expect("one entry"); + label.push('/'); + label.push_str(next); + child = grandchild; + } + let key = match prefix { + "" => label.clone(), + _ => format!("{prefix}/{label}"), + }; + let folded = collapsed(&key); + rows.push(TreeRow::Dir { + key: key.clone(), + label, + depth, + files: child.file_count(), + collapsed: folded, + }); + if !folded { + emit(child, &key, depth + 1, collapsed, rows); + } + } + let mut files = node.files.clone(); + files.sort_by(|a, b| a.0.cmp(b.0)); + rows.extend( + files + .into_iter() + .map(|(_, index)| TreeRow::File { index, depth }), + ); +} + +/// The filter box's text, normalised once: lowercased, trimmed, and `None` +/// when there is nothing left to filter by. +pub(crate) fn normalize_query(text: &str) -> Option { + let q = text.trim().to_lowercase(); + (!q.is_empty()).then_some(q) +} + +/// Whether a changed path survives the filter. +/// +/// Every whitespace-separated word has to appear somewhere in the path, in +/// any order: `scm panel` finds `src/ui/scm/panel.rs`, and so does `panel +/// scm`. `query` is what [`normalize_query`] returned. +pub(crate) fn path_matches(path: &str, query: &str) -> bool { + let hay = path.to_lowercase(); + query.split_whitespace().all(|word| hay.contains(word)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn open(paths: &[&str]) -> Vec { + tree_rows(paths.iter().copied(), |_| false) + } + + fn dir(key: &str, label: &str, depth: usize, files: usize) -> TreeRow { + TreeRow::Dir { + key: key.into(), + label: label.into(), + depth, + files, + collapsed: false, + } + } + + fn file(index: usize, depth: usize) -> TreeRow { + TreeRow::File { index, depth } + } + + #[test] + fn files_are_grouped_under_their_directories() { + let rows = open(&["src/a.rs", "README.md", "src/b.rs", "docs/x.md"]); + assert_eq!( + rows, + vec![ + dir("docs", "docs", 0, 1), + file(3, 1), + dir("src", "src", 0, 2), + file(0, 1), + file(2, 1), + // Files after directories, at the root as everywhere else. + file(1, 0), + ] + ); + } + + #[test] + fn a_directory_holding_only_a_directory_is_compacted() { + let rows = open(&[ + "crates/core/src/git/status.rs", + "crates/core/src/git/diff.rs", + "crates/core/Cargo.toml", + ]); + assert_eq!( + rows, + vec![ + // `crates` holds only `core`, so they share a row; `core` + // holds a file, so the chain stops there. + dir("crates/core", "crates/core", 0, 3), + dir("crates/core/src/git", "src/git", 1, 2), + file(1, 2), + file(0, 2), + file(2, 1), + ] + ); + } + + #[test] + fn a_directory_with_two_children_is_not_compacted() { + let rows = open(&["a/b/x.rs", "a/c/y.rs"]); + assert_eq!( + rows, + vec![ + dir("a", "a", 0, 2), + dir("a/b", "b", 1, 1), + file(0, 2), + dir("a/c", "c", 1, 1), + file(1, 2), + ] + ); + } + + #[test] + fn a_folded_directory_keeps_its_row_and_its_count() { + let rows = tree_rows(["src/ui/a.rs", "src/ui/b.rs", "src/main.rs"], |key| { + key == "src/ui" + }); + assert_eq!( + rows, + vec![ + dir("src", "src", 0, 3), + TreeRow::Dir { + key: "src/ui".into(), + label: "ui".into(), + depth: 1, + files: 2, + collapsed: true, + }, + file(2, 1), + ] + ); + } + + #[test] + fn folding_is_asked_about_the_compacted_path() { + let asked = std::cell::RefCell::new(Vec::new()); + tree_rows(["a/b/c/x.rs"], |key| { + asked.borrow_mut().push(key.to_string()); + false + }); + assert_eq!(asked.into_inner(), vec!["a/b/c".to_string()]); + } + + #[test] + fn an_untracked_directory_is_a_leaf() { + let rows = open(&["build/", "src/new/"]); + assert_eq!(rows, vec![dir("src", "src", 0, 1), file(1, 1), file(0, 0)]); + } + + #[test] + fn every_path_lands_in_exactly_one_file_row() { + let paths = [ + "z.rs", + "a/b/c.rs", + "a/b/d.rs", + "a/e.rs", + "f/g/h/i.rs", + "f/j.rs", + "a/b/", + ]; + let mut seen: Vec = open(&paths) + .into_iter() + .filter_map(|row| match row { + TreeRow::File { index, .. } => Some(index), + TreeRow::Dir { .. } => None, + }) + .collect(); + seen.sort_unstable(); + assert_eq!(seen, (0..paths.len()).collect::>()); + } + + #[test] + fn an_empty_list_is_an_empty_tree() { + assert!(open(&[]).is_empty()); + } + + #[test] + fn the_filter_needs_every_word_in_any_order_and_any_case() { + let q = normalize_query(" Scm PANEL ").unwrap(); + assert!(path_matches("src/ui/scm/panel.rs", &q)); + assert!(path_matches("src/ui/Panel/SCM.rs", &q)); + assert!(!path_matches("src/ui/scm/state.rs", &q)); + let q = normalize_query("panel scm").unwrap(); + assert!(path_matches("src/ui/scm/panel.rs", &q)); + } + + #[test] + fn a_blank_filter_is_no_filter() { + assert_eq!(normalize_query(""), None); + assert_eq!(normalize_query(" "), None); + assert_eq!(normalize_query(" a "), Some("a".into())); + } + + #[test] + fn the_filter_reads_non_ascii_paths() { + let q = normalize_query("设计").unwrap(); + assert!(path_matches("文档/设计/方案.md", &q)); + assert!(!path_matches("文档/实现.md", &q)); + } +} diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 8ddf639b..bd2ebc97 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -24,7 +24,7 @@ use uuid::Uuid; use crate::core::config::{ BellMode, Config, CursorStyle, LinkFileOpen, MouseZoomModifier, NewTabPosition, NotifyMode, - TabBarPosition, UI_FONT_SIZE_DEFAULT, UpdateChannel, WindowBackdrop, + SshTabTitle, TabBarPosition, UI_FONT_SIZE_DEFAULT, UpdateChannel, WindowBackdrop, }; use crate::core::keychain::{ CredentialRef, CredentialStore as _, OsCredentialStore, key_account_from_contents, @@ -839,6 +839,11 @@ fn settings_search_entries() -> &'static [SearchEntry] { title: SettingsDiffPreviewFromCounts, keywords: SettingsSearchDiffPreviewFromCountsKeywords, }, + SearchEntry { + section: WindowTabs, + title: SettingsSshTabTitle, + keywords: SettingsSearchSshTabTitleKeywords, + }, SearchEntry { section: General, title: SettingsNotifyOnCommandFinish, @@ -898,6 +903,7 @@ impl SearchEntry { L10nKey::SettingsTabBarPosition => "tab_bar_position", L10nKey::SettingsSidebarGrouping => "sidebar_auto_grouping", L10nKey::SettingsDiffPreviewFromCounts => "sidebar_diff_preview", + L10nKey::SettingsSshTabTitle => "ssh_tab_title", L10nKey::SettingsNotifyOnCommandFinish => "notify_on_command_finish", L10nKey::SettingsNotifyThreshold => "notify_threshold_secs", L10nKey::SettingsTerminalBell => "bell", @@ -1007,6 +1013,7 @@ impl SearchEntry { L10nKey::SettingsTabBarPosition => t(L10nKey::SettingsTabBarPositionDesc), L10nKey::SettingsSidebarGrouping => t(L10nKey::SettingsSidebarGroupingDesc), L10nKey::SettingsDiffPreviewFromCounts => t(L10nKey::SettingsDiffPreviewFromCountsDesc), + L10nKey::SettingsSshTabTitle => t(L10nKey::SettingsSshTabTitleDesc), L10nKey::SettingsNotifyOnCommandFinish => t(L10nKey::SettingsNotifyOnCommandFinishDesc), L10nKey::SettingsNotifyThreshold => t(L10nKey::SettingsNotifyThresholdDesc), L10nKey::SettingsAppHttpProxy => t(L10nKey::SettingsAppHttpProxyDesc), @@ -1052,6 +1059,7 @@ impl SearchEntry { L10nKey::SettingsDiffPreviewFromCounts => { cfg.sidebar_diff_preview != defaults.sidebar_diff_preview } + L10nKey::SettingsSshTabTitle => cfg.ssh_tab_title != defaults.ssh_tab_title, L10nKey::SettingsNotifyOnCommandFinish => { cfg.notify_on_command_finish != defaults.notify_on_command_finish } @@ -1556,6 +1564,7 @@ pub(crate) struct ForwardRuleForm { pub(crate) target_host: Entity, pub(crate) target_port: Entity, pub(crate) description: Entity, + pub(crate) enabled: bool, } impl ForwardRuleForm { @@ -1578,6 +1587,7 @@ impl ForwardRuleForm { bind, target, description: val(&self.description), + enabled: self.enabled, }) } @@ -2021,6 +2031,7 @@ fn seed_forward_row( &rule.description, t(L10nKey::ForwardDescriptionPlaceholder), ), + enabled: rule.enabled, } } @@ -6229,7 +6240,7 @@ impl Tty7App { let count = form .forwards .iter() - .filter(|r| r.collect(cx).is_some()) + .filter(|r| r.enabled && r.collect(cx).is_some()) .count(); let summary = match count { 0 => t(L10nKey::SettingsNoneSummary).to_string(), @@ -6374,6 +6385,20 @@ impl Tty7App { .flex_1() .min_w(px(80.)) .child(Input::new(&row.description).xsmall()); + let enabled = div().flex_shrink_0().child( + crate::ui::theme::switch(("ssh-fwd-enabled", idx), cx) + .checked(row.enabled) + .xsmall() + .tooltip(t(L10nKey::SettingsFwdEnabled)) + .on_click(cx.listener(move |this, on: &bool, _w, cx| { + if let Some(f) = this.ssh_form_mut() + && let Some(r) = f.forwards.get_mut(idx) + { + r.enabled = *on; + cx.notify(); + } + })), + ); let remove = crate::ui::tab_strip::hit_target( Button::new(("ssh-fwd-remove", idx)) .icon(Icon::new(IconName::Close)) @@ -6390,6 +6415,7 @@ impl Tty7App { h_flex() .gap_2() .items_center() + .child(enabled) .child(kind_switch) .child(description) .child(remove), @@ -6398,9 +6424,15 @@ impl Tty7App { true => mapping(v_flex().gap_1().items_start()), false => mapping(h_flex().gap_2().items_center()), }), - false => mapping(h_flex().gap_2().items_center().child(kind_switch)) - .child(description) - .child(remove), + false => mapping( + h_flex() + .gap_2() + .items_center() + .child(enabled) + .child(kind_switch), + ) + .child(description) + .child(remove), }; v_flex() @@ -7662,6 +7694,11 @@ impl Tty7App { }; let sidebar_diff_preview = cfg.sidebar_diff_preview; let sidebar_auto_grouping = cfg.sidebar_auto_grouping; + let ssh_tab_title_idx = match cfg.ssh_tab_title { + SshTabTitle::Dynamic => 0, + SshTabTitle::ProfileName => 1, + SshTabTitle::Hostname => 2, + }; let notify_idx = match cfg.notify_on_command_finish { NotifyMode::Never => 0, NotifyMode::Unfocused => 1, @@ -7776,6 +7813,25 @@ impl Tty7App { ) .into_any_element(); + let ssh_tab_title_radio = self.segmented( + "wt-ssh-tab-title", + &[ + t(L10nKey::SettingsSshTabTitleDynamic), + t(L10nKey::SettingsSshTabTitleProfileName), + t(L10nKey::SettingsSshTabTitleHostname), + ], + ssh_tab_title_idx, + cx, + |this, ix, _w, cx| { + let mode = match ix { + 0 => SshTabTitle::Dynamic, + 1 => SshTabTitle::ProfileName, + _ => SshTabTitle::Hostname, + }; + this.set_ssh_tab_title(mode, cx); + }, + ); + v_flex() .when(general, |v| { v.child(self.section_header(t(L10nKey::SettingsWindow), cx)) @@ -7831,6 +7887,12 @@ impl Tty7App { sidebar_diff_switch, cx, )) + .child(self.settings_row( + t(L10nKey::SettingsSshTabTitle), + t(L10nKey::SettingsSshTabTitleDesc), + ssh_tab_title_radio, + cx, + )) }) .when(general, |v| { v.child(self.section_rule(cx)) diff --git a/src/ui/ssh_connect.rs b/src/ui/ssh_connect.rs index c780c7bd..e1bf4210 100644 --- a/src/ui/ssh_connect.rs +++ b/src/ui/ssh_connect.rs @@ -461,6 +461,38 @@ fn build_spec_inner( } } +/// The name an SSH pane's tab is pinned to under `mode` (#726), or `None` for +/// [`SshTabTitle::Dynamic`], which leaves the tab to the title the pane is +/// showing. +/// +/// Built on the same evidence as the name the pane wears before the remote +/// side speaks — [`NativeSshSpec::display_name`], set above — so the pinned +/// name and that one only ever differ where the saved host has been renamed +/// since the pane dialled: the live name wins there, looked up by +/// `profile_id`. An alias from `~/.ssh/config` and a quick connect are not +/// saved hosts, and their `display_name` is the alias and the address typed. +pub(crate) fn pinned_ssh_title( + mode: crate::core::config::SshTabTitle, + spec: &NativeSshSpec, + profiles: &[SshProfile], +) -> Option { + use crate::core::config::SshTabTitle; + + let non_blank = |s: &str| Some(s.trim().to_string()).filter(|s| !s.is_empty()); + match mode { + SshTabTitle::Dynamic => None, + SshTabTitle::ProfileName => spec + .profile_id + .as_deref() + .and_then(|id| Uuid::parse_str(id).ok()) + .and_then(|id| profiles.iter().find(|p| p.id == id)) + .and_then(|p| non_blank(&p.name)) + .or_else(|| spec.display_name.as_deref().and_then(non_blank)) + .or_else(|| non_blank(&spec.host)), + SshTabTitle::Hostname => non_blank(&spec.host), + } +} + /// What a tab's host row opens when it is taken. /// /// The two halves are not the same form. A saved host is already a record, so @@ -574,6 +606,7 @@ fn unmap_forward(rule: &SshForwardRule) -> ForwardRule { bind: HostPort::new(rule.bind_host.clone(), rule.bind_port), target: HostPort::new(rule.target_host.clone(), rule.target_port), description: rule.description.clone().unwrap_or_default(), + enabled: rule.enabled, } } @@ -617,7 +650,7 @@ fn map_proxy(profile: &SshProfile) -> SshProxy { SshProxy::None } -fn map_forward(rule: &ForwardRule) -> SshForwardRule { +pub(crate) fn map_forward(rule: &ForwardRule) -> SshForwardRule { SshForwardRule { kind: match rule.kind { ForwardKind::Local => SshForwardKind::Local, @@ -629,6 +662,7 @@ fn map_forward(rule: &ForwardRule) -> SshForwardRule { target_host: rule.target.host.clone(), target_port: rule.target.port, description: (!rule.description.is_empty()).then(|| rule.description.clone()), + enabled: rule.enabled, } } @@ -823,7 +857,16 @@ mod tests { bind: HostPort::new("localhost", 8080), target: HostPort::new("127.0.0.1", 80), description: "web".into(), + enabled: true, }]; + // A switched-off rule survives the trip through a live spec switched + // off, or reopening a host from its connection would turn it back on. + p.forwards.push(ForwardRule { + bind: HostPort::new("localhost", 8080), + target: HostPort::new("10.0.0.7", 80), + enabled: false, + ..ForwardRule::default() + }); let spec = build_native_ssh_spec(&p, &[], &store, true); let back = profile_from_live_spec(&spec); @@ -1061,4 +1104,83 @@ mod tests { SshProxy::Http { .. } )); } + + /// #726's three sources under each mode: the saved host's own name, the + /// alias for a `~/.ssh/config` host, and the address for a quick connect; + /// `Hostname` always the address dialled, and `Dynamic` nothing at all. + #[test] + fn a_pinned_ssh_title_comes_from_the_source_the_pane_was_opened_from() { + use crate::core::config::SshTabTitle::{Dynamic, Hostname, ProfileName}; + let store = InMemoryCredentialStore::new(); + + let mut saved = profile("prod-web", "10.0.0.5", "deploy"); + let spec = build_native_ssh_spec(&saved, std::slice::from_ref(&saved), &store, true); + let profiles = vec![saved.clone()]; + assert_eq!(pinned_ssh_title(Dynamic, &spec, &profiles), None); + assert_eq!( + pinned_ssh_title(ProfileName, &spec, &profiles).as_deref(), + Some("prod-web") + ); + assert_eq!( + pinned_ssh_title(Hostname, &spec, &profiles).as_deref(), + Some("10.0.0.5") + ); + // Renamed after it dialled: the tab follows the saved host. + saved.name = "prod-api".into(); + assert_eq!( + pinned_ssh_title(ProfileName, &spec, &[saved.clone()]).as_deref(), + Some("prod-api") + ); + // A saved host nobody named reads as its address. + saved.name = " ".into(); + let spec = build_native_ssh_spec(&saved, std::slice::from_ref(&saved), &store, true); + assert_eq!( + pinned_ssh_title(ProfileName, &spec, &[saved]).as_deref(), + Some("deploy@10.0.0.5") + ); + + let root = tempfile::tempdir().unwrap(); + let ssh = root.path().join(".ssh"); + std::fs::create_dir_all(&ssh).unwrap(); + std::fs::write( + ssh.join("config"), + "Host bastion\n HostName 192.168.1.9\n User ops\n", + ) + .unwrap(); + let alias = crate::core::ssh_config::resolve_alias_to_profile_from( + ssh.join("config"), + root.path(), + "bastion", + ) + .unwrap(); + let spec = native_spec_from_transient_profile( + &alias.profile, + alias.proxy_jump, + &store, + true, + &|_| None, + ); + assert_eq!( + pinned_ssh_title(ProfileName, &spec, &profiles).as_deref(), + Some("bastion") + ); + assert_eq!( + pinned_ssh_title(Hostname, &spec, &profiles).as_deref(), + Some("192.168.1.9") + ); + + // What `quick_connect` builds for an address that is no alias. + let mut typed = SshProfile::new("10.0.0.7"); + typed.host = "10.0.0.7".into(); + typed.user = "root".into(); + let spec = build_native_ssh_spec(&typed, &profiles, &store, true); + assert_eq!( + pinned_ssh_title(ProfileName, &spec, &profiles).as_deref(), + Some("10.0.0.7") + ); + assert_eq!( + pinned_ssh_title(Hostname, &spec, &profiles).as_deref(), + Some("10.0.0.7") + ); + } } diff --git a/src/ui/tab_sidebar.rs b/src/ui/tab_sidebar.rs index 6050b1ce..55d5db75 100644 --- a/src/ui/tab_sidebar.rs +++ b/src/ui/tab_sidebar.rs @@ -547,7 +547,8 @@ impl Tty7App { let tab = &self.tabs[i]; let is_active = i == active; let ssh_dot = self.tab_ssh_dot(tab, cx); - let agent = tab.agent(cx); + let asleep = tab.is_asleep(); + let agent = tab.agent(cx).or_else(|| tab.asleep_agent()); let agent_status = tab.agent_status(cx); let agent_unread = tab.agent_unread_count(cx); let git_cwd = git_click(tab, window, cx); @@ -562,6 +563,13 @@ impl Tty7App { } else { 0. }; + // The sleep mark takes the zoom mark's size; a sleeping tab + // has no panes on screen to zoom, so the two never share a row. + let zoom_extra = if asleep { + zoom_extra + row_metrics::ZOOM + row_metrics::GAP + } else { + zoom_extra + }; // Elision is measured against this budget so the label and // branch never wrap or overflow into CSS truncation. let label_avail = @@ -973,6 +981,9 @@ impl Tty7App { s.text_color(cx.theme().sidebar_foreground) .hover(|s| s.bg(gpui::rgb(sf.hover))) }) + // Faded as well as marked, the way the strip's chip is: the + // tabs holding nothing should be the quiet ones in the column. + .when(asleep, |s| s.opacity(0.6)) .when(row_preview.as_ref().is_some_and(|p| p.from == slot), |s| { s.opacity(0.75) }) @@ -1021,6 +1032,9 @@ impl Tty7App { .when(zoomed, |row| { row.child(self.zoom_mark(("sidebar-zoom", i), cx)) }) + .when(asleep, |row| { + row.child(self.sleep_mark(("sidebar-asleep", i), cx)) + }) .child(label_region) .when(show_badges && badge_pos < 9, |row| { row.child( @@ -3991,6 +4005,7 @@ mod fold_tests { group: None, last_auto: Some(AutoKey::Repo(PathBuf::from("/w/alpha"))), root: tty7_core::core::machine::PaneNode::Leaf { pane: 1 }, + hibernated: false, }; app.tabs[i] = crate::ui::app::Tab::from_tree(&tree, pane); } diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs index d5f5a54f..bc8f4805 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -13,9 +13,10 @@ use unicode_segmentation::UnicodeSegmentation as _; use crate::core::actions::{ CloseActiveTab, CloseOtherTabs, CloseTabsToTheRight, CopyAgentSessionId, CopyWorkingDirectory, - ForkAgentSession, MarkTabUnread, NewWorktreeTab, OpenSettings, RenameTab, SelectWorkspace1, - SelectWorkspace2, SelectWorkspace3, SelectWorkspace4, SelectWorkspace5, SelectWorkspace6, - SelectWorkspace7, SelectWorkspace8, SelectWorkspace9, SplitDown, SplitRight, TogglePalette, + ForkAgentSession, HibernateTab, MarkTabUnread, NewWorktreeTab, OpenSettings, RenameTab, + SelectWorkspace1, SelectWorkspace2, SelectWorkspace3, SelectWorkspace4, SelectWorkspace5, + SelectWorkspace6, SelectWorkspace7, SelectWorkspace8, SelectWorkspace9, SplitDown, SplitRight, + TogglePalette, }; use crate::core::config::{Config, RightPanelTab}; use crate::core::shells::DetectedShell; @@ -684,17 +685,27 @@ pub(crate) fn chrome_tile_sized( /// closes the section is where the rest are. const MENU_HOSTS: usize = 6; +/// How many shells the New Tab menu names, the default among them. +/// +/// The same reasoning as [`MENU_HOSTS`], at a smaller number: a stock macOS box +/// reports nine shells and almost nobody opens more than one or two of them. +/// The default always leads — it is the answer to "what do I get if I just +/// click" — and the rest of the rows go to whatever has actually been opened, +/// by frecency. Everything else is one row away, in the palette. +const MENU_SHELLS: usize = 3; + /// How wide the New Tab menu is allowed to get. const MENU_W: Pixels = px(360.); /// How tall, before it starts scrolling. /// -/// Enough for the menu's own full hand — the nine shells a stock macOS box -/// reports, both headings, [`MENU_HOSTS`] hosts and the two rows that close the -/// list, at the 26px a row occupies — so the shape everyone actually sees -/// arrives whole. Past that (a pile of custom shells) it scrolls, and it is -/// capped again against the window in [`NewTabMenu::build`], since a menu taller -/// than what it hangs off is worse than one that scrolls. +/// Both lists are capped — [`MENU_SHELLS`] shells and [`MENU_HOSTS`] hosts — +/// so the menu's full hand is a fixed number of rows: those, both headings, the +/// row closing each section and the modifier hint, at the 26px a row occupies. +/// This leaves room above that for a seam row or two more, so the full hand +/// always arrives whole and never scrolls on its own. It is capped again +/// against the window in [`NewTabMenu::build`], since a menu taller than what +/// it hangs off is worse than one that scrolls. const MENU_H: Pixels = px(560.); /// What the row closing the SSH section types into the palette for you. @@ -715,6 +726,14 @@ const MENU_H: Pixels = px(560.); /// and opens the unfiltered command list has made the reader ask twice. const PALETTE_SSH_QUERY: &str = "ssh"; +/// What the row closing the Local section types into the palette for you. +/// +/// Every shell in the window's inventory is a palette command titled +/// `Shell: {label}` ([`L10nKey::AppCmdShellTitle`], the same word in every +/// language we ship, for the reason [`PALETTE_SSH_QUERY`] gives), so this one +/// word lands on exactly the shells, default first and then by frecency. +const PALETTE_SHELL_QUERY: &str = "shell"; + /// How this platform spells the key that turns a New Tab row into a split. fn split_modifier() -> &'static str { if cfg!(target_os = "macos") { @@ -733,8 +752,12 @@ fn split_modifier() -> &'static str { /// the window sat still is in the list the next time the `+` is pressed. struct NewTabMenu { app: gpui::WeakEntity, - shells: Vec<(SharedString, ShellSpec)>, + /// The shells the menu names, by label — at most [`MENU_SHELLS`]. + shells: Vec, default_shell: SharedString, + /// The inventory holds shells the menu does not name, so the section + /// closes with a row into the palette. + more_shells: bool, /// Saved host, its display name, and the `user@host:port` beside it — /// empty when the name already says it. hosts: Vec<(uuid::Uuid, SharedString, SharedString)>, @@ -760,21 +783,17 @@ impl NewTabMenu { // host with a descriptive name and a long `user@host` drags every // other row out with it and the menu stops looking like chrome. .max_w(MENU_W) - // Shells are whatever this machine has plus whatever the user - // added by hand, so the row count has no ceiling. Past the height + // Both lists are capped, but a short window is not. Past the height // of the window an un-scrollable menu simply loses its last rows — // and the last rows here are the SSH section. .scrollable(true) - // Only the overflow case should scroll, and the default ceiling is - // too low to tell the two apart: a stock macOS box has nine shells, - // which with both headings, the hosts and the two closing rows - // already runs past `PopupMenu`'s built-in 450px. The menu would - // arrive scrolled on every machine, with `Local` cut off above. + // Our own ceiling rather than `PopupMenu`'s built-in 450px, so the + // one that applies is the one [`MENU_H`] reasons about. .max_h(ceiling) .item(PopupMenuItem::label(t(L10nKey::TabMenuLocalShells))); - for (label, spec) in &self.shells { - let spec = spec.clone(); + for label in &self.shells { let app = self.app.clone(); + let open = label.clone(); let row = if *label == self.default_shell { let label = label.clone(); PopupMenuItem::element(move |_window, cx| { @@ -786,9 +805,7 @@ impl NewTabMenu { menu = menu.item(row.on_click(move |_, window, cx| { let at = SpawnWhere::from_modifiers(window.modifiers()); if let Some(app) = app.upgrade() { - app.update(cx, |this, cx| { - this.open_shell(Some(spec.clone()), at, window, cx) - }); + app.update(cx, |this, cx| this.open_listed_shell(&open, at, window, cx)); } })); } @@ -805,6 +822,22 @@ impl NewTabMenu { }, )); } + // The rest of the inventory is in the palette, already filtered to + // it — the same seam the SSH section closes with. Absent when the + // rows above are the whole inventory: a row into a list of nothing + // new would be one more thing to read for no gain. + if self.more_shells { + let app = self.app.clone(); + menu = menu.item(PopupMenuItem::new(t(L10nKey::TabMenuOtherShells)).on_click( + move |_, window, cx| { + if let Some(app) = app.upgrade() { + app.update(cx, |this, cx| { + this.open_palette(PALETTE_SHELL_QUERY, window, cx) + }); + } + }, + )); + } menu = menu .item(PopupMenuItem::separator()) @@ -850,6 +883,19 @@ impl NewTabMenu { }); } })); + // The agents' seam into the palette, the same way the row above is the + // hosts': the menu names none of them, the palette holds them all. + let app = self.app.clone(); + menu = menu.item(PopupMenuItem::new(t(L10nKey::TabMenuLaunchAgent)).on_click( + move |_, window, cx| { + if let Some(app) = app.upgrade() { + app.update(cx, |this, cx| { + let query = crate::ui::agent_launch::PALETTE_AGENT_QUERY; + this.open_palette(query, window, cx); + }); + } + }, + )); // The one place ⌥ is spelled out. Nothing else in the app teaches it, // and a modifier nobody is told about is a feature nobody has. No rule @@ -862,6 +908,55 @@ impl NewTabMenu { } } +/// Every shell in the inventory, most likely first: the default, then whatever +/// has been opened often and recently, then the rest in the inventory's own +/// order. Shared by the palette and the New Tab menu so the same shell leads +/// both lists. +pub(crate) fn shells_by_frecency<'a>( + shells: &'a [DetectedShell], + default: &str, + usage: &std::collections::HashMap, + now: u64, +) -> Vec<&'a DetectedShell> { + let score = |s: &DetectedShell| usage.get(&s.label).map_or(0.0, |u| u.score(now)); + let mut sorted: Vec<&DetectedShell> = shells.iter().collect(); + // Stable, so shells nobody has opened keep the order the inventory chose. + sorted.sort_by(|a, b| { + (b.label == default) + .cmp(&(a.label == default)) + .then_with(|| { + score(b) + .partial_cmp(&score(a)) + .unwrap_or(std::cmp::Ordering::Equal) + }) + }); + sorted +} + +/// The shells the menu names, off the front of [`shells_by_frecency`]'s list: +/// its head — the default, whenever the inventory has it — and then only +/// shells that have actually been opened, [`MENU_SHELLS`] rows in all. +/// +/// A shell nobody has opened is not named just because there is room: a menu +/// that filled its spare rows with `csh` and `tcsh` would be the nine-row list +/// again, only shorter. +fn menu_shells<'a>( + sorted: &[&'a DetectedShell], + default: &str, + usage: &std::collections::HashMap, + now: u64, +) -> Vec<&'a DetectedShell> { + sorted + .iter() + .take(MENU_SHELLS) + .enumerate() + .filter(|(i, s)| { + *i == 0 || s.label == default || usage.get(&s.label).is_some_and(|u| u.score(now) > 0.0) + }) + .map(|(_, s)| *s) + .collect() +} + /// The hosts the menu names, in the order they were handed over — frecency, /// so the ones that fit are the ones actually used. /// @@ -1485,6 +1580,24 @@ impl Tty7App { /// Drawn in the tab entry rather than on the pane so it reads from either /// tab surface, and so it says something about the tabs you are *not* /// looking at — the zoom outlives a switch away from them. + /// The mark a sleeping tab carries (#762), in the slot the zoom mark uses: + /// leading, beside the other state marks, where the pointer that comes to + /// read it does not cover it. + pub(crate) fn sleep_mark(&self, id: impl Into, cx: &App) -> gpui::AnyElement { + let tip = SharedString::from(t(L10nKey::TabTooltipAsleep)); + div() + .id(id) + .flex_shrink_0() + .flex() + .items_center() + .justify_center() + .size(px(16.)) + .text_color(cx.theme().muted_foreground) + .child(Icon::new(IconName::Moon).size(px(11.))) + .tooltip(move |window, cx| Tooltip::new(tip.clone()).build(window, cx)) + .into_any_element() + } + pub(crate) fn zoom_mark(&self, id: impl Into, cx: &App) -> gpui::AnyElement { let tip = chord_tooltip(t(L10nKey::TabTooltipZoomed), "ToggleMaximizePane", cx); div() @@ -1582,19 +1695,50 @@ impl Tty7App { /// What the menu offers, read off the app as the menu opens — the builder /// runs on the popup's own entity, so the rows carry a weak handle back. fn new_tab_menu_rows(&self, app: gpui::WeakEntity, cx: &App) -> NewTabMenu { + let default_shell = self.default_shell_label(cx); + let usage = &cx.global::().shell_frecency; + let now = crate::core::config::unix_now(); + let sorted = shells_by_frecency(&self.shells.shells, &default_shell, usage, now); + let shells = menu_shells(&sorted, &default_shell, usage, now); NewTabMenu { app, - shells: self - .shells - .shells - .iter() - .map(|s| (SharedString::from(s.label.clone()), shell_spec(s))) + more_shells: shells.len() < sorted.len(), + shells: shells + .into_iter() + .map(|s| SharedString::from(s.label.clone())) .collect(), - default_shell: SharedString::from(self.default_shell_label(cx)), + default_shell: SharedString::from(default_shell), hosts: menu_hosts(crate::ui::ssh_connect::ssh_profiles_by_frecency(cx)), } } + /// Open the inventory's shell by the label it is listed under, wherever + /// `at` says — the New Tab menu's rows and the palette's `Shell:` commands + /// both land here, so both count toward the frecency that orders them. + pub(crate) fn open_listed_shell( + &mut self, + label: &str, + at: SpawnWhere, + window: &mut Window, + cx: &mut Context, + ) { + let Some(spec) = self + .shells + .shells + .iter() + .find(|s| s.label == label) + .map(shell_spec) + else { + return; + }; + self.update_config(cx, |cfg| { + let entry = cfg.shell_frecency.entry(label.to_string()).or_default(); + entry.count = entry.count.saturating_add(1); + entry.last_used = crate::core::config::unix_now(); + }); + self.open_shell(Some(spec), at, window, cx); + } + pub(crate) fn tab_context_menu( menu: PopupMenu, index: usize, @@ -1646,6 +1790,34 @@ impl Tty7App { ); } + // Sleep and wake (#762). Waking is also what selecting the tab does; + // the item is here for waking one without leaving the tab on screen. + // Hibernate is offered only where the machine can do it, and greyed + // out for a tab it cannot be done to right now — the last one awake, + // or one still connecting — rather than vanishing from under the + // pointer that came looking for it. + if tab.is_some_and(|t| t.is_asleep()) { + menu = menu.item(PopupMenuItem::new(t(L10nKey::TabContextWake)).on_click({ + let app = app.clone(); + move |_, window, cx| { + let _ = app.update(cx, |this, cx| this.wake_tab(index, window, cx)); + } + })); + } else if crate::ui::tree_sync::can_hibernate_on(cx, this.workspace) { + menu = menu.item( + PopupMenuItem::new(t(L10nKey::TabContextHibernate)) + .action(Box::new(HibernateTab)) + .disabled(!this.can_hibernate_tab(index, cx)) + .on_click({ + let app = app.clone(); + move |_, window, cx| { + let _ = + app.update(cx, |this, cx| this.hibernate_tab(index, window, cx)); + } + }), + ); + } + // Where this tab sits, and where it could be put instead. // // Laid out flat rather than behind a "Move to Group ▸" submenu: there @@ -1945,7 +2117,8 @@ impl Tty7App { let label = self.tab_label(tab, i, Some(window), cx); let full_title = self.tab_title_tooltip(tab, i, Some(window), cx); let ssh_dot = self.tab_ssh_dot(tab, cx); - let agent = tab.agent(cx); + let asleep = tab.is_asleep(); + let agent = tab.agent(cx).or_else(|| tab.asleep_agent()); let agent_status = tab.agent_status(cx); let agent_unread = tab.agent_unread_count(cx); let zoomed = self.tab_is_zoomed(i); @@ -2025,6 +2198,9 @@ impl Tty7App { s.text_color(cx.theme().muted_foreground) .hover(|s| s.bg(cx.theme().muted)) }) + // Faded as well as marked: a row of chips is read at a glance, + // and the tabs holding nothing should be the quiet ones. + .when(asleep, |s| s.opacity(0.6)) .when(dragged, |s| s.opacity(0.75)) .child( canvas( @@ -2083,6 +2259,9 @@ impl Tty7App { .when(zoomed, |chip| { chip.child(self.zoom_mark(("tab-zoom", i), cx)) }) + .when(asleep, |chip| { + chip.child(self.sleep_mark(("tab-asleep", i), cx)) + }) .child(label_region) .when(show_badges && i < 9, |chip| { chip.child( @@ -2421,6 +2600,84 @@ mod ssh_host_row_tests { assert_eq!(spec.port, 2222, "a non-default port is part of the address"); }); } + + /// #726 end to end: the setting reaches the name the strip draws, on the + /// title's rung — below a name the user gave the tab, above whatever the + /// remote shell titled itself — and only for an SSH pane. + #[gpui::test] + fn the_ssh_tab_title_setting_pins_only_ssh_tabs_and_only_their_label(cx: &mut TestAppContext) { + use crate::core::config::SshTabTitle; + + set_locale("en"); + let (app, mut vcx) = harness(cx); + let saved = uuid::Uuid::new_v4(); + let set_mode = |cx: &mut gpui::App, mode: SshTabTitle| { + let mut cfg = cx.global::().clone(); + cfg.ssh_tab_title = mode; + cx.set_global(cfg); + }; + + let (_ends, ssh) = app.update_in(&mut vcx, |app, window, cx| { + let mut cfg = cx.global::().clone(); + let mut profile = SshProfile::new("prod-web"); + profile.id = saved; + profile.user = "me".to_string(); + profile.host = "build-box".to_string(); + cfg.ssh_profiles = vec![profile]; + cx.set_global(cfg); + + let (local, a) = quiet_test_pane(1, window, cx); + let (ssh, b) = quiet_test_ssh_pane_of(2, Some(saved), window, cx); + // Both have titled themselves over OSC 0/2 the way a shell does. + local.update(cx, |v, _| v.title = "me@laptop:/work/here".into()); + ssh.update(cx, |v, _| v.title = "me@build-box:/srv/app".into()); + for view in [local, ssh.clone()] { + app.tabs.push(Tab::new(Pane::leaf(PaneSlot::Ready(view)))); + } + app.active = 0; + ((a, b), ssh) + }); + vcx.background_executor.run_until_parked(); + + app.update_in(&mut vcx, |app, window, cx| { + let label = + |app: &crate::ui::app::Tty7App, i: usize, window: &gpui::Window, cx: &gpui::App| { + app.tab_label(&app.tabs[i], i, Some(window), cx) + }; + let dynamic_local = label(app, 0, window, cx); + let dynamic_ssh = label(app, 1, window, cx); + assert_eq!(dynamic_ssh, "/srv/app", "Dynamic is what tty7 always did"); + + set_mode(cx, SshTabTitle::ProfileName); + assert_eq!(label(app, 1, window, cx), "prod-web"); + assert_eq!( + label(app, 0, window, cx), + dynamic_local, + "a local tab is untouched" + ); + assert_eq!( + ssh.read(cx).title, + "me@build-box:/srv/app", + "the remote title is still tracked underneath" + ); + + set_mode(cx, SshTabTitle::Hostname); + assert_eq!(label(app, 1, window, cx), "build-box"); + + // A name the user gave the tab outranks the setting. + app.tabs[1].name = Some("mine".into()); + assert_eq!(label(app, 1, window, cx), "mine"); + app.tabs[1].name = None; + + // An ended session keeps saying so under the pinned name. + ssh.update(cx, |v, _| v.terminal.exited = true); + assert_eq!(label(app, 1, window, cx), "build-box — process exited"); + ssh.update(cx, |v, _| v.terminal.exited = false); + + set_mode(cx, SshTabTitle::Dynamic); + assert_eq!(label(app, 1, window, cx), dynamic_ssh); + }); + } } #[cfg(test)] @@ -3276,4 +3533,131 @@ mod tests { assert_eq!(label_of(&root, 0, Some(home())), "/"); assert_eq!(tooltip_of(&root, 0, Some(home())), None); } + + fn inventory(labels: &[&str]) -> Vec { + labels + .iter() + .map(|l| DetectedShell { + label: l.to_string(), + program: format!("/bin/{l}"), + args: Vec::new(), + args_are_tty7_defaults: true, + user_authored: false, + }) + .collect() + } + + fn used( + entries: &[(&str, u32, u64)], + ) -> std::collections::HashMap { + entries + .iter() + .map(|(l, count, last_used)| { + ( + l.to_string(), + crate::core::config::ProfileUsage { + count: *count, + last_used: *last_used, + }, + ) + }) + .collect() + } + + fn labels(shells: &[&DetectedShell]) -> Vec { + shells.iter().map(|s| s.label.clone()).collect() + } + + const STOCK_MAC: &[&str] = &["zsh", "bash", "sh", "csh", "tcsh", "ksh", "dash"]; + + #[test] + fn the_new_tab_menu_names_the_default_and_what_has_been_opened_by_frecency() { + let now = 100_000_000u64; + let day = 86_400u64; + let shells = inventory(STOCK_MAC); + // `bash` used often and lately; `dash` used more but a year ago; + // `ksh` once, two months back; and the default opened too. + let usage = used(&[ + ("zsh", 50, now), + ("ksh", 1, now - 60 * day), + ("bash", 9, now - day), + ("dash", 20, now - 365 * day), + ]); + let sorted = shells_by_frecency(&shells, "zsh", &usage, now); + assert_eq!( + labels(&sorted), + ["zsh", "bash", "dash", "ksh", "sh", "csh", "tcsh"], + "default first, then by frecency, then the inventory's own order" + ); + let menu = menu_shells(&sorted, "zsh", &usage, now); + assert_eq!(menu.len(), MENU_SHELLS); + assert_eq!(labels(&menu), ["zsh", "bash", "dash"]); + } + + #[test] + fn the_default_shell_leads_the_menu_even_when_it_has_never_been_opened() { + let now = 100_000_000u64; + let shells = inventory(STOCK_MAC); + let usage = used(&[("tcsh", 3, now), ("sh", 7, now), ("ksh", 5, now)]); + let sorted = shells_by_frecency(&shells, "bash", &usage, now); + let menu = menu_shells(&sorted, "bash", &usage, now); + assert_eq!(labels(&menu), ["bash", "sh", "ksh"]); + } + + #[test] + fn a_shell_nobody_has_opened_is_not_named_just_because_there_is_room() { + // A fresh install: nothing has been opened, so the menu names the + // default alone and leaves the rest to the palette. + let now = 100_000_000u64; + let shells = inventory(STOCK_MAC); + let none = used(&[]); + let sorted = shells_by_frecency(&shells, "zsh", &none, now); + assert_eq!(labels(&menu_shells(&sorted, "zsh", &none, now)), ["zsh"]); + + // A usage record at zero counts as never opened. + let zeroed = used(&[("bash", 0, now)]); + let sorted = shells_by_frecency(&shells, "zsh", &zeroed, now); + assert_eq!(labels(&menu_shells(&sorted, "zsh", &zeroed, now)), ["zsh"]); + + // An inventory that does not list the default still offers its head, + // which is the shell a new tab opens with. + let sorted = shells_by_frecency(&shells, "fish", &none, now); + assert_eq!(labels(&menu_shells(&sorted, "fish", &none, now)), ["zsh"]); + } + + #[test] + fn other_shells_is_offered_only_when_the_inventory_holds_more_than_the_menu_names() { + let now = 100_000_000u64; + let more = |inv: &[&str], usage: &[(&str, u32, u64)]| { + let shells = inventory(inv); + let usage = used(usage); + let default = inv.first().copied().unwrap_or(""); + let sorted = shells_by_frecency(&shells, default, &usage, now); + menu_shells(&sorted, default, &usage, now).len() < sorted.len() + }; + assert!(more(STOCK_MAC, &[]), "a stock box names one of seven"); + assert!( + !more(&["pwsh"], &[]), + "the default alone is the whole inventory" + ); + assert!( + !more(&["zsh", "bash"], &[("bash", 1, now)]), + "every shell is already a row" + ); + assert!( + !more( + &["zsh", "bash", "fish"], + &[("bash", 1, now), ("fish", 2, now)] + ), + "three shells, all named" + ); + assert!( + more(&["zsh", "bash", "fish"], &[("bash", 1, now)]), + "`fish` has never been opened, so it is left to the palette" + ); + assert!( + !more(&[], &[]), + "no inventory: the fallback row, nothing more" + ); + } } diff --git a/src/ui/tree_sync.rs b/src/ui/tree_sync.rs index 1d000dc6..5fc8931c 100644 --- a/src/ui/tree_sync.rs +++ b/src/ui/tree_sync.rs @@ -51,6 +51,18 @@ fn classify_tree_link(client: Option>) -> TreeLink { } } +/// Whether the machine holding `client_ws` can put one of its tabs to sleep. +/// A machine that cannot would take the mark and leave the shells running, or +/// refuse it outright, so a window never offers to on its behalf. +pub(crate) fn can_hibernate_on(cx: &App, client_ws: WorkspaceId) -> bool { + let feature = tty7_core::daemon::control::feature::TAB_HIBERNATE; + let host = WorkspaceStore::host_of(cx, client_ws); + match host.is_local() { + true => crate::ui::local_link::LocalLink::supports(cx, feature), + false => crate::ui::remote_connect::HostLinks::peer_supports(cx, host, feature), + } +} + fn tree_workspace_id(cx: &App, client_ws: WorkspaceId) -> WorkspaceId { WorkspaceStore::all(cx) .get(client_ws) @@ -66,6 +78,9 @@ pub(crate) struct DesiredTab { pub group: Option, pub last_auto: Option, pub root: DesiredNode, + /// The tab is asleep, and `root` is the layout it will wake into — the + /// same pane ids the machine already holds, none of them running. + pub hibernated: bool, } #[derive(Debug, Clone)] @@ -121,7 +136,15 @@ pub(crate) fn desired_tabs( let mut active = None; let mut held = Vec::new(); for (index, tab) in app.tabs.iter().enumerate() { - let Some(root) = desired_node(&tab.pane, remote, cx) else { + // A sleeping tab has nothing on screen to read its layout off, so it + // is read off what it will wake into: the same panes, by the same ids, + // which is what keeps the diff below from taking it for a tab that + // lost them all. + let root = match tab.asleep_layout() { + Some(layout) => desired_node_from_session(layout, remote), + None => desired_node(&tab.pane, remote, cx), + }; + let Some(root) = root else { if !(remote && every_leaf_is_native_ssh(&tab.pane, cx)) { held.push(tab.tree_id.get()); } @@ -141,11 +164,66 @@ pub(crate) fn desired_tabs( group: tab.group.get(), last_auto: tab.auto_group.borrow().clone(), root, + hibernated: tab.asleep_layout().is_some(), }); } (out, active, held) } +/// [`desired_node`] for a layout that is only a record — a sleeping tab's. +/// A leaf with no id is one the machine never had, and a native SSH leaf in a +/// remote window lives on this client rather than on the window's machine; +/// neither is the machine's to hold, exactly as for a live tab. +fn desired_node_from_session(layout: &SessionPane, remote_window: bool) -> Option { + match layout { + SessionPane::Leaf { + cwd, + pane_id, + shell, + ssh_spec, + agent, + agent_session_id, + agent_launch_argv, + } => { + let pane = (*pane_id)?; + if remote_window && ssh_spec.is_some() { + return None; + } + Some(DesiredNode::Leaf { + pane, + seed: PaneSeed { + pane, + cwd: cwd.as_ref().map(|p| p.to_string_lossy().into_owned()), + ssh_spec: ssh_spec.clone(), + agent: agent.map(|agent| AgentFacts { + agent, + session_id: agent_session_id.clone(), + launch_argv: agent_launch_argv.clone(), + status: None, + }), + shell: shell.clone(), + }, + }) + } + SessionPane::Split { axis, ratio, a, b } => { + let left = desired_node_from_session(a, remote_window); + let right = desired_node_from_session(b, remote_window); + match (left, right) { + (Some(a), Some(b)) => Some(DesiredNode::Split { + axis: match axis { + crate::core::session::SessionAxis::Horizontal => TreeAxis::Horizontal, + crate::core::session::SessionAxis::Vertical => TreeAxis::Vertical, + }, + ratio: *ratio, + a: Box::new(a), + b: Box::new(b), + }), + (one, other) => one.or(other), + } + } + } +} + fn every_leaf_is_native_ssh(pane: &Pane, cx: &App) -> bool { match pane { Pane::Leaf(PaneSlot::Ready(view)) => view.read(cx).ssh_spec().is_some(), @@ -512,6 +590,13 @@ fn create_tab( last_auto: want.last_auto.clone(), }); } + if want.hibernated { + ops.push(ControlRequest::TabSetHibernated { + workspace, + tab: want.id, + hibernated: true, + }); + } mirror.tabs.insert( index.min(mirror.tabs.len()), TreeTab { @@ -520,6 +605,7 @@ fn create_tab( group: want.group, last_auto: want.last_auto.clone(), root, + hibernated: want.hibernated, }, ); mirror.active = Some(want.id); @@ -579,6 +665,18 @@ fn reconcile_tab( last_auto: want.last_auto.clone(), }); } + // Ahead of anything structural. Going to sleep changes nothing else + // about the tab; waking swaps every pane for its successor, which may + // take a close-and-recreate below — and the mark has to come off the + // tab that exists now, before that one is gone. + if tab.hibernated != want.hibernated { + tab.hibernated = want.hibernated; + ops.push(ControlRequest::TabSetHibernated { + workspace, + tab: want.id, + hibernated: want.hibernated, + }); + } } let desired_root = want.root.to_pane_node(); @@ -1685,15 +1783,19 @@ pub(crate) fn session_from_tree( ws: &tty7_core::core::machine::Workspace, panes: &[PaneRecord], ) -> Session { + let views = tty7_core::core::tab_view::tab_views_of(ws, panes); let tabs: Vec = ws .tabs .iter() - .map(|tab| SessionTab { + .zip(views) + .map(|(tab, view)| SessionTab { name: tab.name.clone(), tree_id: Some(tab.id), group: tab.group, last_auto: tab.last_auto.clone(), pane: session_pane_from_node(&tab.root, panes), + hibernated: tab.hibernated, + asleep_view: tab.hibernated.then_some(view), }) .collect(); let active = ws @@ -2761,6 +2863,13 @@ impl Tty7App { } true } + // A tab arriving asleep, or one that someone else put to sleep or + // woke, is not a shape to graft onto this window: building it here + // would spawn what is meant to stay stopped, or leave running what + // was just stopped. Saying the delta did not apply re-pulls the + // tree, and the restore that follows is the one path that already + // knows what a sleeping tab is. + LayoutDelta::TabCreated { tab, .. } if tab.hibernated => false, LayoutDelta::TabCreated { at, tab } => { self.insert_tab_from_tree((*at).min(self.tabs.len()), tab, window, cx) } @@ -2772,6 +2881,7 @@ impl Tty7App { .and_then(|id| index_of(&self.tabs, id)) .unwrap_or_else(|| index.min(self.tabs.len().saturating_sub(1))); self.maximized = None; + self.wake_active_if_asleep(window, cx); self.focus_active(window, cx); } true @@ -2819,6 +2929,7 @@ impl Tty7App { true } LayoutDelta::TabRestructured { tab, .. } => match index_of(&self.tabs, tab.id) { + Some(index) if tab.hibernated || self.tabs[index].is_asleep() => false, Some(index) => self.rebuild_tab_from_tree(index, tab, window, cx), None => false, }, @@ -2845,6 +2956,7 @@ impl Tty7App { } self.maximized = None; self.active = index; + self.wake_active_if_asleep(window, cx); self.focus_active(window, cx); } @@ -3578,6 +3690,7 @@ mod tests { group: None, last_auto: None, root: PaneNode::Leaf { pane: 1 }, + hibernated: false, }; assert!( @@ -4214,6 +4327,7 @@ mod tests { group: None, last_auto: None, root: PaneNode::Leaf { pane: 1 }, + hibernated: false, }, TreeTab { id: failed, @@ -4221,6 +4335,7 @@ mod tests { group: None, last_auto: None, root: PaneNode::Leaf { pane: 2 }, + hibernated: false, }, ], active: Some(put_up), @@ -4307,6 +4422,7 @@ mod tests { group: None, last_auto: None, root: PaneNode::Leaf { pane: 11 }, + hibernated: false, }, TreeTab { id: theirs.1, @@ -4314,6 +4430,7 @@ mod tests { group: None, last_auto: None, root: PaneNode::Leaf { pane: 12 }, + hibernated: false, }, ], active: Some(theirs.0), @@ -4450,6 +4567,7 @@ mod tests { group: None, last_auto: None, root, + hibernated: false, } } @@ -5117,6 +5235,7 @@ mod tests { group: None, last_auto: None, root: PaneNode::Leaf { pane: 1 }, + hibernated: false, }; assert!(apply_to_mirror( &mut watcher, @@ -5143,6 +5262,7 @@ mod tests { a: Box::new(PaneNode::Leaf { pane: 1 }), b: Box::new(PaneNode::Leaf { pane: 2 }), }, + hibernated: false, }, pane: None, }, @@ -5213,6 +5333,7 @@ mod tests { a: Box::new(PaneNode::Leaf { pane: 1 }), b: Box::new(PaneNode::Leaf { pane: 2 }), }, + hibernated: false, }], active_tab: Some(tab_id), ..Default::default() @@ -5309,6 +5430,7 @@ mod tests { group: None, last_auto: None, root: PaneNode::Leaf { pane: 7 }, + hibernated: false, }], active_tab: Some(tab_id), ..Default::default() @@ -5339,6 +5461,7 @@ mod tests { group: None, last_auto: None, root: PaneNode::Leaf { pane: 1 }, + hibernated: false, }], active_tab: Some(TabId::new()), ..Default::default() @@ -5368,4 +5491,249 @@ mod tests { "got {ops:?}" ); } + + fn asleep(id: TabId, root: DesiredNode) -> DesiredTab { + DesiredTab { + hibernated: true, + ..tab(id, root) + } + } + + /// Going to sleep changes nothing about the tab's shape — the same panes, + /// by the same ids — so the only thing the machine hears is the mark. A + /// tab that lost its panes would have been a close, and the close would + /// have taken every record the wake needs with it. + #[test] + fn a_tab_going_to_sleep_says_so_and_nothing_else() { + let ws = WorkspaceId::new(); + let id = TabId::new(); + let mut mirror = WsMirror::default(); + let root = || split(TreeAxis::Vertical, 0.5, leaf(1), leaf(2)); + diff( + ws, + &mut mirror, + &[tab(id, root())], + Some(id), + SyncScope::Full, + &[], + ); + + let ops = diff( + ws, + &mut mirror, + &[asleep(id, root())], + Some(id), + SyncScope::Full, + &[], + ); + assert_eq!( + ops, + vec![ControlRequest::TabSetHibernated { + workspace: ws, + tab: id, + hibernated: true, + }] + ); + assert!(mirror.tabs[0].hibernated); + assert!( + diff( + ws, + &mut mirror, + &[asleep(id, root())], + Some(id), + SyncScope::Full, + &[] + ) + .is_empty(), + "a tab that stays asleep is not told again" + ); + } + + /// Waking swaps each stopped pane for its successor. The mark comes off + /// first, while the tab it belongs to is certainly still the one the + /// machine has. + #[test] + fn a_waking_tab_clears_its_mark_before_its_panes_are_replaced() { + let ws = WorkspaceId::new(); + let id = TabId::new(); + let mut mirror = WsMirror::default(); + diff( + ws, + &mut mirror, + &[asleep(id, leaf(1))], + Some(id), + SyncScope::Full, + &[], + ); + + let want = vec![tab(id, leaf(9))]; + let ops = diff(ws, &mut mirror, &want, Some(id), SyncScope::Full, &[]); + assert_eq!( + ops, + vec![ + ControlRequest::TabSetHibernated { + workspace: ws, + tab: id, + hibernated: false, + }, + ControlRequest::PaneReplace { + workspace: ws, + old: 1, + new: seed(9), + }, + ] + ); + assert!(!mirror.tabs[0].hibernated); + assert_converged(&mirror, &want); + } + + /// A window writing a sleeping tab onto a machine that lost it — a store + /// wiped under a remote workspace — writes it back asleep, not as a tab + /// the next restore would spawn. + #[test] + fn a_tab_created_asleep_is_marked_as_it_is_created() { + let ws = WorkspaceId::new(); + let id = TabId::new(); + let mut mirror = WsMirror::default(); + let ops = diff( + ws, + &mut mirror, + &[asleep(id, leaf(4))], + Some(id), + SyncScope::Full, + &[], + ); + assert!( + matches!(ops.first(), Some(ControlRequest::TabCreate { .. })), + "{ops:?}" + ); + assert!( + ops.contains(&ControlRequest::TabSetHibernated { + workspace: ws, + tab: id, + hibernated: true, + }), + "{ops:?}" + ); + assert!(mirror.tabs[0].hibernated); + } + + /// The window reads a sleeping tab's layout off the record it kept, and + /// has to hand the machine the very tab the machine already holds: same + /// ids, same seeds, same shape. + #[test] + fn a_sleeping_layout_reads_back_as_the_tree_it_came_from() { + use crate::core::session::SessionAxis; + let leaf_of = |pane: u64, cwd: &str| SessionPane::Leaf { + cwd: Some(std::path::PathBuf::from(cwd)), + pane_id: Some(pane), + shell: None, + ssh_spec: None, + agent: Some(crate::core::cli_agent::CLIAgent::Claude), + agent_session_id: Some(format!("sess-{pane}")), + agent_launch_argv: None, + }; + let layout = SessionPane::Split { + axis: SessionAxis::Horizontal, + ratio: 0.3, + a: Box::new(leaf_of(3, "/a")), + b: Box::new(leaf_of(4, "/b")), + }; + let node = desired_node_from_session(&layout, false).expect("both leaves have ids"); + assert_eq!( + node.to_pane_node(), + PaneNode::Split { + axis: TreeAxis::Horizontal, + ratio: 0.3, + a: Box::new(PaneNode::Leaf { pane: 3 }), + b: Box::new(PaneNode::Leaf { pane: 4 }), + } + ); + let seed = node.seed_of(4).expect("pane 4 is a leaf"); + assert_eq!(seed.cwd.as_deref(), Some("/b")); + assert_eq!( + seed.agent.as_ref().and_then(|a| a.session_id.as_deref()), + Some("sess-4"), + "the session a wake resumes stays on the record" + ); + + // A leaf the machine never had is left out, as for a live tab. + let unknown = SessionPane::Split { + axis: SessionAxis::Vertical, + ratio: 0.5, + a: Box::new(leaf_of(3, "/a")), + b: Box::new(SessionPane::Leaf { + cwd: None, + pane_id: None, + shell: None, + ssh_spec: None, + agent: None, + agent_session_id: None, + agent_launch_argv: None, + }), + }; + assert_eq!( + desired_node_from_session(&unknown, false).map(|n| n.to_pane_node()), + Some(PaneNode::Leaf { pane: 3 }) + ); + } + + /// A restore reads the mark off the tree: the sleeping tab keeps its pane + /// ids — they are what the wake restores from — and comes with a name read + /// off the tree's records, since nothing in it will be running to say one. + #[test] + fn a_restore_brings_a_sleeping_tab_back_asleep_and_named() { + let (awake, sleeping) = (TabId::new(), TabId::new()); + let ws = tty7_core::core::machine::Workspace { + tabs: vec![ + TreeTab { + id: awake, + name: None, + group: None, + last_auto: None, + root: PaneNode::Leaf { pane: 1 }, + hibernated: false, + }, + TreeTab { + id: sleeping, + name: None, + group: None, + last_auto: None, + root: PaneNode::Leaf { pane: 2 }, + hibernated: true, + }, + ], + active_tab: Some(awake), + ..Default::default() + }; + let panes = vec![ + PaneRecord { + cwd: Some("/work".into()), + live: true, + ..PaneRecord::new(1) + }, + PaneRecord { + cwd: Some("/work/api".into()), + osc_title: Some("fixing the switcher".into()), + live: false, + ..PaneRecord::new(2) + }, + ]; + + let session = session_from_tree(&ws, &panes); + assert!(!session.tabs[0].hibernated); + assert!(session.tabs[0].asleep_view.is_none()); + let tab = &session.tabs[1]; + assert!(tab.hibernated); + match &tab.pane { + SessionPane::Leaf { pane_id, cwd, .. } => { + assert_eq!(*pane_id, Some(2)); + assert_eq!(cwd.as_deref(), Some(std::path::Path::new("/work/api"))); + } + _ => panic!("leaf"), + } + let view = tab.asleep_view.as_ref().expect("a sleeping tab is named"); + assert_eq!(view.osc_title.as_deref(), Some("fixing the switcher")); + assert!(!view.live); + } }