Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 21 additions & 13 deletions crates/nebula-tui/src/event_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -4452,22 +4455,27 @@ fn open_file_link(app: &mut App, path: &str, line: Option<u64>) {
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<std::path::PathBuf> {
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
Expand Down
157 changes: 157 additions & 0 deletions crates/nebula-tui/src/event_loop/host_terminal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<PathBuf>,
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
Expand Down Expand Up @@ -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() {
Expand Down
39 changes: 39 additions & 0 deletions crates/nebula/tests/e2e_tui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const FOOTER_TERMINAL_LOCKED: &str = "^q: sessions";
struct TuiHarness {
writer: Box<dyn Write + Send>,
parser: Arc<Mutex<vt100::Parser>>,
output: Arc<Mutex<Vec<u8>>>,
child: Box<dyn portable_pty::Child + Send + Sync>,
runtime_dir: PathBuf,
data_dir: PathBuf,
Expand Down Expand Up @@ -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]);
}
});
Expand All @@ -117,6 +121,7 @@ impl TuiHarness {
Self {
writer,
parser,
output,
child,
runtime_dir,
data_dir,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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();
Expand Down
5 changes: 5 additions & 0 deletions docs/how-it-works.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

<sub>[← README](../README.md) · [Keys](keys.md) · [Commands](commands.md) · [Sessions](sessions.md) · [Configuration](configuration.md) · [How it works](how-it-works.md)</sub>

- **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-<uid>/`, mode 0700). Quit the TUI, relaunch later, and your sessions are still alive
Expand Down