feat: add direct terminal attach stream

This commit is contained in:
Ogulcan Celik 2026-05-17 01:41:17 +03:00
parent a36da363f1
commit 3ecacfddae
16 changed files with 914 additions and 122 deletions

View File

@ -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 <target>` attaches your current terminal directly to one server-owned terminal, like a single-pane terminal attach. `herdr terminal attach <terminal_id>` 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.

View File

@ -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

View File

@ -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);

View File

@ -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.

View File

@ -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,

View File

@ -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,

View File

@ -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>]");

View File

@ -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));
@ -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(

View File

@ -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");

View File

@ -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,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);

View File

@ -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;

View File

@ -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<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>>,
@ -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);

View File

@ -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]

View File

@ -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();

View File

@ -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,

View File

@ -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]