From 3ecacfddae86d603bfd0d1a0c01602b1d34b1df3 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Sun, 17 May 2026 01:41:17 +0300 Subject: [PATCH] feat: add direct terminal attach stream --- README.md | 6 + src/app/input/mouse.rs | 4 +- src/app/input/navigate.rs | 5 +- src/app/input/terminal.rs | 45 ++-- src/app/mod.rs | 1 + src/app/state.rs | 6 +- src/cli.rs | 87 +++++++ src/client/mod.rs | 210 +++++++++++++++-- src/kitty_graphics.rs | 16 +- src/pane/terminal.rs | 108 ++++++++- src/server/client_transport.rs | 14 ++ src/server/headless.rs | 412 ++++++++++++++++++++++++++++++--- src/server/protocol.rs | 20 ++ src/server/render_stream.rs | 28 +++ src/ui/panes.rs | 54 +++-- src/workspace.rs | 20 +- 16 files changed, 914 insertions(+), 122 deletions(-) diff --git a/README.md b/README.md index 1b5ed7fa..dd8ec7a1 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,12 @@ Host workbox same session, same agents, same state. +### direct agent attach + +`herdr` and `herdr --remote` attach to the full Herdr session UI. `herdr agent attach ` attaches your current terminal directly to one server-owned terminal, like a single-pane terminal attach. `herdr terminal attach ` does the same by terminal id. + +Direct attach streams the current rendered terminal state first, then live ANSI frames. Your input goes straight to that terminal. Detach with `ctrl+b q`; send a literal `ctrl+b` with `ctrl+b ctrl+b`. One writable client owns input and resize for a terminal. A second attach fails unless you pass `--takeover`. + ## agent awareness the sidebar shows which agents are blocked, working, or done. workspaces roll up to their most urgent state so you can scan the full list at a glance. diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs index 10ef9c18..827e37e3 100644 --- a/src/app/input/mouse.rs +++ b/src/app/input/mouse.rs @@ -1143,10 +1143,10 @@ impl AppState { } fn forward_pane_reported_wheel(&self, info: &PaneInfo, mouse: MouseEvent) -> bool { - let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) else { + let Some(ws_idx) = self.active else { return false; }; - let Some(rt) = ws.runtimes.get(&info.id) else { + let Some(rt) = self.runtime_for_pane_in_workspace(ws_idx, info.id) else { return false; }; if !rt diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index 9fc11605..6445b6ab 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -261,8 +261,9 @@ impl App { let pane_id = ws .focused_pane_id() .ok_or_else(|| std::io::Error::other("no focused pane"))?; - let scrollback = ws - .focused_runtime() + let scrollback = self + .state + .runtime_for_pane_in_workspace(ws_idx, pane_id) .ok_or_else(|| std::io::Error::other("focused pane has no scrollback runtime"))? .recent_text(usize::MAX); diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs index 3f0bff2c..90a1a0e2 100644 --- a/src/app/input/terminal.rs +++ b/src/app/input/terminal.rs @@ -508,16 +508,18 @@ mod tests { app.state.mode = Mode::Terminal; app.state.view.pane_infos = pane_infos; - let start_metrics = app.state.workspaces[0] - .runtime(pane_id) + let start_metrics = app + .state + .runtime_for_pane_in_workspace(0, pane_id) .and_then(crate::pane::PaneRuntime::scroll_metrics) .expect("initial scroll metrics"); assert_eq!(start_metrics.offset_from_bottom, 0); app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); - let end_metrics = app.state.workspaces[0] - .runtime(pane_id) + let end_metrics = app + .state + .runtime_for_pane_in_workspace(0, pane_id) .and_then(crate::pane::PaneRuntime::scroll_metrics) .expect("scroll metrics after PageUp"); assert_eq!( @@ -550,8 +552,9 @@ mod tests { app.state.view.pane_infos = pane_infos; app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); - let after_up = app.state.workspaces[0] - .runtime(pane_id) + let after_up = app + .state + .runtime_for_pane_in_workspace(0, pane_id) .and_then(crate::pane::PaneRuntime::scroll_metrics) .expect("scroll metrics after PageUp"); assert!(after_up.offset_from_bottom > 0); @@ -560,8 +563,9 @@ mod tests { KeyCode::PageDown, KeyModifiers::empty(), )); - let after_down = app.state.workspaces[0] - .runtime(pane_id) + let after_down = app + .state + .runtime_for_pane_in_workspace(0, pane_id) .and_then(crate::pane::PaneRuntime::scroll_metrics) .expect("scroll metrics after PageDown"); assert_eq!(after_down.offset_from_bottom, 0); @@ -591,8 +595,9 @@ mod tests { app.state.view.pane_infos = pane_infos; app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); - let after_press = app.state.workspaces[0] - .runtime(pane_id) + let after_press = app + .state + .runtime_for_pane_in_workspace(0, pane_id) .and_then(crate::pane::PaneRuntime::scroll_metrics) .expect("scroll metrics after PageUp press"); assert_eq!( @@ -605,8 +610,9 @@ mod tests { .with_kind(KeyEventKind::Release), ); - let after_release = app.state.workspaces[0] - .runtime(pane_id) + let after_release = app + .state + .runtime_for_pane_in_workspace(0, pane_id) .and_then(crate::pane::PaneRuntime::scroll_metrics) .expect("scroll metrics after PageUp release"); assert_eq!( @@ -640,8 +646,9 @@ mod tests { app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::CONTROL)); - let metrics = app.state.workspaces[0] - .runtime(pane_id) + let metrics = app + .state + .runtime_for_pane_in_workspace(0, pane_id) .and_then(crate::pane::PaneRuntime::scroll_metrics) .expect("scroll metrics after modified PageUp"); assert_eq!(metrics.offset_from_bottom, 0); @@ -672,16 +679,18 @@ mod tests { app.state.mode = Mode::Terminal; app.state.view.pane_infos = pane_infos; - let start_metrics = app.state.workspaces[0] - .runtime(pane_id) + let start_metrics = app + .state + .runtime_for_pane_in_workspace(0, pane_id) .and_then(crate::pane::PaneRuntime::scroll_metrics) .expect("initial scroll metrics"); assert_eq!(start_metrics.offset_from_bottom, 0); app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); - let end_metrics = app.state.workspaces[0] - .runtime(pane_id) + let end_metrics = app + .state + .runtime_for_pane_in_workspace(0, pane_id) .and_then(crate::pane::PaneRuntime::scroll_metrics) .expect("scroll metrics after PageUp"); // Forwarded to pane, so test runtime doesn't process it — scroll stays at bottom. diff --git a/src/app/mod.rs b/src/app/mod.rs index b1fb9775..c5654db2 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -312,6 +312,7 @@ impl App { let mut state = AppState { terminals: std::collections::HashMap::new(), terminal_runtimes: std::collections::HashMap::new(), + direct_attach_resize_locks: std::collections::HashSet::new(), workspaces, active, selected, diff --git a/src/app/state.rs b/src/app/state.rs index daed47f0..ff5357b2 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -822,6 +822,8 @@ pub struct AppState { std::collections::HashMap, pub terminal_runtimes: std::collections::HashMap, + /// Terminal ids whose size is currently owned by a direct attach client. + pub direct_attach_resize_locks: std::collections::HashSet, pub workspaces: Vec, pub active: Option, pub selected: usize, @@ -933,8 +935,7 @@ impl AppState { self.mode == Mode::Terminal && self .active - .and_then(|idx| self.workspaces.get(idx)) - .and_then(crate::workspace::Workspace::focused_runtime) + .and_then(|idx| self.focused_runtime_in_workspace(idx)) .and_then(crate::pane::PaneRuntime::input_state) .is_some_and(crate::pane::InputState::mouse_reporting_enabled) } @@ -1060,6 +1061,7 @@ impl AppState { Self { terminals: std::collections::HashMap::new(), terminal_runtimes: std::collections::HashMap::new(), + direct_attach_resize_locks: std::collections::HashSet::new(), workspaces: Vec::new(), active: None, selected: 0, diff --git a/src/cli.rs b/src/cli.rs index 5d9dbb04..ee73fb6f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -36,6 +36,7 @@ pub fn maybe_run(args: &[String]) -> std::io::Result { "workspace" => run_workspace_command(&args[2..])?, "tab" => run_tab_command(&args[2..])?, "agent" => run_agent_command(&args[2..])?, + "terminal" => run_terminal_command(&args[2..])?, "pane" => run_pane_command(&args[2..])?, "wait" => run_wait_command(&args[2..])?, "integration" => run_integration_command(&args[2..])?, @@ -280,6 +281,7 @@ fn run_agent_command(args: &[String]) -> std::io::Result { "send" => agent_send(&args[1..]), "rename" => agent_rename(&args[1..]), "focus" => agent_focus(&args[1..]), + "attach" => agent_attach(&args[1..]), "start" => agent_start(&args[1..]), "help" | "--help" | "-h" => { print_agent_help(); @@ -292,6 +294,25 @@ fn run_agent_command(args: &[String]) -> std::io::Result { } } +fn run_terminal_command(args: &[String]) -> std::io::Result { + let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { + print_terminal_help(); + return Ok(2); + }; + + match subcommand { + "attach" => terminal_attach(&args[1..]), + "help" | "--help" | "-h" => { + print_terminal_help(); + Ok(0) + } + _ => { + print_terminal_help(); + Ok(2) + } + } +} + fn run_pane_command(args: &[String]) -> std::io::Result { let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { print_pane_help(); @@ -906,6 +927,65 @@ fn agent_focus(args: &[String]) -> std::io::Result { })?) } +fn agent_attach(args: &[String]) -> std::io::Result { + let (target, takeover) = + match parse_attach_target(args, "usage: herdr agent attach [--takeover]") { + Ok(parsed) => parsed, + Err(code) => return Ok(code), + }; + + let response = send_request(&Request { + id: "cli:agent:attach:resolve".into(), + method: Method::AgentGet(AgentTarget { + target: target.clone(), + }), + })?; + if let Some(error) = response.get("error") { + eprintln!("{}", serde_json::to_string(error).unwrap()); + return Ok(1); + } + let Some(terminal_id) = response["result"]["agent"]["terminal_id"].as_str() else { + eprintln!("agent attach failed: response did not include terminal_id"); + return Ok(1); + }; + crate::client::run_terminal_attach(terminal_id.to_owned(), takeover)?; + Ok(0) +} + +fn terminal_attach(args: &[String]) -> std::io::Result { + let (terminal_id, takeover) = match parse_attach_target( + args, + "usage: herdr terminal attach [--takeover]", + ) { + Ok(parsed) => parsed, + Err(code) => return Ok(code), + }; + crate::client::run_terminal_attach(terminal_id, takeover)?; + Ok(0) +} + +fn parse_attach_target(args: &[String], usage: &str) -> Result<(String, bool), i32> { + let Some(target) = args.first() else { + eprintln!("{usage}"); + return Err(2); + }; + let mut takeover = false; + for arg in &args[1..] { + match arg.as_str() { + "--takeover" => takeover = true, + "help" | "--help" | "-h" => { + eprintln!("{usage}"); + return Err(0); + } + other => { + eprintln!("unknown option: {other}"); + return Err(2); + } + } + } + Ok((target.clone(), takeover)) +} + fn agent_rename(args: &[String]) -> std::io::Result { let Some(target) = args.first() else { eprintln!("usage: herdr agent rename |--clear"); @@ -1898,10 +1978,17 @@ fn print_agent_help() { eprintln!(" herdr agent send "); eprintln!(" herdr agent rename |--clear"); eprintln!(" herdr agent focus "); + eprintln!(" herdr agent attach [--takeover]"); eprintln!(" herdr agent start [--cwd PATH] [--workspace ID] [--tab ID] [--split right|down] [--focus|--no-focus] -- "); eprintln!(" targets accept terminal ids, unique agent names, and legacy pane ids"); } +fn print_terminal_help() { + eprintln!("herdr terminal commands:"); + eprintln!(" herdr terminal attach [--takeover]"); + eprintln!(" detach from direct attach with ctrl+b q; send literal ctrl+b with ctrl+b ctrl+b"); +} + fn print_pane_help() { eprintln!("herdr pane commands:"); eprintln!(" herdr pane list [--workspace ]"); diff --git a/src/client/mod.rs b/src/client/mod.rs index 5d52f8b3..c743a24b 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -54,6 +54,54 @@ struct ClientState { sound_config: crate::config::SoundConfig, /// Whether this client may write Kitty graphics bytes to its host terminal. kitty_graphics_enabled: bool, + /// Direct attach prefix escape state. None for full-app clients. + attach_escape: Option, +} + +#[derive(Debug, Default)] +struct AttachEscapeState { + pending_prefix: bool, +} + +#[derive(Debug)] +enum AttachInputAction { + Forward(Vec), + Detach, + None, +} + +impl AttachEscapeState { + fn filter_input(&mut self, data: Vec) -> AttachInputAction { + const PREFIX: u8 = 0x02; // Ctrl+B + + let mut output = Vec::with_capacity(data.len()); + for byte in data { + if self.pending_prefix { + self.pending_prefix = false; + match byte { + b'q' => return AttachInputAction::Detach, + PREFIX => output.push(PREFIX), + other => { + output.push(PREFIX); + output.push(other); + } + } + continue; + } + + if byte == PREFIX { + self.pending_prefix = true; + } else { + output.push(byte); + } + } + + if output.is_empty() { + AttachInputAction::None + } else { + AttachInputAction::Forward(output) + } + } } impl ClientState { @@ -153,22 +201,42 @@ impl From for ClientError { /// /// Returns a guard that restores the terminal when dropped. fn setup_terminal(mouse_capture: bool) -> io::Result { + setup_terminal_with_capabilities(true, mouse_capture) +} + +/// Sets up a direct attach terminal. +/// +/// Direct attach forwards stdin to the attached PTY, so it must not enable +/// outer-terminal protocols that generate local responses or mouse reports. +fn setup_direct_attach_terminal() -> io::Result { + setup_terminal_with_capabilities(false, false) +} + +fn setup_terminal_with_capabilities( + enable_client_protocols: bool, + mouse_capture: bool, +) -> io::Result { ratatui::init(); - if mouse_capture { - execute!(io::stdout(), EnableMouseCapture)?; + + if enable_client_protocols { + if mouse_capture { + execute!(io::stdout(), EnableMouseCapture)?; + } else { + execute!(io::stdout(), DisableMouseCapture)?; + } + execute!( + io::stdout(), + EnableBracketedPaste, + EnableFocusChange, + PushKeyboardEnhancementFlags(crate::input::ime_compatible_keyboard_enhancement_flags()) + )?; } else { execute!(io::stdout(), DisableMouseCapture)?; } - execute!( - io::stdout(), - EnableBracketedPaste, - EnableFocusChange, - PushKeyboardEnhancementFlags(crate::input::ime_compatible_keyboard_enhancement_flags()) - )?; // tmux doesn't understand kitty keyboard protocol push. - // Enable modifyOtherKeys mode 2 for tmux. - let in_tmux = std::env::var("TMUX").is_ok(); + // Enable modifyOtherKeys mode 2 for tmux only for the full app client. + let in_tmux = enable_client_protocols && std::env::var("TMUX").is_ok(); if in_tmux { io::stdout().write_all(b"\x1b[>4;2m")?; io::stdout().flush()?; @@ -311,15 +379,41 @@ enum ClientLoopEvent { /// /// This is the entry point called from `main.rs` when running in client mode. pub fn run_client() -> io::Result<()> { + run_client_with_mode( + requested_render_encoding(), + None, + None, + "connecting to server", + ) +} + +/// Runs a direct terminal attach client. +pub fn run_terminal_attach(terminal_id: String, takeover: bool) -> io::Result<()> { + run_client_with_mode( + RenderEncoding::TerminalAnsi, + Some((terminal_id, takeover)), + Some(AttachEscapeState::default()), + "attaching to terminal", + ) +} + +fn run_client_with_mode( + requested_encoding: RenderEncoding, + attach_request: Option<(String, bool)>, + attach_escape: Option, + log_message: &'static str, +) -> io::Result<()> { init_logging(); let loaded_config = crate::config::Config::load(); let sound_config = loaded_config.config.ui.sound; - let kitty_graphics_enabled = loaded_config.config.experimental.kitty_graphics; + let direct_attach_requested = attach_request.is_some(); + let kitty_graphics_enabled = + loaded_config.config.experimental.kitty_graphics && !direct_attach_requested; let socket_path = client_socket_path(); crate::logging::startup("client"); - info!(path = %socket_path.display(), "connecting to server"); + info!(path = %socket_path.display(), "{log_message}"); // Try to connect to the server. let mut stream = match UnixStream::connect(&socket_path) { @@ -336,8 +430,6 @@ pub fn run_client() -> io::Result<()> { let (cols, rows, cell_width_px, cell_height_px) = current_terminal_geometry(kitty_graphics_enabled); - let requested_encoding = requested_render_encoding(); - // Perform handshake while the stream is still in blocking mode. let negotiated_encoding = match do_handshake( &mut stream, @@ -354,10 +446,26 @@ pub fn run_client() -> io::Result<()> { } }; - // Now set up the terminal (raw mode, mouse, keyboard enhancements). - // This must happen AFTER the handshake succeeds, so we don't leave - // the terminal in raw mode if the server rejects us. - let _guard = setup_terminal(false).map_err(|err| { + if let Some((terminal_id, takeover)) = attach_request { + let attach = ClientMessage::AttachTerminal { + terminal_id, + takeover, + }; + if let Err(err) = write_to_server(&mut stream, &attach) { + eprintln!("herdr: failed to request terminal attach: {err}"); + std::process::exit(1); + } + } + + // Now set up the terminal. This must happen AFTER the handshake succeeds, + // so we don't leave the terminal in raw mode if the server rejects us. + let direct_attach = attach_escape.is_some(); + let _guard = if direct_attach { + setup_direct_attach_terminal() + } else { + setup_terminal(false) + } + .map_err(|err| { eprintln!("herdr: failed to set up terminal: {err}"); err })?; @@ -394,6 +502,7 @@ pub fn run_client() -> io::Result<()> { kitty_graphics_enabled, false, negotiated_encoding, + attach_escape, ) .await }); @@ -439,6 +548,7 @@ async fn run_client_loop( kitty_graphics_enabled: bool, mouse_capture_active: bool, negotiated_encoding: RenderEncoding, + attach_escape: Option, ) -> Result<(), ClientError> { let mut state = ClientState { blit_encoder: blit::BlitEncoder::new(), @@ -446,6 +556,7 @@ async fn run_client_loop( reported_size: (cols, rows), sound_config, kitty_graphics_enabled, + attach_escape, }; debug!(?negotiated_encoding, "client render encoding active"); @@ -459,7 +570,9 @@ async fn run_client_loop( input::stdin_reader_loop(stdin_tx, &stdin_quit); }); - query_host_terminal_theme(); + if state.attach_escape.is_none() { + query_host_terminal_theme(); + } // Spawn the resize poller thread. let resize_quit = should_quit.clone(); @@ -503,10 +616,22 @@ async fn run_client_loop( match event { ClientLoopEvent::StdinInput(data) => { - let events = crate::raw_input::parse_raw_input_bytes_sync(&data); - if crate::raw_input::events_require_host_surface_redraw(&events) { - state.request_full_redraw(); - } + let data = if let Some(attach_escape) = &mut state.attach_escape { + match attach_escape.filter_input(data) { + AttachInputAction::Forward(data) => data, + AttachInputAction::Detach => { + let _ = write_to_server(&mut write_stream, &ClientMessage::Detach); + return Ok(()); + } + AttachInputAction::None => continue, + } + } else { + let events = crate::raw_input::parse_raw_input_bytes_sync(&data); + if crate::raw_input::events_require_host_surface_redraw(&events) { + state.request_full_redraw(); + } + data + }; let msg = ClientMessage::Input { data }; if let Err(e) = write_to_server(&mut write_stream, &msg) { return Err(ClientError::ConnectionLost(e)); @@ -1000,6 +1125,45 @@ mod tests { assert_eq!(output, b"\x1b[?25h\x1b[0 q"); } + #[test] + fn attach_escape_detaches_on_prefix_q() { + let mut escape = AttachEscapeState::default(); + assert!(matches!( + escape.filter_input(vec![0x02]), + AttachInputAction::None + )); + assert!(matches!( + escape.filter_input(vec![b'q']), + AttachInputAction::Detach + )); + } + + #[test] + fn attach_escape_sends_literal_prefix_on_double_prefix() { + let mut escape = AttachEscapeState::default(); + assert!(matches!( + escape.filter_input(vec![0x02]), + AttachInputAction::None + )); + match escape.filter_input(vec![0x02]) { + AttachInputAction::Forward(bytes) => assert_eq!(bytes, vec![0x02]), + other => panic!("expected forwarded prefix, got {other:?}"), + } + } + + #[test] + fn attach_escape_forwards_prefix_before_non_escape_key() { + let mut escape = AttachEscapeState::default(); + assert!(matches!( + escape.filter_input(vec![b'a', 0x02]), + AttachInputAction::Forward(bytes) if bytes == b"a" + )); + match escape.filter_input(vec![b'x']) { + AttachInputAction::Forward(bytes) => assert_eq!(bytes, vec![0x02, b'x']), + other => panic!("expected forwarded bytes, got {other:?}"), + } + } + #[test] fn client_error_display_connection_failed() { let err = ClientError::ConnectionFailed(io::Error::new( diff --git a/src/kitty_graphics.rs b/src/kitty_graphics.rs index de1ac58a..47aba364 100644 --- a/src/kitty_graphics.rs +++ b/src/kitty_graphics.rs @@ -362,27 +362,25 @@ fn collect_visible_placements( return Vec::new(); } }; - let tab = match app + if app .workspaces .get(ws_idx) .and_then(crate::workspace::Workspace::active_tab) + .is_none() { - Some(t) => t, - None => { - tracing::debug!(ws_idx, "collect_visible_placements: no active tab"); - return Vec::new(); - } - }; + tracing::debug!(ws_idx, "collect_visible_placements: no active tab"); + return Vec::new(); + } tracing::debug!( ws_idx, - tab_runtimes_len = tab.runtimes.len(), + terminal_runtimes_len = app.terminal_runtimes.len(), pane_infos_len = app.view.pane_infos.len(), "collect_visible_placements: starting iteration" ); let mut placements = Vec::new(); for info in &app.view.pane_infos { - let runtime = match tab.runtimes.get(&info.id) { + let runtime = match app.runtime_for_pane_in_workspace(ws_idx, info.id) { Some(rt) => rt, None => { tracing::debug!(pane_id = ?info.id, "collect_visible_placements: runtime not found"); diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index f0535c7a..efe9c7ce 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -88,6 +88,7 @@ pub(crate) struct GhosttyPaneTerminal { pub(crate) struct GhosttyPaneCore { pub terminal: crate::ghostty::Terminal, pub render_state: crate::ghostty::RenderState, + pub initial_default_foreground: Option, pub initial_default_background: Option, pub host_terminal_theme: crate::terminal_theme::TerminalTheme, pub transient_default_color_owner_pgid: Option, @@ -263,11 +264,12 @@ impl GhosttyPaneTerminal { let mut render_state = crate::ghostty::RenderState::new().map_err(|e| std::io::Error::other(e.to_string()))?; - let initial_default_background = render_state + let initial_colors = render_state .update(&terminal) .ok() - .and_then(|_| render_state.colors().ok()) - .map(|colors| colors.background); + .and_then(|_| render_state.colors().ok()); + let initial_default_foreground = initial_colors.map(|colors| colors.foreground); + let initial_default_background = initial_colors.map(|colors| colors.background); let mut key_encoder = crate::ghostty::KeyEncoder::new().map_err(|e| std::io::Error::other(e.to_string()))?; key_encoder.set_from_terminal(&terminal); @@ -275,6 +277,7 @@ impl GhosttyPaneTerminal { core: Mutex::new(GhosttyPaneCore { terminal, render_state, + initial_default_foreground, initial_default_background, host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), transient_default_color_owner_pgid: None, @@ -705,6 +708,7 @@ impl GhosttyPaneTerminal { return; }; let host_theme = core.host_terminal_theme; + let initial_default_foreground = core.initial_default_foreground; let initial_default_background = core.initial_default_background; let GhosttyPaneCore { terminal, @@ -717,7 +721,9 @@ impl GhosttyPaneTerminal { let colors = render_state.colors().ok(); let default_bg = colors .and_then(|c| ghostty_default_bg(c.background, host_theme, initial_default_background)); - let default_fg = colors.map(|c| ghostty_color(c.foreground)); + let default_fg = colors + .and_then(|c| ghostty_default_fg(c.foreground, host_theme, initial_default_foreground)); + let resolved_fg = colors.map(|c| ghostty_color(c.foreground)); let resolved_bg = colors.map(|c| ghostty_color(c.background)); let mut row_iterator = match crate::ghostty::RowIterator::new() { @@ -745,7 +751,13 @@ impl GhosttyPaneTerminal { let mut x = 0u16; while x < area.width && cells.next() { let wide = cells.wide().unwrap_or(crate::ghostty::CellWide::Narrow); - let style = ghostty_cell_style(&cells, default_fg, default_bg, resolved_bg); + let style = ghostty_cell_style( + &cells, + default_fg, + default_bg, + resolved_fg, + resolved_bg, + ); let symbol = match ghostty_buffer_symbol_into( &cells, wide, @@ -1063,6 +1075,7 @@ fn ghostty_cell_style( cells: &crate::ghostty::RowCellIter<'_>, default_fg: Option, default_bg: Option, + resolved_fg: Option, resolved_bg: Option, ) -> Style { let style_data = cells.style().unwrap_or_default(); @@ -1092,7 +1105,7 @@ fn ghostty_cell_style( bg = resolved_bg; } if fg.is_none() { - fg = default_fg; + fg = resolved_fg; } std::mem::swap(&mut fg, &mut bg); } @@ -1127,6 +1140,24 @@ fn ghostty_cell_style( style.add_modifier(modifiers) } +fn ghostty_default_fg( + color: crate::ghostty::RgbColor, + host_theme: crate::terminal_theme::TerminalTheme, + initial_default_foreground: Option, +) -> Option { + if let Some(host_foreground) = host_theme.foreground { + if host_foreground == terminal_theme_color(color) { + None + } else { + Some(ghostty_color(color)) + } + } else if initial_default_foreground.is_some_and(|initial| initial != color) { + Some(ghostty_color(color)) + } else { + None + } +} + fn ghostty_default_bg( color: crate::ghostty::RgbColor, host_theme: crate::terminal_theme::TerminalTheme, @@ -1737,11 +1768,37 @@ mod tests { let buffer = terminal.backend().buffer(); assert_eq!(buffer[(0, 0)].symbol(), "h"); + assert_eq!(buffer[(0, 0)].style().fg, Some(Color::Reset)); assert_eq!(buffer[(0, 0)].style().bg, Some(Color::Reset)); assert_eq!(buffer[(2, 0)].symbol(), " "); + assert_eq!(buffer[(2, 0)].style().fg, Some(Color::Reset)); assert_eq!(buffer[(2, 0)].style().bg, Some(Color::Reset)); } + #[test] + fn render_keeps_explicit_cell_foreground_when_host_is_unknown() { + let (tx, _rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); + { + let mut core = pane.core.lock().unwrap(); + core.terminal.write(b"\x1b[38;2;68;85;102mhi\x1b[0m"); + } + + let backend = ratatui::backend::TestBackend::new(20, 5); + let mut terminal = ratatui::Terminal::new(backend).unwrap(); + terminal + .draw(|frame| pane.render(frame, Rect::new(0, 0, 20, 5), false)) + .unwrap(); + + let buffer = terminal.backend().buffer(); + let expected_fg = Some(Color::Rgb(0x44, 0x55, 0x66)); + assert_eq!(buffer[(0, 0)].symbol(), "h"); + assert_eq!(buffer[(0, 0)].style().fg, expected_fg); + assert_eq!(buffer[(2, 0)].symbol(), " "); + assert_eq!(buffer[(2, 0)].style().fg, Some(Color::Reset)); + } + #[test] fn render_keeps_explicit_cell_background_when_host_is_unknown() { let (tx, _rx) = mpsc::channel(4); @@ -1872,11 +1929,50 @@ mod tests { let buffer = terminal.backend().buffer(); assert_eq!(buffer[(0, 0)].symbol(), "h"); + assert_eq!(buffer[(0, 0)].style().fg, Some(Color::Reset)); assert_eq!(buffer[(0, 0)].style().bg, Some(Color::Reset)); assert_eq!(buffer[(2, 0)].symbol(), " "); + assert_eq!(buffer[(2, 0)].style().fg, Some(Color::Reset)); assert_eq!(buffer[(2, 0)].style().bg, Some(Color::Reset)); } + #[test] + fn render_keeps_explicit_default_foreground_when_it_differs_from_host() { + let (tx, _rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); + let host_theme = crate::terminal_theme::TerminalTheme { + foreground: Some(crate::terminal_theme::RgbColor { + r: 0xaa, + g: 0xbb, + b: 0xcc, + }), + background: Some(crate::terminal_theme::RgbColor { + r: 0x11, + g: 0x22, + b: 0x33, + }), + }; + pane.apply_host_terminal_theme(host_theme); + { + let mut core = pane.core.lock().unwrap(); + core.terminal.write(b"\x1b]10;rgb:44/55/66\x1b\\hi"); + } + + let backend = ratatui::backend::TestBackend::new(20, 5); + let mut terminal = ratatui::Terminal::new(backend).unwrap(); + terminal + .draw(|frame| pane.render(frame, Rect::new(0, 0, 20, 5), false)) + .unwrap(); + + let buffer = terminal.backend().buffer(); + let expected_fg = Some(Color::Rgb(0x44, 0x55, 0x66)); + assert_eq!(buffer[(0, 0)].symbol(), "h"); + assert_eq!(buffer[(0, 0)].style().fg, expected_fg); + assert_eq!(buffer[(2, 0)].symbol(), " "); + assert_eq!(buffer[(2, 0)].style().fg, expected_fg); + } + #[test] fn render_keeps_explicit_default_background_when_it_differs_from_host() { let (tx, _rx) = mpsc::channel(4); diff --git a/src/server/client_transport.rs b/src/server/client_transport.rs index 348d00c7..971c879f 100644 --- a/src/server/client_transport.rs +++ b/src/server/client_transport.rs @@ -58,6 +58,12 @@ pub(crate) enum ServerEvent { }, /// A client sent an input message. ClientInput { client_id: u64, data: Vec }, + /// A client requested direct attach to one terminal. + ClientAttachTerminal { + client_id: u64, + terminal_id: String, + takeover: bool, + }, /// A client sent a resize message. ClientResize { client_id: u64, @@ -354,6 +360,14 @@ fn client_read_loop( } } ClientMessage::Detach => ServerEvent::ClientDetach { client_id }, + ClientMessage::AttachTerminal { + terminal_id, + takeover, + } => ServerEvent::ClientAttachTerminal { + client_id, + terminal_id, + takeover, + }, ClientMessage::Hello { .. } => { // Duplicate Hello — ignore. continue; diff --git a/src/server/headless.rs b/src/server/headless.rs index fbfef251..d1c8d144 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -28,6 +28,7 @@ use tokio::sync::mpsc; use tracing::{debug, error, info, warn}; use base64::Engine; +use bytes::Bytes; use crate::api; use crate::app; @@ -200,8 +201,24 @@ fn derive_client_socket_from_api_socket(api_socket_path: &Path) -> PathBuf { // Connected client state // --------------------------------------------------------------------------- +#[derive(Debug, Clone, PartialEq, Eq)] +enum ClientConnectionMode { + App, + TerminalAttach { terminal_id: String }, +} + +type RenderTarget = ( + u64, + (u16, u16), + crate::kitty_graphics::HostCellSize, + bool, + ClientConnectionMode, +); + /// A connected client tracked by the server. struct ClientConnection { + /// Whether this connection is the full app client or a direct terminal attach. + mode: ClientConnectionMode, /// The client's terminal size (after clamping). terminal_size: (u16, u16), /// Pixel size of one client terminal cell. @@ -225,6 +242,7 @@ struct ClientConnection { } impl ClientConnection { + #[cfg(test)] fn new( terminal_size: (u16, u16), cell_size: crate::kitty_graphics::HostCellSize, @@ -233,8 +251,31 @@ impl ClientConnection { last_activity: u64, render_encoding: RenderEncoding, writer: Option, + ) -> Self { + Self::new_with_mode( + ClientConnectionMode::App, + terminal_size, + cell_size, + host_terminal_theme, + outer_terminal_focus, + last_activity, + render_encoding, + writer, + ) + } + + fn new_with_mode( + mode: ClientConnectionMode, + terminal_size: (u16, u16), + cell_size: crate::kitty_graphics::HostCellSize, + host_terminal_theme: crate::terminal_theme::TerminalTheme, + outer_terminal_focus: Option, + last_activity: u64, + render_encoding: RenderEncoding, + writer: Option, ) -> Self { Self { + mode, terminal_size, cell_size, host_terminal_theme, @@ -291,6 +332,8 @@ pub struct HeadlessServer { next_client_id: u64, /// The client currently driving the shared pane runtime size and theme. foreground_client_id: Option, + /// Writable direct attach owner per terminal id string. + terminal_attach_owners: HashMap, /// Monotonic activity counter used to pick the most recently active client. next_activity_stamp: u64, /// Shared pane runtime size derived from the foreground client, @@ -336,6 +379,7 @@ impl HeadlessServer { clients: HashMap::new(), next_client_id: 1, foreground_client_id: None, + terminal_attach_owners: HashMap::new(), next_activity_stamp: 1, effective_size: (MIN_COLS, MIN_ROWS), shutting_down: false, @@ -596,6 +640,7 @@ impl HeadlessServer { let next_foreground = self .clients .iter() + .filter(|(_, client)| matches!(client.mode, ClientConnectionMode::App)) .max_by_key(|(_, client)| client.last_activity) .map(|(&client_id, _)| client_id); let changed = next_foreground != self.foreground_client_id; @@ -607,7 +652,20 @@ impl HeadlessServer { fn remove_client(&mut self, client_id: u64) -> bool { let was_foreground = self.foreground_client_id == Some(client_id); self.send_client_graphics_cleanup(client_id); - self.clients.remove(&client_id); + let removed = self.clients.remove(&client_id); + if let Some(ClientConnection { + mode: ClientConnectionMode::TerminalAttach { terminal_id }, + .. + }) = removed + { + self.terminal_attach_owners.remove(&terminal_id); + if let Some(terminal_id) = self.terminal_id_by_string(&terminal_id) { + self.app + .state + .direct_attach_resize_locks + .remove(&terminal_id); + } + } if was_foreground { self.promote_latest_remaining_client() } else { @@ -759,6 +817,23 @@ impl HeadlessServer { changed } + fn terminal_id_by_string(&self, terminal_id: &str) -> Option { + self.app + .state + .terminals + .keys() + .find(|id| id.to_string() == terminal_id) + .cloned() + } + + fn runtime_for_terminal_id_string( + &self, + terminal_id: &str, + ) -> Option<&crate::terminal::TerminalRuntime> { + let terminal_id = self.terminal_id_by_string(terminal_id)?; + self.app.state.terminal_runtimes.get(&terminal_id) + } + fn pane_effective_state(&self, pane_id: crate::layout::PaneId) -> crate::detect::AgentState { self.app .state @@ -1000,6 +1075,26 @@ impl HeadlessServer { true } + AppEvent::PaneDied { pane_id } => { + let terminal_id = self.app.state.workspaces.iter().find_map(|ws| { + ws.tabs.iter().find_map(|tab| { + tab.panes + .get(pane_id) + .map(|pane| pane.attached_terminal_id.to_string()) + }) + }); + + self.app.handle_internal_event(ev); + + if let Some(terminal_id) = terminal_id { + self.shutdown_terminal_attach_clients( + &terminal_id, + format!("terminal {terminal_id} exited"), + ); + } + + true + } _ => { self.app.handle_internal_event(ev); true @@ -1130,6 +1225,104 @@ impl HeadlessServer { } } + fn shutdown_terminal_attach_clients(&mut self, terminal_id: &str, reason: String) { + let client_ids: Vec = self + .clients + .iter() + .filter_map(|(&client_id, client)| match &client.mode { + ClientConnectionMode::TerminalAttach { + terminal_id: attached, + } if attached == terminal_id => Some(client_id), + _ => None, + }) + .collect(); + + for client_id in client_ids { + self.send_to_client( + client_id, + ServerMessage::ServerShutdown { + reason: Some(reason.clone()), + }, + ); + let foreground_changed = self.remove_client(client_id); + if foreground_changed { + self.resize_shared_runtime_to_effective_size(); + } + } + } + + fn attach_terminal_client( + &mut self, + client_id: u64, + terminal_id: String, + takeover: bool, + ) -> bool { + let Some(real_terminal_id) = self.terminal_id_by_string(&terminal_id) else { + self.send_to_client( + client_id, + ServerMessage::ServerShutdown { + reason: Some(format!( + "terminal attach failed: terminal {terminal_id} not found" + )), + }, + ); + self.remove_client(client_id); + return false; + }; + + if let Some(existing_owner) = self.terminal_attach_owners.get(&terminal_id).copied() { + if existing_owner != client_id && !takeover { + self.send_to_client( + client_id, + ServerMessage::ServerShutdown { + reason: Some(format!( + "terminal attach failed: terminal {terminal_id} already has an attached client; retry with --takeover" + )), + }, + ); + self.remove_client(client_id); + return false; + } + if existing_owner != client_id { + self.send_to_client( + existing_owner, + ServerMessage::ServerShutdown { + reason: Some("terminal attach taken over".to_owned()), + }, + ); + self.remove_client(existing_owner); + } + } + + let stamp = self.allocate_activity_stamp(); + let Some(client) = self.clients.get_mut(&client_id) else { + return false; + }; + let (cols, rows) = client.terminal_size; + let cell_size = client.cell_size; + client.mode = ClientConnectionMode::TerminalAttach { + terminal_id: terminal_id.clone(), + }; + client.render_state.reset_baseline(); + client.last_activity = stamp; + let was_foreground = self.foreground_client_id == Some(client_id); + if was_foreground { + self.promote_latest_remaining_client(); + } + + info!(client_id, cols, rows, terminal_id = %terminal_id, "terminal attach client connected"); + self.terminal_attach_owners + .insert(terminal_id.clone(), client_id); + self.app + .state + .direct_attach_resize_locks + .insert(real_terminal_id.clone()); + if let Some(runtime) = self.app.state.terminal_runtimes.get(&real_terminal_id) { + runtime.resize(rows, cols, cell_size.width_px, cell_size.height_px); + } + true + } + /// Handles a server event. Returns true if the event requires a re-render. fn handle_server_event(&mut self, ev: ServerEvent) -> bool { match ev { @@ -1154,7 +1347,8 @@ impl HeadlessServer { let last_activity = self.allocate_activity_stamp(); self.clients.insert( client_id, - ClientConnection::new( + ClientConnection::new_with_mode( + ClientConnectionMode::App, (cols, rows), crate::kitty_graphics::HostCellSize { width_px: cell_width_px, @@ -1172,8 +1366,25 @@ impl HeadlessServer { self.resize_shared_runtime_to_effective_size(); true } + ServerEvent::ClientAttachTerminal { + client_id, + terminal_id, + takeover, + } => self.attach_terminal_client(client_id, terminal_id, takeover), ServerEvent::ClientInput { client_id, data } => { debug!(client_id, len = data.len(), "client input received"); + if let Some(ClientConnection { + mode: ClientConnectionMode::TerminalAttach { terminal_id }, + .. + }) = self.clients.get(&client_id) + { + if let Some(runtime) = self.runtime_for_terminal_id_string(terminal_id) { + if let Err(err) = runtime.try_send_bytes(Bytes::from(data)) { + warn!(client_id, terminal_id = %terminal_id, err = %err, "terminal attach input failed"); + } + } + return true; + } let events = crate::raw_input::parse_raw_input_bytes_sync(&data); let host_surface_redraw = crate::raw_input::events_require_host_surface_redraw(&events); @@ -1251,6 +1462,30 @@ impl HeadlessServer { client_id, cols, rows, cell_width_px, cell_height_px, "client resize" ); + let direct_terminal_id = if let Some(ClientConnection { + mode: ClientConnectionMode::TerminalAttach { terminal_id }, + terminal_size, + cell_size, + render_state, + .. + }) = self.clients.get_mut(&client_id) + { + *terminal_size = (cols, rows); + *cell_size = crate::kitty_graphics::HostCellSize { + width_px: cell_width_px, + height_px: cell_height_px, + }; + render_state.reset_baseline(); + Some(terminal_id.clone()) + } else { + None + }; + if let Some(terminal_id) = direct_terminal_id { + if let Some(runtime) = self.runtime_for_terminal_id_string(&terminal_id) { + runtime.resize(rows, cols, cell_width_px, cell_height_px); + } + return true; + } if let Some(client) = self.clients.get_mut(&client_id) { client.terminal_size = (cols, rows); client.cell_size = crate::kitty_graphics::HostCellSize { @@ -1508,6 +1743,9 @@ impl HeadlessServer { let mut broken_clients: Vec = Vec::new(); for (&client_id, client) in &mut self.clients { + if !matches!(client.mode, ClientConnectionMode::App) { + continue; + } if client.host_mouse_capture_active == Some(enabled) { continue; } @@ -1537,21 +1775,23 @@ impl HeadlessServer { /// frames to all connected clients. fn render_and_stream(&mut self) { let foreground_client_id = self.foreground_client_id; - let mut render_targets: Vec<(u64, (u16, u16), crate::kitty_graphics::HostCellSize, bool)> = - self.clients - .iter() - .filter(|(_, client)| client.writer.is_some()) - .map(|(&client_id, client)| { - ( - client_id, - client.terminal_size, - client.cell_size, - foreground_client_id == Some(client_id), - ) - }) - .collect(); + let mut render_targets: Vec = self + .clients + .iter() + .filter(|(_, client)| client.writer.is_some()) + .map(|(&client_id, client)| { + ( + client_id, + client.terminal_size, + client.cell_size, + foreground_client_id == Some(client_id), + client.mode.clone(), + ) + }) + .collect(); - render_targets.sort_by_key(|(client_id, _, _, is_foreground)| (*is_foreground, *client_id)); + render_targets + .sort_by_key(|(client_id, _, _, is_foreground, _)| (*is_foreground, *client_id)); if render_targets.is_empty() { let (cols, rows) = self.effective_size; @@ -1570,32 +1810,55 @@ impl HeadlessServer { } let mut broken_clients: Vec = Vec::new(); - for (client_id, (cols, rows), cell_size, is_foreground) in render_targets { + for (client_id, (cols, rows), cell_size, is_foreground, mode) in render_targets { let area = Rect::new(0, 0, cols, rows); - let (buffer, cursor) = if self.app.state.kitty_graphics_enabled && cell_size.is_known() - { - crate::server::render_stream::render_virtual_with_cell_size( - &mut self.app.state, - area, - is_foreground, - cell_size, - ) - } else { - crate::server::render_stream::render_virtual( - &mut self.app.state, - area, - is_foreground, - ) + let is_app_client = matches!(mode, ClientConnectionMode::App); + let mut frame = match mode { + ClientConnectionMode::App => { + let (buffer, cursor) = + if self.app.state.kitty_graphics_enabled && cell_size.is_known() { + crate::server::render_stream::render_virtual_with_cell_size( + &mut self.app.state, + area, + is_foreground, + cell_size, + ) + } else { + crate::server::render_stream::render_virtual( + &mut self.app.state, + area, + is_foreground, + ) + }; + let hyperlinks = + crate::server::render_stream::visible_hyperlinks(&self.app.state); + FrameData::from_ratatui_buffer_with_hyperlinks(&buffer, cursor, &hyperlinks) + } + ClientConnectionMode::TerminalAttach { terminal_id } => { + let Some(runtime) = self.runtime_for_terminal_id_string(&terminal_id) else { + self.send_to_client( + client_id, + ServerMessage::ServerShutdown { + reason: Some(format!( + "terminal attach ended: terminal {terminal_id} not found" + )), + }, + ); + broken_clients.push(client_id); + continue; + }; + let (buffer, cursor) = + crate::server::render_stream::render_terminal_virtual(runtime, area); + let hyperlinks = runtime.visible_hyperlinks(area); + FrameData::from_ratatui_buffer_with_hyperlinks(&buffer, cursor, &hyperlinks) + } }; - let hyperlinks = crate::server::render_stream::visible_hyperlinks(&self.app.state); - let mut frame = - FrameData::from_ratatui_buffer_with_hyperlinks(&buffer, cursor, &hyperlinks); let Some(client) = self.clients.get_mut(&client_id) else { continue; }; let mut next_graphics_cache = client.graphics_cache.clone(); - if self.app.state.kitty_graphics_enabled && cell_size.is_known() { + if is_app_client && self.app.state.kitty_graphics_enabled && cell_size.is_known() { frame.graphics = crate::kitty_graphics::encode_local_pane_graphics( &self.app.state, cell_size, @@ -1999,6 +2262,7 @@ mod tests { clients: HashMap::new(), next_client_id: 1, foreground_client_id: None, + terminal_attach_owners: HashMap::new(), next_activity_stamp: 1, effective_size: (MIN_COLS, MIN_ROWS), shutting_down: false, @@ -2020,6 +2284,13 @@ mod tests { } } + fn read_server_shutdown_reason(bytes: Vec) -> Option { + match read_server_message(bytes) { + ServerMessage::ServerShutdown { reason } => reason, + other => panic!("expected shutdown, got {other:?}"), + } + } + fn test_client_writer() -> ( ClientWriter, std::sync::mpsc::Receiver>, @@ -2037,6 +2308,77 @@ mod tests { ) } + #[test] + fn terminal_attach_rejects_missing_terminal_and_removes_client() { + let mut server = test_headless_server(); + let (writer, control_rx, _render_rx) = test_client_writer(); + + assert!(server.handle_server_event(ServerEvent::ClientConnected { + client_id: 7, + cols: 80, + rows: 24, + cell_width_px: 0, + cell_height_px: 0, + render_encoding: RenderEncoding::TerminalAnsi, + writer, + })); + assert!(server.clients.contains_key(&7)); + + assert!( + !server.handle_server_event(ServerEvent::ClientAttachTerminal { + client_id: 7, + terminal_id: "term_missing".to_owned(), + takeover: false, + }) + ); + assert!(!server.clients.contains_key(&7)); + let reason = read_server_shutdown_reason(control_rx.recv().expect("shutdown message")); + assert_eq!( + reason, + Some("terminal attach failed: terminal term_missing not found".to_owned()) + ); + } + + #[test] + fn terminal_attach_client_exits_when_attached_pane_dies() { + let mut server = test_headless_server(); + let workspace = crate::workspace::Workspace::test_new("attached"); + let pane_id = workspace.tabs[0].root_pane; + server.app.state.workspaces = vec![workspace]; + server.app.state.ensure_test_terminals(); + let terminal_id = server.app.state.workspaces[0] + .pane_state(pane_id) + .expect("pane") + .attached_terminal_id + .to_string(); + let (writer, control_rx, _render_rx) = test_client_writer(); + + assert!(server.handle_server_event(ServerEvent::ClientConnected { + client_id: 7, + cols: 80, + rows: 24, + cell_width_px: 0, + cell_height_px: 0, + render_encoding: RenderEncoding::TerminalAnsi, + writer, + })); + assert!( + server.handle_server_event(ServerEvent::ClientAttachTerminal { + client_id: 7, + terminal_id: terminal_id.clone(), + takeover: false, + }) + ); + assert_eq!(server.terminal_attach_owners.get(&terminal_id), Some(&7)); + + assert!(server.handle_internal_event_with_forwarding(AppEvent::PaneDied { pane_id })); + + assert!(!server.clients.contains_key(&7)); + assert!(!server.terminal_attach_owners.contains_key(&terminal_id)); + let reason = read_server_shutdown_reason(control_rx.recv().expect("shutdown message")); + assert_eq!(reason, Some(format!("terminal {terminal_id} exited"))); + } + #[test] fn client_socket_path_derived_from_api_socket_override() { let path = client_socket_path_from_overrides(Some("/tmp/test-herdr.sock"), None); diff --git a/src/server/protocol.rs b/src/server/protocol.rs index 7d0d3e2d..6745b0db 100644 --- a/src/server/protocol.rs +++ b/src/server/protocol.rs @@ -79,6 +79,14 @@ pub enum ClientMessage { /// Graceful disconnect request. Detach, + + /// Switch this connection into direct terminal attach mode. + AttachTerminal { + /// Terminal id to attach to. + terminal_id: String, + /// Replace an existing writable attach owner for this terminal. + takeover: bool, + }, } // --------------------------------------------------------------------------- @@ -647,6 +655,18 @@ mod tests { assert_eq!(msg, decoded); } + #[test] + fn client_attach_terminal_roundtrip() { + let msg = ClientMessage::AttachTerminal { + terminal_id: "term_123".to_owned(), + takeover: true, + }; + let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); + let (decoded, _): (ClientMessage, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); + assert_eq!(msg, decoded); + } + // ---- Round-trip: ServerMessage ---- #[test] diff --git a/src/server/render_stream.rs b/src/server/render_stream.rs index 94a43980..d82eb855 100644 --- a/src/server/render_stream.rs +++ b/src/server/render_stream.rs @@ -258,6 +258,34 @@ pub(crate) fn render_virtual_with_cell_size( (buffer, cursor) } +/// Renders one server-owned terminal directly for `terminal attach` clients. +pub(crate) fn render_terminal_virtual( + runtime: &crate::terminal::TerminalRuntime, + area: Rect, +) -> (ratatui::buffer::Buffer, Option) { + let backend = CursorTrackingBackend::new(area.width, area.height); + let mut terminal = ratatui::Terminal::new(backend).expect("TestBackend::new should never fail"); + + terminal + .draw(|frame| { + runtime.render(frame, area, true); + }) + .expect("render to TestBackend should never fail"); + + let buffer = terminal.backend().buffer().clone(); + let cursor = runtime + .cursor_state(area, true) + .map(|cursor| CursorState { + x: cursor.x, + y: cursor.y, + visible: cursor.visible && !crate::ui::pane_is_scrolled_back(runtime), + shape: cursor.shape, + }) + .or_else(|| terminal.backend().rendered_cursor()); + + (buffer, cursor) +} + pub(crate) fn visible_hyperlinks(app_state: &AppState) -> Vec<((u16, u16), String, String)> { let Some(ws_idx) = app_state.active else { return Vec::new(); diff --git a/src/ui/panes.rs b/src/ui/panes.rs index c90183ea..e149362c 100644 --- a/src/ui/panes.rs +++ b/src/ui/panes.rs @@ -59,13 +59,15 @@ fn runtime_for_tab_pane<'a>( app: &'a AppState, tab: &'a crate::workspace::Tab, pane_id: crate::layout::PaneId, -) -> Option<&'a TerminalRuntime> { +) -> Option<(&'a crate::terminal::TerminalId, &'a TerminalRuntime)> { + let terminal_id = tab.terminal_id(pane_id)?; #[cfg(test)] if let Some(runtime) = tab.runtimes.get(&pane_id) { - return Some(runtime); + return Some((terminal_id, runtime)); } - let terminal_id = tab.terminal_id(pane_id)?; - app.terminal_runtimes.get(terminal_id) + app.terminal_runtimes + .get(terminal_id) + .map(|runtime| (terminal_id, runtime)) } fn stable_scrollbar_gutter(rt: &TerminalRuntime, pane_inner: Rect) -> (Rect, Option) { @@ -98,14 +100,16 @@ pub(super) fn resize_tab_panes( if tab.zoomed { let focused_id = tab.layout.focused(); - if let Some(rt) = runtime_for_tab_pane(app, tab, focused_id) { + if let Some((terminal_id, rt)) = runtime_for_tab_pane(app, tab, focused_id) { let inner_rect = stable_terminal_inner_rect(area); - rt.resize( - inner_rect.height, - inner_rect.width, - cell_size.width_px, - cell_size.height_px, - ); + if !app.direct_attach_resize_locks.contains(terminal_id) { + rt.resize( + inner_rect.height, + inner_rect.width, + cell_size.width_px, + cell_size.height_px, + ); + } } return; } @@ -117,14 +121,16 @@ pub(super) fn resize_tab_panes( area }; - if let Some(rt) = runtime_for_tab_pane(app, tab, info.id) { + if let Some((terminal_id, rt)) = runtime_for_tab_pane(app, tab, info.id) { let inner_rect = stable_terminal_inner_rect(pane_inner); - rt.resize( - inner_rect.height, - inner_rect.width, - cell_size.width_px, - cell_size.height_px, - ); + if !app.direct_attach_resize_locks.contains(terminal_id) { + rt.resize( + inner_rect.height, + inner_rect.width, + cell_size.width_px, + cell_size.height_px, + ); + } } } } @@ -152,7 +158,11 @@ pub(super) fn compute_pane_infos( let mut scrollbar_rect = None; if let Some(rt) = app.runtime_for_pane_in_workspace(ws_idx, focused_id) { (inner_rect, scrollbar_rect) = stable_scrollbar_gutter(rt, area); - if resize_panes { + if resize_panes + && ws.terminal_id(focused_id).is_some_and(|terminal_id| { + !app.direct_attach_resize_locks.contains(terminal_id) + }) + { rt.resize( inner_rect.height, inner_rect.width, @@ -191,7 +201,11 @@ pub(super) fn compute_pane_infos( let mut scrollbar_rect = None; if let Some(rt) = app.runtime_for_pane_in_workspace(ws_idx, info.id) { (inner_rect, scrollbar_rect) = stable_scrollbar_gutter(rt, pane_inner); - if resize_panes { + if resize_panes + && ws.terminal_id(info.id).is_some_and(|terminal_id| { + !app.direct_attach_resize_locks.contains(terminal_id) + }) + { rt.resize( inner_rect.height, inner_rect.width, diff --git a/src/workspace.rs b/src/workspace.rs index 553206c4..1d8842c8 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -720,15 +720,25 @@ mod tests { #[test] fn workspace_identity_uses_identity_cwd() { + let root = std::env::temp_dir().join(format!( + "herdr-workspace-identity-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let identity_cwd = root.join("pion"); + std::fs::create_dir_all(identity_cwd.join(".git")).unwrap(); + let mut ws = Workspace::test_new("ignored"); ws.custom_name = None; - ws.identity_cwd = PathBuf::from("/herdr-test/pion"); + ws.identity_cwd = identity_cwd.clone(); assert_eq!(ws.display_name(), "pion"); - assert_eq!( - ws.resolved_identity_cwd(), - Some(PathBuf::from("/herdr-test/pion")) - ); + assert_eq!(ws.resolved_identity_cwd(), Some(identity_cwd)); + + std::fs::remove_dir_all(root).unwrap(); } #[test]