diff --git a/crates/nebula-tui/src/event_loop.rs b/crates/nebula-tui/src/event_loop.rs index 943d9b5..b85b157 100644 --- a/crates/nebula-tui/src/event_loop.rs +++ b/crates/nebula-tui/src/event_loop.rs @@ -43,8 +43,8 @@ use focus_walk::{ }; pub use host_terminal::restore_terminal; use host_terminal::{ - on_host_resize, reassert_modes, repaint, setup_terminal, take_worker_panic, watch_held_key, - MODE_REASSERT, + on_host_resize, reassert_modes, repaint, report_working_directory, setup_terminal, + take_worker_panic, watch_held_key, MODE_REASSERT, }; pub use release_watch::ReleaseWatch; @@ -292,6 +292,7 @@ async fn main_loop( // Pointer shape last sent to the terminal (OSC 22), so hover over a // splitter swaps the cursor once instead of on every motion event. let mut pointer_sent = PointerShape::default(); + let mut directory_sent = None; let mut next_draw = tokio::time::Instant::now(); // When the loop may paint again (FRAME PACING): back to back for a key // and its answer, 60 fps under sustained output. @@ -711,6 +712,8 @@ async fn main_loop( app.dirty = true; } + report_working_directory(&app, &mut directory_sent, terminal.backend_mut())?; + // Mouse handlers only record the pointer shape they want; emit the // OSC 22 request when it changes. Terminals without pointer-shape // support (Terminal.app) parse and drop the sequence. @@ -4452,22 +4455,27 @@ fn open_file_link(app: &mut App, path: &str, line: Option) { spawn_editor_modal(app, &editor, &root, &file, line.unwrap_or(1), size); } -/// Worktree root of the attached session; falls back to the selected -/// worktree when the attachment isn't an agent (or isn't in the tree yet). +/// Worktree root of the attached agent or shell; falls back to the +/// selected worktree when nothing is attached (or it isn't in the tree yet). fn attached_worktree_root(app: &App) -> Option { - if let Some(SessionRef::Agent(id)) = app.term.as_ref().map(|t| &t.sref) { - let root = app + let worktree_id = app.term.as_ref().and_then(|t| match &t.sref { + SessionRef::Agent(id) => app .tree .agents .iter() .find(|a| &a.id == id) - .and_then(|a| app.tree.worktrees.iter().find(|w| w.id == a.worktree_id)) - .map(|w| w.path.clone()); - if root.is_some() { - return root; - } - } - app.selected_worktree().map(|w| w.path.clone()) + .map(|a| &a.worktree_id), + SessionRef::Terminal(id) => app + .tree + .terminals + .iter() + .find(|t| &t.id == id) + .map(|t| &t.worktree_id), + }); + worktree_id + .and_then(|id| app.tree.worktrees.iter().find(|w| &w.id == id)) + .or_else(|| app.selected_worktree()) + .map(|w| w.path.clone()) } /// Resolve a clicked path against the worktree: expand `~/`, try it as diff --git a/crates/nebula-tui/src/event_loop/host_terminal.rs b/crates/nebula-tui/src/event_loop/host_terminal.rs index 718a5aa..8f0940b 100644 --- a/crates/nebula-tui/src/event_loop/host_terminal.rs +++ b/crates/nebula-tui/src/event_loop/host_terminal.rs @@ -20,6 +20,8 @@ use crossterm::event::KeyboardEnhancementFlags; use ratatui::backend::CrosstermBackend; use ratatui::Terminal; use std::io::{BufWriter, Stdout, Write}; +use std::os::unix::ffi::OsStrExt; +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU8, AtomicUsize, Ordering}; use std::thread::ThreadId; use std::time::Duration; @@ -116,9 +118,59 @@ pub fn restore_terminal() { crossterm::event::DisableMouseCapture, LeaveAlternateScreen, ); + // Nebula never changes its own cwd. Hand that directory back to the + // shell instead of leaving the last previewed checkout on the host. + if !nebula_core::host::is_remote_session() { + if let Ok(cwd) = std::env::current_dir() { + let mut stdout = std::io::stdout().lock(); + let _ = write_working_directory(&mut stdout, &cwd); + let _ = stdout.flush(); + } + } let _ = disable_raw_mode(); } +/// Publish the visible session's checkout through OSC 7, so the outer +/// terminal can resolve relative links even though child OSCs are consumed +/// by vt100. An unchanged directory produces no output. +pub(super) fn report_working_directory( + app: &crate::app::App, + sent: &mut Option, + w: &mut impl Write, +) -> std::io::Result<()> { + // ponytail: local checkout roots only; child-shell cd and remote host + // identity need separate tracking, never advertise SSH paths as local. + if app.is_remote { + return Ok(()); + } + let cwd = super::attached_worktree_root(app).or_else(|| std::env::current_dir().ok()); + if cwd != *sent { + if let Some(path) = &cwd { + write_working_directory(w, path)?; + w.flush()?; + } + *sent = cwd; + } + Ok(()) +} + +fn write_working_directory(w: &mut impl Write, path: &Path) -> std::io::Result<()> { + if !path.is_absolute() { + return Ok(()); + } + w.write_all(b"\x1b]7;file://localhost")?; + // Encode raw path bytes: spaces, URL delimiters and terminal controls + // must stay filename data, and non-UTF-8 names must round-trip too. + for &byte in path.as_os_str().as_bytes() { + if byte.is_ascii_alphanumeric() || b"/-._~".contains(&byte) { + w.write_all(&[byte])?; + } else { + write!(w, "%{byte:02X}")?; + } + } + w.write_all(b"\x1b\\") +} + /// Wrap whatever panic hook is installed (the crash log's, which chains to /// the default) so the terminal is restored before the panic message prints /// — but only when the panic is on `owner`, the thread whose unwinding ends @@ -221,6 +273,111 @@ pub(super) fn watch_held_key(w: &mut impl Write, on: bool) -> std::io::Result<() #[cfg(test)] mod tests { use super::*; + use crate::app::{App, AttachedTerm}; + use crate::event_loop::tests::{seed_feat_worktree, seed_tree}; + use nebula_core::{AgentId, SessionRef, TerminalId, TerminalTab, WorktreeId}; + + #[test] + fn working_directory_follows_the_attached_agent_not_the_sidebar() { + let mut app = App::new(); + app.is_remote = false; + seed_tree(&mut app); + seed_feat_worktree(&mut app, "w2", "links"); + app.term = Some(AttachedTerm::new( + SessionRef::Agent(AgentId("a1".into())), + 80, + 24, + )); + let mut sent = None; + let mut out = Vec::new(); + report_working_directory(&app, &mut sent, &mut out).unwrap(); + assert_eq!(text(&out), "\x1b]7;file://localhost/tmp/demo\x1b\\"); + out.clear(); + report_working_directory(&app, &mut sent, &mut out).unwrap(); + assert!(out.is_empty(), "unchanged cwd is not repeated"); + + // An agent moved checkout while the sidebar still selects main. + app.tree.agents[0].worktree_id = WorktreeId("w2".into()); + assert_eq!( + app.selected_worktree().unwrap().path, + Path::new("/tmp/demo") + ); + report_working_directory(&app, &mut sent, &mut out).unwrap(); + assert_eq!( + text(&out), + "\x1b]7;file://localhost/tmp/demo-worktrees/links\x1b\\" + ); + + out.clear(); + app.term = None; + report_working_directory(&app, &mut sent, &mut out).unwrap(); + assert_eq!(text(&out), "\x1b]7;file://localhost/tmp/demo\x1b\\"); + } + + #[test] + fn working_directory_follows_an_attached_shell_and_leaves_ssh_alone() { + let mut app = App::new(); + app.is_remote = false; + seed_tree(&mut app); + seed_feat_worktree(&mut app, "w2", "shell"); + let id = TerminalId("t1".into()); + app.tree.terminals.push(TerminalTab { + id: id.clone(), + worktree_id: WorktreeId("w2".into()), + name: "shell".into(), + sort_order: 0, + alive: true, + run_command: None, + }); + app.term = Some(AttachedTerm::new(SessionRef::Terminal(id), 80, 24)); + let mut sent = None; + let mut out = Vec::new(); + report_working_directory(&app, &mut sent, &mut out).unwrap(); + assert_eq!( + text(&out), + "\x1b]7;file://localhost/tmp/demo-worktrees/shell\x1b\\" + ); + + app.is_remote = true; + sent = None; + out.clear(); + report_working_directory(&app, &mut sent, &mut out).unwrap(); + assert!( + out.is_empty(), + "remote paths must not become local file URLs" + ); + assert!(sent.is_none()); + } + + #[test] + fn working_directory_encodes_url_delimiters_controls_and_non_utf8_bytes() { + let path = Path::new(std::ffi::OsStr::from_bytes( + b"/tmp/a b#?%/caf\xc3\xa9/\xff\x07\x1b\\", + )); + let mut out = Vec::new(); + write_working_directory(&mut out, path).unwrap(); + assert_eq!( + text(&out), + "\x1b]7;file://localhost/tmp/a%20b%23%3F%25/caf%C3%A9/%FF%07%1B%5C\x1b\\" + ); + out.clear(); + write_working_directory(&mut out, Path::new("relative/path")).unwrap(); + assert!(out.is_empty()); + } + + #[test] + fn working_directory_retries_a_failed_write() { + let mut app = App::new(); + app.is_remote = false; + seed_tree(&mut app); + let mut sent = None; + let mut full = &mut [][..]; + assert!(report_working_directory(&app, &mut sent, &mut full).is_err()); + assert!(sent.is_none(), "only a successful write counts as reported"); + let mut out = Vec::new(); + report_working_directory(&app, &mut sent, &mut out).unwrap(); + assert_eq!(text(&out), "\x1b]7;file://localhost/tmp/demo\x1b\\"); + } #[test] fn a_worker_thread_panic_is_counted_and_leaves_the_terminal_alone() { diff --git a/crates/nebula/tests/e2e_tui.rs b/crates/nebula/tests/e2e_tui.rs index 0e6df2b..8b6dc18 100644 --- a/crates/nebula/tests/e2e_tui.rs +++ b/crates/nebula/tests/e2e_tui.rs @@ -39,6 +39,7 @@ const FOOTER_TERMINAL_LOCKED: &str = "^q: sessions"; struct TuiHarness { writer: Box, parser: Arc>, + output: Arc>>, child: Box, runtime_dir: PathBuf, data_dir: PathBuf, @@ -101,14 +102,17 @@ impl TuiHarness { std::mem::forget(pty.master); let parser = Arc::new(Mutex::new(vt100::Parser::new(ROWS, COLS, 0))); + let output = Arc::new(Mutex::new(Vec::new())); { let parser = parser.clone(); + let output = output.clone(); std::thread::spawn(move || { let mut buf = [0u8; 8192]; while let Ok(n) = reader.read(&mut buf) { if n == 0 { break; } + output.lock().unwrap().extend_from_slice(&buf[..n]); parser.lock().unwrap().process(&buf[..n]); } }); @@ -117,6 +121,7 @@ impl TuiHarness { Self { writer, parser, + output, child, runtime_dir, data_dir, @@ -185,6 +190,18 @@ impl TuiHarness { } } + fn wait_for_working_directory(&self, path: &Path) { + let sequence = format!("\x1b]7;file://localhost{}\x1b\\", path.display()); + self.wait_for(&format!("working directory {path:?}"), |_| { + self.output + .lock() + .unwrap() + .windows(sequence.len()) + .any(|bytes| bytes == sequence.as_bytes()) + }); + self.output.lock().unwrap().clear(); + } + fn wait_for_text(&self, needle: &str) { self.wait_for(&format!("text {needle:?}"), |s| { screen_to_text(s).contains(needle) @@ -382,6 +399,28 @@ fn repo_git(repo: &std::path::Path, args: &[&str]) { assert!(ok, "git {args:?} failed in {}", repo.display()); } +/// Host-terminal links follow the visible checkout, not the directory where +/// nebula was launched. On exit, the shell gets its original directory back. +#[test] +fn host_working_directory_follows_project_switches_and_restores_on_exit() { + let mut tui = TuiHarness::spawn_with_env(&[ + ("SSH_CONNECTION", String::new()), + ("SSH_TTY", String::new()), + ]); + let first = tui.make_repo("cwd-first").canonicalize().unwrap(); + let second = tui.make_repo("cwd-second").canonicalize().unwrap(); + tui.wait_for_text("create your first project"); + add_project(&mut tui, &first, "cwd-first"); + tui.wait_for_working_directory(&first); + add_project(&mut tui, &second, "cwd-second"); + tui.wait_for_working_directory(&second); + + tui.send(b"q"); + tui.wait_for_text("Quit nebula"); + tui.send(ENTER); + tui.wait_for_working_directory(&tui._repos.path().canonicalize().unwrap()); +} + #[test] fn tui_help_modal_grouped_keymap() { let mut tui = TuiHarness::spawn(); diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 4a2a645..28de31e 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -2,6 +2,11 @@ [← README](../README.md) · [Keys](keys.md) · [Commands](commands.md) · [Sessions](sessions.md) · [Configuration](configuration.md) · [How it works](how-it-works.md) +- **Relative links in the host terminal.** On local sessions, nebula reports the displayed + agent or shell's checkout root through OSC 7, so the host terminal can resolve relative + file links. With no attached session it reports the selected checkout; on exit it restores + nebula's launch directory. This follows checkout changes, not `cd` inside a child shell. + SSH sessions leave the host's directory unchanged rather than label remote paths as local. - **Detached daemon (tmux-style).** A background `nebula` daemon owns every PTY, so agents keep running when the TUI closes. The TUI is a client that attaches over a unix socket (`$XDG_RUNTIME_DIR/nebula/` or `/tmp/nebula-/`, mode 0700). Quit the TUI, relaunch later, and your sessions are still alive