diff --git a/crates/tty7-cli/src/cli.rs b/crates/tty7-cli/src/cli.rs index 408f523c..b658888f 100644 --- a/crates/tty7-cli/src/cli.rs +++ b/crates/tty7-cli/src/cli.rs @@ -38,7 +38,7 @@ pub struct Cli { #[arg( value_name = "PATH", - help = "Launch or activate the GUI, at PATH if given (not wired up yet)" + help = "Launch or activate the GUI, opening a new tab at PATH if given" )] pub path: Option, diff --git a/crates/tty7-cli/src/commands.rs b/crates/tty7-cli/src/commands.rs index 1def95f4..da553db4 100644 --- a/crates/tty7-cli/src/commands.rs +++ b/crates/tty7-cli/src/commands.rs @@ -1,4 +1,4 @@ -use anyhow::{Result, bail}; +use anyhow::{Context as _, Result, bail}; use serde_json::{Value, json}; use tty7_core::core::machine::{Axis, Machine, PaneSeed, Workspace}; use tty7_core::core::session::WorkspaceId; @@ -46,14 +46,13 @@ pub fn execute(cli: Cli, ctx: &Context, backend: &mut dyn Backend) -> Result match cli.path { // clap has no subcommand for this word, so it landed in [PATH]. // Treat a word that is not a path as the typo it almost certainly - // is: answering "launching the GUI is not wired up yet" to - // `tty7 statu` helps nobody. + // is: launching the GUI for `tty7 statu` would hide the typo. Some(word) if !looks_like_a_path(&word) => bail!( "unknown subcommand '{word}' — run `tty7 --help` for the list. \ (A path in this position would open the GUI there, but \ '{word}' does not name one.)" ), - path => launch_gui(path), + path => launch_gui(path, machine.as_deref(), backend), }, Some(Command::Ls) | Some(Command::Ws(WsCmd::Ls)) => ws_ls(backend), Some(Command::Ws(WsCmd::Tree { ws })) => ws_tree(ws.as_deref(), ctx, backend), @@ -140,11 +139,84 @@ fn looks_like_a_path(s: &str) -> bool { || std::path::Path::new(s).exists() } -fn launch_gui(path: Option) -> Result { - match path { - Some(p) => bail!("launching the GUI is not wired up yet (would open {p})"), - None => bail!("launching the GUI is not wired up yet"), +fn launch_gui( + path: Option, + machine: Option<&str>, + backend: &mut dyn Backend, +) -> Result { + if let Some(machine) = machine { + bail!( + "`tty7 [PATH]` controls the GUI on this machine and cannot be combined with -m {machine}" + ); } + + let path = path.map(resolve_gui_path).transpose()?; + let wire_path = path.as_deref().map(gui_wire_path).transpose()?; + // A live GUI receives the request through the daemon. If the daemon itself + // is absent, the same fallback as "no GUI registered" starts the app, which + // will start its daemon during normal initialization. + let delivered = match backend.control(ControlRequest::GuiOpen { + path: wire_path.clone(), + }) { + Ok(ReplyOk::Bool(delivered)) => delivered, + Ok(other) => bail!("the server answered GuiOpen with {other:?}"), + Err(_) => false, + }; + + if !delivered { + crate::gui::launch(path.as_deref())?; + } + report( + "", + json!({ + "path": wire_path, + "delivered": delivered, + "launched": !delivered, + }), + ) +} + +fn gui_wire_path(path: &std::path::Path) -> Result { + let Some(path_text) = path.to_str() else { + bail!( + "cannot open {} through the GUI protocol because the path is not valid UTF-8", + path.display() + ); + }; + Ok(path_text.to_owned()) +} + +fn resolve_gui_path(raw: String) -> Result { + let expanded = expand_home(&raw).unwrap_or_else(|| std::path::PathBuf::from(&raw)); + // Do not canonicalize here: preserving the caller's junction or symlink + // spelling keeps shell cwd reporting and tab labels consistent. + let path = if expanded.is_absolute() { + expanded + } else { + std::env::current_dir() + .context("reading the current directory")? + .join(expanded) + }; + let metadata = + std::fs::metadata(&path).with_context(|| format!("opening {}", path.display()))?; + if !metadata.is_dir() { + bail!("{} is not a directory", path.display()); + } + Ok(path) +} + +fn expand_home(raw: &str) -> Option { + let rest = raw.strip_prefix("~/").or_else(|| raw.strip_prefix("~\\")); + if raw != "~" && rest.is_none() { + return None; + } + let home = std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(std::path::PathBuf::from)?; + Some(match rest { + Some(rest) => home.join(rest), + None => home, + }) } fn fetch_machine(backend: &mut dyn Backend) -> Result { @@ -1651,14 +1723,68 @@ mod tests { assert!(msg.contains("unknown subcommand"), "{typo}: {msg}"); assert!(msg.contains(typo), "{typo}: {msg}"); } + } - // Things that do look like paths still reach the GUI launcher, and fail - // there for the honest reason. - for path in ["/tmp", "./src", "~/proj", "a/b"] { - let err = execute(cli(&["tty7", path]), &Context::default(), &mut mock()) - .expect_err("the GUI launcher is not implemented yet"); - assert!(err.to_string().contains("not wired up"), "{path}: {err}"); - } + #[test] + fn a_path_asks_the_running_gui_to_open_an_absolute_directory() { + let mut backend = mock(); + backend.replies.push_back(ReplyOk::Bool(true)); + let out = run_cli(&["tty7", "."], &Context::default(), &mut backend); + let expected = std::env::current_dir() + .unwrap() + .join(".") + .to_str() + .unwrap() + .to_owned(); + assert_eq!( + backend.control_calls, + vec![ControlRequest::GuiOpen { + path: Some(expected.clone()) + }] + ); + let Outcome::Report(report) = out else { + panic!("GUI open is a regular report"); + }; + assert_eq!(report.json["path"], expected); + assert_eq!(report.json["delivered"], true); + assert_eq!(report.json["launched"], false); + } + + #[test] + fn an_invalid_gui_path_fails_before_touching_the_wire() { + let mut backend = mock(); + let missing = + std::env::temp_dir().join(format!("tty7-cli-missing-path-{}", std::process::id())); + let arg = missing.to_str().unwrap().to_owned(); + let err = execute(cli(&["tty7", &arg]), &Context::default(), &mut backend) + .expect_err("a missing directory must be rejected"); + assert!(err.to_string().contains("opening"), "{err:#}"); + assert!(backend.control_calls.is_empty()); + } + + #[cfg(unix)] + #[test] + fn a_non_utf8_gui_path_is_rejected_instead_of_changed() { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt as _; + + let path = std::env::temp_dir().join(OsString::from_vec(b"tty7-\xff".to_vec())); + let error = gui_wire_path(&path).expect_err("the string protocol cannot preserve bytes"); + + assert!(error.to_string().contains("not valid UTF-8"), "{error:#}"); + } + + #[test] + fn the_gui_launcher_is_local_only() { + let mut backend = mock(); + let err = execute( + cli(&["tty7", "-m", "devbox", "."]), + &Context::default(), + &mut backend, + ) + .expect_err("a local GUI request cannot be routed to another machine"); + assert!(err.to_string().contains("cannot be combined"), "{err:#}"); + assert!(backend.control_calls.is_empty()); } #[test] diff --git a/crates/tty7-cli/src/gui.rs b/crates/tty7-cli/src/gui.rs new file mode 100644 index 00000000..a8942056 --- /dev/null +++ b/crates/tty7-cli/src/gui.rs @@ -0,0 +1,81 @@ +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use anyhow::{Context as _, Result, bail}; + +pub fn launch(path: Option<&Path>) -> Result<()> { + let executable = find_executable()?; + let mut command = Command::new(&executable); + if let Some(path) = path { + command.arg("--open-path").arg(path); + } + + // The CLI must not keep a caller's redirected pipes alive after it exits. + // The GUI owns its own logging and never needs this console's standard IO. + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .with_context(|| format!("launching {}", executable.display()))?; + Ok(()) +} + +fn find_executable() -> Result { + if let Some(explicit) = std::env::var_os("TTY7_APP") { + let path = PathBuf::from(explicit); + if path.is_file() { + return Ok(path); + } + bail!( + "TTY7_APP points to {}, but that file does not exist", + path.display() + ); + } + + let name = executable_name(); + if let Ok(own) = std::env::current_exe() + && let Some(dir) = own.parent() + { + let sibling = dir.join(name); + if sibling.is_file() { + return Ok(sibling); + } + } + + if let Some(paths) = std::env::var_os("PATH") { + for dir in std::env::split_paths(&paths) { + let candidate = dir.join(name); + if candidate.is_file() { + return Ok(candidate); + } + } + } + + bail!("could not find {name} next to this CLI or on PATH — install tty7-app, or set TTY7_APP") +} + +fn executable_name() -> &'static str { + if cfg!(windows) { + "tty7-app.exe" + } else { + "tty7-app" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_gui_executable_name_matches_the_platform() { + assert_eq!( + executable_name(), + if cfg!(windows) { + "tty7-app.exe" + } else { + "tty7-app" + } + ); + } +} diff --git a/crates/tty7-cli/src/main.rs b/crates/tty7-cli/src/main.rs index 86051837..efb2ad90 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 gui; mod output; mod resolve; mod screen; diff --git a/crates/tty7-core/src/daemon/control.rs b/crates/tty7-core/src/daemon/control.rs index 62e66a9d..adc5e376 100644 --- a/crates/tty7-core/src/daemon/control.rs +++ b/crates/tty7-core/src/daemon/control.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use super::protocol::{MAX_FRAME, read_frame, write_frame}; -pub const CONTROL_VERSION: u32 = 4; +pub const CONTROL_VERSION: u32 = 5; const DIALECT_MARKER: &str = "speaks control v"; @@ -152,6 +152,10 @@ pub enum ControlRequest { id: String, }, + GuiOpen { + path: Option, + }, + MachineGet, WorkspaceTree { workspace: WorkspaceId, @@ -285,6 +289,7 @@ impl ControlRequest { Git { .. } | GitStream { .. } | Search { .. } => Duration::from_secs(20), Shells => Duration::from_secs(20), WorkspaceAttach { .. } | WorkspaceDetach { .. } => Duration::from_secs(10), + GuiOpen { .. } => Duration::from_secs(5), MachineGet | WorkspaceTree { .. } | WorkspaceCreate { .. } @@ -467,6 +472,9 @@ pub enum ControlEvent { workspace: String, by: String, }, + GuiOpen { + path: Option, + }, Layout { workspace: String, delta: LayoutDelta, @@ -501,6 +509,8 @@ pub struct ControlHello { pub workspace: Option, pub client_token: String, pub client_hostname: String, + #[serde(default)] + pub gui: bool, } impl ControlHello { @@ -510,6 +520,14 @@ impl ControlHello { workspace: None, client_token: client_token.into(), client_hostname: client_hostname.into(), + gui: false, + } + } + + pub fn gui(client_token: impl Into, client_hostname: impl Into) -> Self { + ControlHello { + gui: true, + ..Self::host_rpc(client_token, client_hostname) } } } @@ -1399,6 +1417,9 @@ mod tests { dirs: vec!["/home/me/proj".into(), "/home/me/proj/src".into()], }, ControlRequest::WatchClose { id: 7 }, + ControlRequest::GuiOpen { + path: Some("/home/me/proj".into()), + }, ControlRequest::AgentStates, ControlRequest::Routes, ControlRequest::Status, @@ -1523,6 +1544,9 @@ mod tests { workspace: "w1".into(), by: "other-laptop".into(), }, + ControlEvent::GuiOpen { + path: Some("/home/me/proj".into()), + }, ] } @@ -1532,6 +1556,7 @@ mod tests { workspace: Some("w1".into()), client_token: "tok".into(), client_hostname: "laptop".into(), + gui: false, } } @@ -1558,6 +1583,14 @@ mod tests { assert!(ok.features.is_empty()); } + #[test] + fn an_old_client_hello_defaults_to_a_non_gui_connection() { + let json = r#"{"control_version":5,"workspace":null,"client_token":"tok", + "client_hostname":"laptop"}"#; + let hello: ControlHello = serde_json::from_str(json).expect("decodes without gui role"); + assert!(!hello.gui); + } + fn hello_ok() -> ControlHelloOk { ControlHelloOk { control_version: CONTROL_VERSION, @@ -2109,6 +2142,7 @@ mod tests { }, s(20), ), + (R::GuiOpen { path: None }, s(5)), (R::AgentStates, s(5)), (R::Routes, s(5)), (R::Status, s(5)), diff --git a/crates/tty7-core/src/host/server.rs b/crates/tty7-core/src/host/server.rs index 6354afd2..872e2929 100644 --- a/crates/tty7-core/src/host/server.rs +++ b/crates/tty7-core/src/host/server.rs @@ -54,6 +54,7 @@ impl Services { #[derive(Default)] pub struct AttachRegistry { live: Mutex>, + guis: Mutex>, handover: Mutex<()>, } @@ -74,6 +75,11 @@ struct Evicted { dedicated: bool, } +struct GuiLive { + conn: u64, + sink: Arc, +} + impl AttachRegistry { fn handover(&self) -> std::sync::MutexGuard<'_, ()> { self.handover.lock().unwrap_or_else(|e| e.into_inner()) @@ -94,6 +100,41 @@ impl AttachRegistry { self.len() == 0 } + fn register_gui(&self, conn: u64, sink: Arc) { + let mut guis = self.guis.lock().unwrap_or_else(|e| e.into_inner()); + guis.retain(|gui| gui.conn != conn); + guis.push(GuiLive { conn, sink }); + } + + fn unregister_gui(&self, conn: u64) { + self.guis + .lock() + .unwrap_or_else(|e| e.into_inner()) + .retain(|gui| gui.conn != conn); + } + + fn open_gui(&self, path: Option) -> bool { + // The newest GUI connection belongs to the most recently started app + // process. Window recency is resolved inside that process, where GPUI + // owns the authoritative focus state. + loop { + let target = self + .guis + .lock() + .unwrap_or_else(|e| e.into_inner()) + .last() + .map(|gui| (gui.conn, Arc::clone(&gui.sink))); + let Some((conn, sink)) = target else { + return false; + }; + let event = ControlServerMsg::Event(ControlEvent::GuiOpen { path: path.clone() }); + if sink.send(&event).is_ok() { + return true; + } + self.unregister_gui(conn); + } + } + fn claim( &self, workspace: &str, @@ -252,6 +293,14 @@ where }, }); + if hello.gui { + // GUI registration starts only after a successful version handshake, + // so an incompatible app can never be reported as a live receiver. + services + .attachments + .register_gui(conn.id, Arc::clone(&sink)); + } + if let Some(workspace) = hello.workspace.as_deref() && let Err(e) = attach_workspace(&conn, workspace, true) { @@ -266,6 +315,8 @@ where .unwrap_or_else(|e| e.into_inner()) .clear(); conn.release_all_workspaces(); + // Every exit path converges here, including EOF and protocol errors. + conn.attachments.unregister_gui(conn.id); drop(machine_sub); sink.retire(); let _ = shutdown.shutdown_link(); @@ -610,6 +661,9 @@ fn run_request( detach_workspace(conn, &id)?; (ReplyOk::Unit, Vec::new()) } + ControlRequest::GuiOpen { path } => { + (ReplyOk::Bool(conn.attachments.open_gui(path)), Vec::new()) + } ControlRequest::MachineGet => ( ReplyOk::MachineTree(Box::new(machine_with_live_panes(conn)?)), @@ -1825,6 +1879,47 @@ mod pool_tests { } } +#[cfg(test)] +mod gui_registry_tests { + use super::*; + use std::io::Cursor; + + struct SharedWriter(Arc>>); + + impl Write for SharedWriter { + fn write(&mut self, bytes: &[u8]) -> io::Result { + self.0.lock().unwrap().extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + + #[test] + fn gui_open_is_delivered_only_while_a_gui_is_registered() { + let registry = AttachRegistry::default(); + assert!(!registry.open_gui(Some("/work".into()))); + + let bytes = Arc::new(Mutex::new(Vec::new())); + let sink = Arc::new(Sink::new(SharedWriter(Arc::clone(&bytes)))); + registry.register_gui(7, sink); + assert!(registry.open_gui(Some("/work".into()))); + + let frame = bytes.lock().unwrap().clone(); + assert_eq!( + ControlServerMsg::read(&mut Cursor::new(frame)).unwrap(), + ControlServerMsg::Event(ControlEvent::GuiOpen { + path: Some("/work".into()) + }) + ); + + registry.unregister_gui(7); + assert!(!registry.open_gui(None)); + } +} + #[cfg(all(test, unix))] mod tests { use super::*; @@ -1885,6 +1980,7 @@ mod tests { workspace: Some(workspace.to_string()), client_token: token.to_string(), client_hostname: hostname.to_string(), + gui: false, } } diff --git a/src/main.rs b/src/main.rs index 4a7c3869..56222a2d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -110,6 +110,91 @@ fn apply_config_dir_arg() { } } +fn open_path_arg() -> Option { + open_path_from(std::env::args().skip(1)) +} + +fn open_path_from(mut args: impl Iterator) -> Option { + while let Some(arg) = args.next() { + if let Some(path) = arg.strip_prefix("--open-path=") { + return Some(path.into()); + } + if arg == "--open-path" { + return args.next().map(Into::into); + } + } + None +} + +/// Offers an explicit launch path to an already running local GUI. +/// +/// The dispatcher is injected so the startup decision can be tested without +/// opening a real daemon connection. Only an explicit `Bool(true)` means the +/// path reached a GUI; every other response keeps the current app alive so it +/// can perform the normal first-window startup. +fn forward_open_path_with( + open_path: Option<&std::path::Path>, + dispatch: impl FnOnce(String) -> std::io::Result, +) -> bool { + use tty7_core::daemon::control::ReplyOk; + + let Some(path) = open_path else { + return false; + }; + let path = if path.is_absolute() { + path.to_path_buf() + } else { + match std::env::current_dir() { + Ok(current_dir) => current_dir.join(path), + Err(error) => { + log::debug!("could not resolve the relative GUI open path: {error}"); + return false; + } + } + }; + + let Some(wire_path) = path.to_str().map(str::to_owned) else { + // Keep the native PathBuf in this process. Returning false continues + // normal startup, which opens the path without crossing the protocol. + log::debug!("the explicit GUI open path is not valid UTF-8; opening it locally"); + return false; + }; + + match dispatch(wire_path) { + Ok(ReplyOk::Bool(true)) => true, + Ok(ReplyOk::Bool(false)) => false, + Ok(other) => { + log::debug!("the daemon answered the early GuiOpen request with {other:?}"); + false + } + Err(error) => { + log::debug!("the early GuiOpen request was not delivered: {error}"); + false + } + } +} + +/// Uses a transient host-RPC connection instead of a GUI connection. +/// +/// A GUI connection is long-lived and registers itself as the daemon's event +/// target. This short probe must never replace the actual running GUI while it +/// asks that GUI to open the requested folder. +fn forward_open_path(open_path: Option<&std::path::Path>) -> bool { + use tty7_core::client::ControlClient; + use tty7_core::daemon::control::{ControlHello, ControlRequest}; + + forward_open_path_with(open_path, |path| { + let hello = ControlHello::host_rpc( + format!("tty7-app-open-{}", std::process::id()), + "this computer", + ); + let client = ControlClient::connect(&hello)?; + let reply = client.request(ControlRequest::GuiOpen { path: Some(path) }); + client.close(); + reply + }) +} + #[cfg(unix)] fn merge_paths(primary: &str, secondary: &str) -> String { let mut seen = std::collections::HashSet::new(); @@ -212,6 +297,10 @@ fn main() { #[cfg(unix)] enrich_path_from_login_shell(); + let open_path = open_path_arg(); + if forward_open_path(open_path.as_deref()) { + return; + } let config = crate::core::config::Config::load(); // After the PATH enrichment above, which is what makes the candidate scan @@ -254,8 +343,11 @@ fn main() { keymap::init(cx); crate::ui::local_link::LocalLink::install(cx); - let reopen = crate::core::session::WorkspaceStore::restore_one(cx); - crate::ui::windows::open(cx, reopen); + let reopen = open_path + .is_none() + .then(|| crate::core::session::WorkspaceStore::restore_one(cx)) + .flatten(); + crate::ui::windows::open_at(cx, reopen, open_path); }); } @@ -279,3 +371,84 @@ mod tests { assert_eq!(merge_paths("", "/usr/bin"), "/usr/bin"); } } + +#[cfg(test)] +mod argument_tests { + use super::{forward_open_path_with, open_path_from}; + use std::path::PathBuf; + use tty7_core::daemon::control::ReplyOk; + + #[test] + fn open_path_accepts_separate_and_equals_forms() { + let separate = open_path_from( + ["--config-dir", "/cfg", "--open-path", "/work"] + .into_iter() + .map(str::to_string), + ); + assert_eq!(separate, Some(PathBuf::from("/work"))); + + let equals = open_path_from(["--open-path=C:\\work".to_string()].into_iter()); + assert_eq!(equals, Some(PathBuf::from("C:\\work"))); + } + + #[test] + fn no_open_path_never_attempts_early_dispatch() { + let delivered = forward_open_path_with(None, |_| { + panic!("the dispatcher must not run without an explicit path") + }); + + assert!(!delivered); + } + + #[test] + fn early_dispatch_exits_only_after_a_gui_accepts_the_path() { + let path = std::env::current_dir().unwrap().join("folder with spaces"); + let expected = path.to_str().unwrap().to_owned(); + let delivered = forward_open_path_with(Some(&path), |actual| { + assert_eq!(actual, expected); + Ok(ReplyOk::Bool(true)) + }); + + assert!(delivered); + assert!(!forward_open_path_with(Some(&path), |_| Ok(ReplyOk::Bool( + false + )))); + assert!(!forward_open_path_with(Some(&path), |_| Ok(ReplyOk::Unit))); + assert!(!forward_open_path_with(Some(&path), |_| { + Err(std::io::Error::new( + std::io::ErrorKind::ConnectionRefused, + "daemon is not running", + )) + })); + } + + #[test] + fn early_dispatch_resolves_relative_paths_before_cross_process_delivery() { + let relative = std::path::Path::new("workspace"); + let expected = std::env::current_dir() + .unwrap() + .join(relative) + .to_str() + .unwrap() + .to_owned(); + + assert!(forward_open_path_with(Some(relative), |actual| { + assert_eq!(actual, expected); + Ok(ReplyOk::Bool(true)) + })); + } + + #[cfg(unix)] + #[test] + fn early_dispatch_keeps_non_utf8_paths_in_the_current_process() { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt as _; + + let path = std::env::temp_dir().join(OsString::from_vec(b"tty7-\xff".to_vec())); + let delivered = forward_open_path_with(Some(&path), |_| { + panic!("a non-UTF-8 path must never be changed and sent over the string protocol") + }); + + assert!(!delivered); + } +} diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 1c4e43a1..fbca11f9 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -45,7 +45,8 @@ actions!( FindNext, FindPrevious, ClearScrollback, - InsertNewline + InsertNewline, + InsertNewlineFallback ] ); @@ -1184,6 +1185,17 @@ impl TerminalView { } } + fn send_shortcut_bytes(&mut self, bytes: &[u8], key: &str, cx: &mut Context) { + let shell_owns_prompt = self.shell_owns_prompt(); + self.release_hold(); + self.send_to_pty(bytes, cx); + if !shell_owns_prompt { + let alt = self.on_alt_screen(); + self.typeahead + .observe(RawInput::Key { key, plain: false }, alt); + } + } + fn handle_cmd_shortcut( &mut self, ks: &gpui::Keystroke, @@ -1222,6 +1234,8 @@ impl TerminalView { if self.input_active() { self.editor_move_edge(false, m.shift); cx.notify(); + } else if cfg!(target_os = "macos") && self.accepts_input(cx) { + self.send_shortcut_bytes(&[0x01], "a", cx); } CmdKey::Consumed } @@ -1229,6 +1243,8 @@ impl TerminalView { if self.input_active() { self.editor_move_edge(true, m.shift); cx.notify(); + } else if cfg!(target_os = "macos") && self.accepts_input(cx) { + self.send_shortcut_bytes(&[0x05], "e", cx); } CmdKey::Consumed } @@ -1240,6 +1256,8 @@ impl TerminalView { self.close_completion(); self.cursor_visible = true; cx.notify(); + } else if cfg!(target_os = "macos") && self.accepts_input(cx) { + self.send_shortcut_bytes(&[0x15], "u", cx); } CmdKey::Consumed } @@ -1251,6 +1269,8 @@ impl TerminalView { self.close_completion(); self.cursor_visible = true; cx.notify(); + } else if cfg!(target_os = "macos") && self.accepts_input(cx) { + self.send_shortcut_bytes(&[0x0b], "k", cx); } CmdKey::Consumed } @@ -2629,6 +2649,19 @@ impl TerminalView { cx.notify(); } + fn insert_newline_fallback_action(&mut self, cx: &mut Context) { + if self.input_active() { + self.insert_newline_action(cx); + } else if (self.search.is_some() && self.search_focused) + || self.kitty_flags().active() + || !self.accepts_input(cx) + { + cx.propagate(); + } else { + self.send_shortcut_bytes(b"\n", "enter", cx); + } + } + fn accept_line(&mut self, cx: &mut Context) { if self .completion @@ -4346,6 +4379,9 @@ impl Render for TerminalView { .on_action(cx.listener(|this, _: &InsertNewline, _w, cx| { this.insert_newline_action(cx); })) + .on_action(cx.listener(|this, _: &InsertNewlineFallback, _w, cx| { + this.insert_newline_fallback_action(cx); + })) .on_action(cx.listener(|this, _: &SendTab, _w, cx| { this.tab_pressed(true, cx); })) @@ -6608,6 +6644,82 @@ mod gpui_tests { .unwrap(); } + #[gpui::test] + fn shift_enter_reaches_a_foreground_tui_with_kitty_encoding(cx: &mut TestAppContext) { + crate::core::config::pin_test_config_dir(); + let (window, mut daemon) = harness(cx); + cx.update(|cx| crate::ui::keymap::init(cx)); + DaemonMsg::Output(b"\x1b[>1u".to_vec()) + .encode(&mut daemon) + .unwrap(); + for _ in 0..200 { + cx.run_until_parked(); + if window + .update(cx, |view, _, _| view.kitty_flags().active()) + .unwrap() + { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + window + .update(cx, |view, window, cx| { + assert!(!view.input_active(), "the foreground TUI owns input"); + window.activate_window(); + view.focus_handle.focus(window, cx); + }) + .unwrap(); + + let mut vcx = gpui::VisualTestContext::from_window(window.into(), cx); + vcx.simulate_keystrokes("shift-enter"); + + assert_eq!( + next_input_until_timeout(&mut daemon), + Some(b"\x1b[13;2u".to_vec()) + ); + } + + #[gpui::test] + fn shift_enter_reaches_a_foreground_tui_as_lf_without_kitty(cx: &mut TestAppContext) { + crate::core::config::pin_test_config_dir(); + let (window, mut daemon) = harness(cx); + cx.update(|cx| crate::ui::keymap::init(cx)); + window + .update(cx, |view, window, cx| { + assert!(!view.input_active(), "the foreground TUI owns input"); + window.activate_window(); + view.focus_handle.focus(window, cx); + }) + .unwrap(); + + let mut vcx = gpui::VisualTestContext::from_window(window.into(), cx); + vcx.simulate_keystrokes("shift-enter"); + + assert_eq!(next_input_until_timeout(&mut daemon), Some(b"\n".to_vec())); + } + + #[gpui::test] + fn alt_enter_keeps_its_legacy_encoding_in_a_foreground_tui(cx: &mut TestAppContext) { + crate::core::config::pin_test_config_dir(); + let (window, mut daemon) = harness(cx); + cx.update(|cx| crate::ui::keymap::init(cx)); + window + .update(cx, |view, window, cx| { + assert!(!view.input_active(), "the foreground TUI owns input"); + window.activate_window(); + view.focus_handle.focus(window, cx); + }) + .unwrap(); + + let mut vcx = gpui::VisualTestContext::from_window(window.into(), cx); + vcx.simulate_keystrokes("alt-enter"); + + assert_eq!( + next_input_until_timeout(&mut daemon), + Some(b"\x1b\r".to_vec()) + ); + } + #[gpui::test] fn insert_newline_action_declines_when_the_editor_is_not_live(cx: &mut TestAppContext) { let (window, _daemon) = harness(cx); @@ -7851,6 +7963,130 @@ mod gpui_tests { assert_eq!(text.as_deref(), Some("hello")); } + #[cfg(target_os = "macos")] + #[gpui::test] + fn cmd_backspace_reaches_a_foreground_tui_as_ctrl_u(cx: &mut TestAppContext) { + crate::core::config::pin_test_config_dir(); + let (window, mut daemon) = harness(cx); + window + .update(cx, |view, window, cx| { + assert!( + !view.input_active(), + "the foreground process, not tty7's editor, owns input" + ); + view.on_key_down( + &KeyDownEvent { + keystroke: gpui::Keystroke { + modifiers: Modifiers { + platform: true, + ..Modifiers::default() + }, + key: "backspace".into(), + key_char: None, + }, + is_held: false, + prefer_character_input: false, + }, + window, + cx, + ); + }) + .unwrap(); + + assert_eq!(next_input_until_timeout(&mut daemon), Some(vec![0x15])); + } + + #[cfg(target_os = "macos")] + #[gpui::test] + fn cmd_navigation_reaches_a_foreground_tui_as_readline_controls(cx: &mut TestAppContext) { + crate::core::config::pin_test_config_dir(); + let (window, mut daemon) = harness(cx); + + for (key, expected) in [("left", 0x01), ("right", 0x05), ("delete", 0x0b)] { + window + .update(cx, |view, window, cx| { + assert!(!view.input_active(), "the foreground TUI owns input"); + view.on_key_down( + &KeyDownEvent { + keystroke: gpui::Keystroke { + modifiers: Modifiers { + platform: true, + ..Modifiers::default() + }, + key: key.into(), + key_char: None, + }, + is_held: false, + prefer_character_input: false, + }, + window, + cx, + ); + }) + .unwrap(); + assert_eq!(next_input_until_timeout(&mut daemon), Some(vec![expected])); + } + } + + #[cfg(target_os = "macos")] + #[gpui::test] + fn cmd_backspace_releases_held_input_before_ctrl_u(cx: &mut TestAppContext) { + crate::core::config::pin_test_config_dir(); + let (window, mut daemon) = harness(cx); + DaemonMsg::Prompt { + active: true, + at_prompt: false, + last_exit: None, + } + .encode(&mut daemon) + .unwrap(); + for _ in 0..200 { + cx.run_until_parked(); + let gap = window + .update(cx, |view, _, _| { + view.terminal.shell_active() && !view.terminal.at_prompt() + }) + .unwrap(); + if gap { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + + window + .update(cx, |view, window, cx| { + view.commit_text("ls", cx); + view.on_key_down( + &KeyDownEvent { + keystroke: gpui::Keystroke { + modifiers: Modifiers { + platform: true, + ..Modifiers::default() + }, + key: "backspace".into(), + key_char: None, + }, + is_held: false, + prefer_character_input: false, + }, + window, + cx, + ); + }) + .unwrap(); + + assert_eq!( + next_input_until_timeout(&mut daemon), + Some(b"ls".to_vec()), + "held text must reach the PTY before the line-kill" + ); + assert_eq!( + next_input_until_timeout(&mut daemon), + Some(vec![0x15]), + "Ctrl-U must follow the text it clears" + ); + } + #[gpui::test] fn paste_to_the_pty_consumes_the_selection(cx: &mut TestAppContext) { let (window, mut daemon) = harness(cx); diff --git a/src/ui/app.rs b/src/ui/app.rs index 58a23119..b4c3737f 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -438,6 +438,15 @@ impl Tty7App { id: Option, window: &mut Window, cx: &mut Context, + ) -> Self { + Self::for_workspace_at(id, None, window, cx) + } + + pub fn for_workspace_at( + id: Option, + initial_cwd: Option, + window: &mut Window, + cx: &mut Context, ) -> Self { let restore = cx.global::().restore_session; let known = id.is_some_and(|id| WorkspaceStore::all(cx).get(id).is_some()); @@ -447,7 +456,7 @@ impl Tty7App { .is_some_and(|w| w.is_remote()); let hydrate = known && (restore || is_remote); let session = hydrate.then(Session::default); - let app = Self::with_session(Some(workspace), session, window, cx); + let app = Self::with_session_at(Some(workspace), session, initial_cwd, window, cx); if hydrate { crate::ui::tree_sync::hydrate_window_from_tree(cx, workspace); } else { @@ -507,6 +516,16 @@ impl Tty7App { session: Option, window: &mut Window, cx: &mut Context, + ) -> Self { + Self::with_session_at(workspace, session, None, window, cx) + } + + fn with_session_at( + workspace: Option, + session: Option, + initial_cwd: Option, + window: &mut Window, + cx: &mut Context, ) -> Self { let workspace = workspace.unwrap_or_default(); let pane_ws = crate::ui::remote_workspace::pane_workspace_for(cx, workspace); @@ -594,7 +613,7 @@ impl Tty7App { pane_ws.clone(), Some(workspace), font_size, - None, + initial_cwd, None, None, window, @@ -2209,20 +2228,39 @@ impl Tty7App { self.new_tab_with_shell(None, window, cx); } + pub(crate) fn new_tab_at( + &mut self, + cwd: std::path::PathBuf, + window: &mut Window, + cx: &mut Context, + ) { + self.new_tab_with_cwd(Some(cwd), None, window, cx); + } + pub(crate) fn new_tab_with_shell( &mut self, shell: Option, window: &mut Window, cx: &mut Context, + ) { + 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_with_cwd(cwd, shell, window, cx); + } + + fn new_tab_with_cwd( + &mut self, + cwd: Option, + shell: Option, + window: &mut Window, + cx: &mut Context, ) { if !self.guard_local_spawn(window, cx) { return; } - 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()) - }); let pane_ws = self.window_workspace(cx); let tab = match new_terminal( pane_ws, diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs index 791a96cc..90f317d6 100644 --- a/src/ui/keymap.rs +++ b/src/ui/keymap.rs @@ -3,7 +3,8 @@ use gpui::{App, Global, KeyBinding, Keystroke, NoAction}; use crate::core::actions::*; use crate::core::config::Config; use crate::terminal::view::{ - ClearScrollback, CopyText, FindInTerminal, FindNext, FindPrevious, InsertNewline, PasteText, + ClearScrollback, CopyText, FindInTerminal, FindNext, FindPrevious, InsertNewline, + InsertNewlineFallback, PasteText, }; use crate::ui::theme::set_menus; @@ -74,6 +75,10 @@ fn extra_keystrokes(effective: &[(String, String)]) -> Vec<(&'static str, &'stat .collect() } +fn is_default_insert_newline_binding(action: &str, key: &str) -> bool { + action == "InsertNewline" && key == INSERT_NEWLINE_DEFAULT +} + pub(crate) fn extra_bindings(cx: &App) -> Vec<(String, String)> { extra_keystrokes(&effective_bindings(cx)) .into_iter() @@ -97,7 +102,16 @@ fn action_bindings(effective: &[(String, String)]) -> Vec { continue; } match make_binding(action, key) { - Some(b) => bindings.push(b), + Some(b) => { + bindings.push(b); + if is_default_insert_newline_binding(action, key) { + bindings.push(KeyBinding::new( + INSERT_NEWLINE_DEFAULT, + InsertNewlineFallback, + Some("Terminal"), + )); + } + } None => log::warn!("ignoring keybinding: unknown action '{action}'"), } } @@ -697,6 +711,52 @@ mod tests { assert_eq!(key_tokens("shift-enter"), vec![SHIFT, "⏎"]); } + #[test] + fn shift_enter_fallback_retires_with_an_insert_newline_rebind() { + assert!(is_default_insert_newline_binding( + "InsertNewline", + "shift-enter" + )); + assert!(!is_default_insert_newline_binding( + "InsertNewline", + "ctrl-o" + )); + assert!(!is_default_insert_newline_binding("InsertNewline", "")); + } + + #[test] + fn shift_enter_prefix_binding_still_waits_for_its_second_key() { + let mut effective = default_bindings() + .into_iter() + .map(|(action, key)| (action.to_string(), key.to_string())) + .collect::>(); + effective + .iter_mut() + .find(|(action, _)| action == "OpenSettings") + .unwrap() + .1 = "shift-enter x".to_string(); + + let mut keymap = gpui::Keymap::default(); + keymap.add_bindings(action_bindings(&effective)); + let input = [gpui::Keystroke::parse("shift-enter").unwrap()]; + let context = [gpui::KeyContext::parse("Terminal").unwrap()]; + let (_, pending) = keymap.bindings_for_input(&input, &context); + + assert!(pending, "the keymap must wait for the second key"); + + let input = [ + gpui::Keystroke::parse("shift-enter").unwrap(), + gpui::Keystroke::parse("x").unwrap(), + ]; + let (matched, pending) = keymap.bindings_for_input(&input, &context); + assert!(!pending); + assert!( + matched + .first() + .is_some_and(|binding| binding.action().partial_eq(&OpenSettings)) + ); + } + #[test] fn paste_ships_both_terminal_chords_off_macos_and_retires_together() { let effective: Vec<(String, String)> = default_bindings() diff --git a/src/ui/local_link.rs b/src/ui/local_link.rs index bbeb42c5..1f910284 100644 --- a/src/ui/local_link.rs +++ b/src/ui/local_link.rs @@ -118,7 +118,7 @@ fn connect_blocking() -> std::io::Result> { use tty7_core::daemon::control::ControlHello; crate::daemon::spawn::ensure_running().map_err(std::io::Error::other)?; - let hello = ControlHello::host_rpc(uuid::Uuid::new_v4().to_string(), "this computer"); + let hello = ControlHello::gui(uuid::Uuid::new_v4().to_string(), "this computer"); let sink: tty7_core::daemon::control::EventSink = Box::new(local_event_sink); #[cfg(unix)] let client = ControlClient::over_unix( diff --git a/src/ui/remote_workspace.rs b/src/ui/remote_workspace.rs index 307251ce..0a448d78 100644 --- a/src/ui/remote_workspace.rs +++ b/src/ui/remote_workspace.rs @@ -1027,6 +1027,9 @@ pub(crate) fn drain_events(cx: &mut gpui::App) { crate::ui::tree_sync::resync_window_from_tree(cx, workspace); } } + ControlEvent::GuiOpen { path } if host.is_local() => { + crate::ui::windows::open_from_cli(cx, path.map(std::path::PathBuf::from)); + } other => log::debug!("unhandled control event from {host:?}: {other:?}"), } } diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 7d9005bf..9b7e08cb 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -36,6 +36,10 @@ use crate::ui::presets; use crate::ui::rounding; use crate::ui::rounding::RoundedCorners as _; +fn settings_row_id(label: &str, _desc: &str) -> SharedString { + SharedString::from(format!("settings-row-{label}")) +} + #[derive(Clone, Copy, PartialEq, Eq)] pub(crate) enum SettingsSection { Appearance, @@ -958,10 +962,15 @@ impl Tty7App { desc: impl Into, control: AnyElement, cx: &Context, - ) -> Div { + ) -> Stateful
{ let theme = cx.theme(); + let label = label.into(); let desc = desc.into(); + // Descriptions can contain live status (for example an agent hook target), so they + // must not participate in the identity that preserves GPUI's hover state. + let element_id = settings_row_id(&label, &desc); h_flex() + .id(element_id) .items_center() .justify_between() .gap_8() @@ -970,6 +979,7 @@ impl Tty7App { .mx_neg_2p5() .rounded_lg() .hover(|h| h.bg(gpui::rgb(cx.global::().window.hover))) + .on_hover(cx.listener(|_this, _hovered, _window, cx| cx.notify())) .child( v_flex() .gap_0p5() @@ -979,7 +989,7 @@ impl Tty7App { .text_sm() .font_weight(FontWeight::MEDIUM) .text_color(theme.foreground) - .child(label.into()), + .child(label), ) .when(!desc.is_empty(), |col| { col.child( @@ -4725,6 +4735,18 @@ impl Tty7App { mod tests { use super::*; + #[test] + fn settings_row_identity_depends_only_on_its_stable_label() { + assert_eq!( + settings_row_id("Claude Code", "Installing…"), + settings_row_id("Claude Code", "Installed in C:\\tools") + ); + assert_ne!( + settings_row_id("Claude Code", "Installed"), + settings_row_id("Codex", "Installed") + ); + } + #[test] fn every_section_has_search_entries() { for section in SettingsSection::ALL { diff --git a/src/ui/windows.rs b/src/ui/windows.rs index e4931e3f..799446e9 100644 --- a/src/ui/windows.rs +++ b/src/ui/windows.rs @@ -63,6 +63,29 @@ impl WindowRegistry { .or_else(|| registry.windows.first().map(|w| w.workspace)) } + pub fn most_recent_local(cx: &mut App) -> Option { + Self::sweep(cx); + let views = WorkspaceStore::all(cx); + let registry = cx.global::(); + let is_open_local = |id: WorkspaceId| { + registry.windows.iter().any(|window| window.workspace == id) + && views.get(id).is_some_and(|view| !view.is_remote()) + }; + views.active.filter(|id| is_open_local(*id)).or_else(|| { + registry + .windows + .iter() + .filter(|window| is_open_local(window.workspace)) + .max_by_key(|window| { + views + .get(window.workspace) + .map(|view| view.last_active) + .unwrap_or_default() + }) + .map(|window| window.workspace) + }) + } + pub fn app_in(cx: &mut App, window: &Window) -> Option> { Self::sweep(cx); let handle = window.window_handle(); @@ -130,6 +153,14 @@ impl WindowRegistry { } pub fn open(cx: &mut App, workspace: Option) { + open_at(cx, workspace, None); +} + +pub fn open_at( + cx: &mut App, + workspace: Option, + initial_cwd: Option, +) { if let Some(id) = workspace && let Some(handle) = WindowRegistry::window_for(cx, id) { @@ -140,7 +171,10 @@ pub fn open(cx: &mut App, workspace: Option) { let options = window_options(cx, workspace); let mut created: Option> = None; let opened = cx.open_window(options, |window, cx| { - let app = cx.new(|cx| Tty7App::for_workspace(workspace, window, cx)); + let app = cx.new(|cx| match initial_cwd.clone() { + Some(cwd) => Tty7App::for_workspace_at(workspace, Some(cwd), window, cx), + None => Tty7App::for_workspace(workspace, window, cx), + }); created = Some(app.clone()); cx.new(|cx| Root::new(app, window, cx).bg(gpui::transparent_black())) }); @@ -162,6 +196,52 @@ pub fn open(cx: &mut App, workspace: Option) { refresh_menu(cx); } +pub fn open_from_cli(cx: &mut App, path: Option) { + // Only the GUI process knows which of its windows was focused most recently. + // The daemon deliberately routes to a process, then leaves window selection + // to this registry. + let workspace = if path.is_some() { + WindowRegistry::most_recent_local(cx) + } else { + WindowRegistry::most_recent(cx) + }; + let Some(workspace) = workspace else { + open_missing_cli_window_with(cx, path, open_at); + return; + }; + let Some(handle) = WindowRegistry::window_for(cx, workspace) else { + return; + }; + let Some(app) = WindowRegistry::app_for(cx, workspace).and_then(|app| app.upgrade()) else { + return; + }; + + cx.activate(true); + let _ = handle.update(cx, move |_, window, cx| { + if let Some(path) = path { + app.update(cx, |app, cx| app.new_tab_at(path, window, cx)); + } + window.activate_window(); + }); +} + +/// Opens a window after CLI routing reaches a GUI process with no live windows. +/// +/// A pathless request follows the same restoration policy as normal startup. +/// An explicit path always starts a fresh local workspace so the requested tab +/// cannot accidentally be attached to a detached remote workspace. +fn open_missing_cli_window_with( + cx: &mut App, + path: Option, + open: impl FnOnce(&mut App, Option, Option), +) { + let restore = path + .is_none() + .then(|| WorkspaceStore::restore_one(cx)) + .flatten(); + open(cx, restore, path); +} + pub fn refresh_menu(cx: &mut App) { crate::ui::theme::set_menus(cx); } @@ -456,6 +536,7 @@ fn cascade(bounds: Bounds, existing: usize) -> Bounds Bounds { Bounds { @@ -491,6 +572,46 @@ mod tests { assert_eq!(cascade(b, 6).origin, cascade(b, 1).origin); } + #[gpui::test] + fn a_pathless_cli_request_restores_a_workspace_when_no_window_is_open( + cx: &mut gpui::TestAppContext, + ) { + let view = WindowView::default(); + let restored = view.id; + let mut opened = None; + + cx.update(|cx| { + WorkspaceStore::install_for_test( + cx, + WindowViews { + views: vec![view], + active: Some(restored), + }, + ); + open_missing_cli_window_with(cx, None, |_, workspace, path| { + opened = Some((workspace, path)); + }); + }); + + assert_eq!(opened, Some((Some(restored), None))); + } + + #[gpui::test] + fn a_pathless_cli_request_creates_a_default_window_without_history( + cx: &mut gpui::TestAppContext, + ) { + let mut opened = None; + + cx.update(|cx| { + WorkspaceStore::install_for_test(cx, WindowViews::default()); + open_missing_cli_window_with(cx, None, |_, workspace, path| { + opened = Some((workspace, path)); + }); + }); + + assert_eq!(opened, Some((None, None))); + } + #[test] fn the_confirmation_says_which_of_the_three_answers_it_got() { assert_eq!( @@ -535,7 +656,6 @@ mod tests { fn a_delete_reads_its_kill_list_before_the_removal_blanks_the_mirror( cx: &mut gpui::TestAppContext, ) { - use crate::core::session::{WindowView, WindowViews}; use tty7_core::core::machine::{Machine, PaneRecord, Tab, Workspace as TreeWorkspace}; cx.update(|cx| {