aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app/input/mouse.rs4
-rw-r--r--src/app/input/navigate.rs5
-rw-r--r--src/app/input/terminal.rs45
-rw-r--r--src/app/mod.rs1
-rw-r--r--src/app/state.rs6
-rw-r--r--src/cli.rs87
-rw-r--r--src/client/mod.rs210
-rw-r--r--src/kitty_graphics.rs16
-rw-r--r--src/pane/terminal.rs108
-rw-r--r--src/server/client_transport.rs14
-rw-r--r--src/server/headless.rs412
-rw-r--r--src/server/protocol.rs20
-rw-r--r--src/server/render_stream.rs28
-rw-r--r--src/ui/panes.rs54
-rw-r--r--src/workspace.rs20
15 files changed, 908 insertions, 122 deletions
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<crate::terminal::TerminalId, crate::terminal::TerminalState>,
pub terminal_runtimes:
std::collections::HashMap<crate::terminal::TerminalId, crate::terminal::TerminalRuntime>,
+ /// Terminal ids whose size is currently owned by a direct attach client.
+ pub direct_attach_resize_locks: std::collections::HashSet<crate::terminal::TerminalId>,
pub workspaces: Vec<Workspace>,
pub active: Option<usize>,
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<CommandOutcome> {
"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<i32> {
"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<i32> {
}
}
+fn run_terminal_command(args: &[String]) -> std::io::Result<i32> {
+ 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<i32> {
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<i32> {
})?)
}
+fn agent_attach(args: &[String]) -> std::io::Result<i32> {
+ let (target, takeover) =
+ match parse_attach_target(args, "usage: herdr agent attach <target> [--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<i32> {
+ let (terminal_id, takeover) = match parse_attach_target(
+ args,
+ "usage: herdr terminal attach <terminal_id> [--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<i32> {
let Some(target) = args.first() else {
eprintln!("usage: herdr agent rename <target> <name>|--clear");
@@ -1898,10 +1978,17 @@ fn print_agent_help() {
eprintln!(" herdr agent send <target> <text>");
eprintln!(" herdr agent rename <target> <name>|--clear");
eprintln!(" herdr agent focus <target>");
+ eprintln!(" herdr agent attach <target> [--takeover]");
eprintln!(" herdr agent start <name> [--cwd PATH] [--workspace ID] [--tab ID] [--split right|down] [--focus|--no-focus] -- <argv...>");
eprintln!(" targets accept terminal ids, unique agent names, and legacy pane ids");
}
+fn print_terminal_help() {
+ eprintln!("herdr terminal commands:");
+ eprintln!(" herdr terminal attach <terminal_id> [--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 <workspace_id>]");
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<AttachEscapeState>,
+}
+
+#[derive(Debug, Default)]
+struct AttachEscapeState {
+ pending_prefix: bool,
+}
+
+#[derive(Debug)]
+enum AttachInputAction {
+ Forward(Vec<u8>),
+ Detach,
+ None,
+}
+
+impl AttachEscapeState {
+ fn filter_input(&mut self, data: Vec<u8>) -> 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<protocol::FramingError> for ClientError {
///
/// Returns a guard that restores the terminal when dropped.
fn setup_terminal(mouse_capture: bool) -> io::Result<TerminalGuard> {
+ 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<TerminalGuard> {
+ setup_terminal_with_capabilities(false, false)
+}
+
+fn setup_terminal_with_capabilities(
+ enable_client_protocols: bool,
+ mouse_capture: bool,
+) -> io::Result<TerminalGuard> {
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<AttachEscapeState>,
+ 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<AttachEscapeState>,
) -> 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));
@@ -1001,6 +1126,45 @@ mod tests {
}
#[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(
io::ErrorKind::ConnectionRefused,
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<crate::ghostty::RgbColor>,
pub initial_default_background: Option<crate::ghostty::RgbColor>,
pub host_terminal_theme: crate::terminal_theme::TerminalTheme,
pub transient_default_color_owner_pgid: Option<u32>,
@@ -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<Color>,
default_bg: Option<Color>,
+ resolved_fg: Option<Color>,
resolved_bg: Option<Color>,
) -> 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<crate::ghostty::RgbColor>,
+) -> Option<Color> {
+ 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,12 +1768,38 @@ 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);
let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
@@ -1872,12 +1929,51 @@ 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);
let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
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<u8> },
+ /// 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,
@@ -234,7 +252,30 @@ impl ClientConnection {
render_encoding: RenderEncoding,
writer: Option<ClientWriter>,
) -> 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<bool>,
+ last_activity: u64,
+ render_encoding: RenderEncoding,
+ writer: Option<ClientWriter>,
+ ) -> 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<u64>,
+ /// Writable direct attach owner per terminal id string.
+ terminal_attach_owners: HashMap<String, u64>,
/// 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<crate::terminal::TerminalId> {
+ 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<u64> = 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<u64> = 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<RenderTarget> = 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<u64> = 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<u8>) -> Option<String> {
+ match read_server_message(bytes) {
+ ServerMessage::ServerShutdown { reason } => reason,
+ other => panic!("expected shutdown, got {other:?}"),
+ }
+ }
+
fn test_client_writer() -> (
ClientWriter,
std::sync::mpsc::Receiver<Vec<u8>>,
@@ -2038,6 +2309,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);
assert_eq!(path, PathBuf::from("/tmp/test-herdr-client.sock"));
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<CursorState>) {
+ 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<Rect>) {
@@ -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]