8337 lines
306 KiB
Rust
8337 lines
306 KiB
Rust
//! Headless server mode — runs the herdr event loop without a real terminal.
|
||
//!
|
||
//! The server:
|
||
//! - Does not enter raw mode or read stdin
|
||
//! - Creates and listens on both `herdr.sock` (existing JSON API) and
|
||
//! `herdr-client.sock` (new binary protocol)
|
||
//! - Initializes AppState and all PTYs from session restore or fresh state
|
||
//! - Runs the main event loop (drain events, drain API requests, scheduled tasks)
|
||
//! - Renders to a virtual ratatui Buffer in memory
|
||
//! - Accepts client connections on the client socket
|
||
//! - Streams frames to connected clients after each render
|
||
//! - Routes client input events through the existing input pipeline
|
||
//! - Continues running after client disconnect
|
||
//! - Handles stale socket cleanup, explicit server stop, minimum terminal size,
|
||
//! and pane spawn failure during restore
|
||
|
||
use std::collections::HashMap;
|
||
use std::io;
|
||
use std::path::{Path, PathBuf};
|
||
use std::sync::atomic::{AtomicBool, Ordering};
|
||
use std::sync::Arc;
|
||
use std::time::{Duration, Instant};
|
||
|
||
use crossterm::event::{KeyModifiers, MouseEventKind};
|
||
use interprocess::local_socket::traits::Listener as _;
|
||
#[cfg(windows)]
|
||
use interprocess::local_socket::traits::Stream as _;
|
||
#[cfg(unix)]
|
||
use interprocess::local_socket::ListenerNonblockingMode;
|
||
use ratatui::layout::Rect;
|
||
use tokio::sync::mpsc;
|
||
use tracing::{debug, error, info, warn};
|
||
|
||
use base64::Engine;
|
||
use bytes::Bytes;
|
||
|
||
use crate::api;
|
||
use crate::app;
|
||
use crate::config;
|
||
use crate::events::AppEvent;
|
||
use crate::ipc::{
|
||
bind_local_listener, remove_socket_file_if_owned, socket_file_identity, LocalListener,
|
||
SocketFileIdentity,
|
||
};
|
||
use crate::protocol::{
|
||
self, AttachScrollDirection, AttachScrollSource, FrameData, ServerMessage, MAX_FRAME_SIZE,
|
||
MAX_GRAPHICS_FRAME_SIZE,
|
||
};
|
||
#[cfg(unix)]
|
||
use crate::server::client_accept::{
|
||
accept_pending_client_connections, reject_pending_client_connections,
|
||
};
|
||
use crate::server::client_transport::ServerEvent;
|
||
use crate::server::clients::{
|
||
events_include_interaction, latest_app_client, render_targets, terminal_stream_client_ids,
|
||
ClientConnection, ClientConnectionMode,
|
||
};
|
||
use crate::server::keybindings::{app_keybindings, apply_keybindings};
|
||
use crate::server::notifications::{
|
||
should_forward_toast_to_clients, toast_message_from_state_change, toast_notify_kind,
|
||
};
|
||
use crate::server::socket_paths::{
|
||
client_socket_path, prepare_socket_path, restrict_socket_permissions,
|
||
};
|
||
use crate::server::terminal_attach::paste_payload_for_runtime;
|
||
|
||
#[cfg(test)]
|
||
use crate::protocol::RenderEncoding;
|
||
#[cfg(test)]
|
||
use crate::server::client_transport::ClientWriter;
|
||
#[cfg(test)]
|
||
use std::fs;
|
||
|
||
fn sound_notify_message(sound: crate::sound::Sound) -> &'static str {
|
||
match sound {
|
||
crate::sound::Sound::Done => "agent done",
|
||
crate::sound::Sound::Request => "agent attention",
|
||
}
|
||
}
|
||
|
||
fn notification_show_response_shown(response: &str) -> bool {
|
||
let Ok(response) = serde_json::from_str::<api::schema::SuccessResponse>(response) else {
|
||
return false;
|
||
};
|
||
matches!(
|
||
response.result,
|
||
api::schema::ResponseResult::NotificationShow { shown: true, .. }
|
||
)
|
||
}
|
||
|
||
fn non_empty_body(value: &str) -> Option<String> {
|
||
(!value.is_empty()).then(|| value.to_owned())
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Loop event enum for the headless server event loop
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Events that the headless server event loop can process.
|
||
enum LoopEvent {
|
||
Timer,
|
||
Internal(AppEvent),
|
||
Api(Box<api::ApiRequestMessage>),
|
||
ServerEvent(ServerEvent),
|
||
RenderRequested,
|
||
}
|
||
|
||
fn rect_fits_frame(rect: Rect, frame: &FrameData) -> bool {
|
||
rect.x.saturating_add(rect.width) <= frame.width
|
||
&& rect.y.saturating_add(rect.height) <= frame.height
|
||
}
|
||
|
||
fn apply_terminal_dirty_patch(
|
||
frame: &mut FrameData,
|
||
area: Rect,
|
||
patch: crate::pane::TerminalDirtyPatch,
|
||
) -> bool {
|
||
if !rect_fits_frame(area, frame) {
|
||
return false;
|
||
}
|
||
let width = usize::from(frame.width);
|
||
for (local_y, row_cells) in patch.rows {
|
||
if local_y >= area.height || row_cells.len() != usize::from(area.width) {
|
||
return false;
|
||
}
|
||
let frame_y = area.y + local_y;
|
||
let start = usize::from(frame_y) * width + usize::from(area.x);
|
||
let end = start + usize::from(area.width);
|
||
if end > frame.cells.len() {
|
||
return false;
|
||
}
|
||
frame.cells[start..end].clone_from_slice(&row_cells);
|
||
}
|
||
true
|
||
}
|
||
|
||
fn dirty_patch_intersects_hyperlinks(
|
||
frame: &FrameData,
|
||
area: Rect,
|
||
patch: &crate::pane::TerminalDirtyPatch,
|
||
) -> bool {
|
||
if frame.hyperlinks.is_empty() || !rect_fits_frame(area, frame) {
|
||
return false;
|
||
}
|
||
let width = usize::from(frame.width);
|
||
for (local_y, _) in &patch.rows {
|
||
if *local_y >= area.height {
|
||
return true;
|
||
}
|
||
let frame_y = area.y + *local_y;
|
||
let start = usize::from(frame_y) * width + usize::from(area.x);
|
||
let end = start + usize::from(area.width);
|
||
if end > frame.cells.len() {
|
||
return true;
|
||
}
|
||
if frame.cells[start..end]
|
||
.iter()
|
||
.any(|cell| cell.hyperlink.is_some())
|
||
{
|
||
return true;
|
||
}
|
||
}
|
||
false
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Constants
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Default shared runtime size (columns, rows) when no clients are attached.
|
||
const MIN_COLS: u16 = 80;
|
||
const MIN_ROWS: u16 = 24;
|
||
|
||
/// Timeout for in-flight API requests during shutdown.
|
||
#[allow(dead_code)]
|
||
const SHUTDOWN_API_TIMEOUT: Duration = Duration::from_secs(5);
|
||
|
||
/// How often the idle headless loop wakes to poll the local listener for new
|
||
/// client connections.
|
||
///
|
||
/// The listener is non-blocking and not integrated into `tokio::select!`, so
|
||
/// a low-frequency wake is required to notice new thin-client attaches while
|
||
/// otherwise idle. Keep this much slower than the old resize-poll cadence to
|
||
/// avoid reintroducing the idle CPU spin.
|
||
const CLIENT_ACCEPT_POLL_INTERVAL: Duration = Duration::from_millis(250);
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Headless server
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// The headless server — runs the herdr event loop without a real terminal.
|
||
pub struct HeadlessServer {
|
||
app: app::App,
|
||
#[cfg(unix)]
|
||
api_tx: Option<api::ApiRequestSender>,
|
||
#[cfg(unix)]
|
||
api_server: Option<api::ServerHandle>,
|
||
#[cfg(unix)]
|
||
client_listener: LocalListener,
|
||
client_socket_path: PathBuf,
|
||
client_socket_identity: SocketFileIdentity,
|
||
clients: HashMap<u64, ClientConnection>,
|
||
#[cfg(unix)]
|
||
next_client_id: u64,
|
||
/// The client currently driving the shared pane runtime size, theme, and input keybindings.
|
||
foreground_client_id: Option<u64>,
|
||
/// Server-owned keybindings, restored when foreground clients use server mode.
|
||
server_keybindings: crate::config::LiveKeybindConfig,
|
||
/// Full server config warning shown to clients that use server keybindings.
|
||
server_config_diagnostic: Option<String>,
|
||
/// Server config warning with keybinding diagnostics removed for local-keybinding clients.
|
||
server_config_diagnostic_without_keybindings: Option<String>,
|
||
/// 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,
|
||
/// or MIN_COLS × MIN_ROWS when no clients are connected.
|
||
effective_size: (u16, u16),
|
||
/// Flag set when shutdown is initiated.
|
||
shutting_down: bool,
|
||
/// Flag set while exporting live PTYs to a replacement server.
|
||
handoff_in_progress: bool,
|
||
/// Imported panes get one app-safe resize nudge after the first client attaches.
|
||
#[cfg(unix)]
|
||
pending_handoff_repaint_nudge: bool,
|
||
/// Flag set by Ctrl+C or `server stop` signal.
|
||
should_quit: Arc<AtomicBool>,
|
||
/// Channel for receiving server events from client connection threads.
|
||
server_event_rx: mpsc::Receiver<ServerEvent>,
|
||
/// Sender for server events (cloned for each client thread).
|
||
server_event_tx: mpsc::Sender<ServerEvent>,
|
||
}
|
||
|
||
fn apply_terminal_attach_scroll(
|
||
runtime: &crate::terminal::TerminalRuntime,
|
||
source: AttachScrollSource,
|
||
direction: AttachScrollDirection,
|
||
lines: u16,
|
||
column: Option<u16>,
|
||
row: Option<u16>,
|
||
modifiers: u8,
|
||
) -> Result<(), String> {
|
||
let wheel_kind = match direction {
|
||
AttachScrollDirection::Up => MouseEventKind::ScrollUp,
|
||
AttachScrollDirection::Down => MouseEventKind::ScrollDown,
|
||
};
|
||
if let AttachScrollSource::PageKey { input } = source {
|
||
let host_scroll = runtime.input_state().is_some_and(|input_state| {
|
||
!input_state.alternate_screen && !input_state.mouse_reporting_enabled()
|
||
});
|
||
if host_scroll {
|
||
match direction {
|
||
AttachScrollDirection::Up => runtime.scroll_up(lines.max(1) as usize),
|
||
AttachScrollDirection::Down => runtime.scroll_down(lines.max(1) as usize),
|
||
}
|
||
return Ok(());
|
||
}
|
||
return apply_terminal_attach_input(runtime, input);
|
||
}
|
||
|
||
match runtime.wheel_routing() {
|
||
Some(crate::pane::WheelRouting::MouseReport) => {
|
||
runtime.scroll_reset();
|
||
let column = column.unwrap_or(0);
|
||
let row = row.unwrap_or(0);
|
||
let Some(bytes) = runtime.encode_mouse_wheel(
|
||
wheel_kind,
|
||
column,
|
||
row,
|
||
KeyModifiers::from_bits_truncate(modifiers),
|
||
) else {
|
||
return Err(format!(
|
||
"failed to encode terminal attach mouse wheel event: {wheel_kind:?}"
|
||
));
|
||
};
|
||
runtime
|
||
.try_send_bytes(Bytes::from(bytes))
|
||
.map_err(|err| format!("terminal attach mouse wheel input failed: {err}"))?;
|
||
}
|
||
Some(crate::pane::WheelRouting::AlternateScroll) => {
|
||
runtime.scroll_reset();
|
||
let Some(bytes) = runtime.encode_alternate_scroll(wheel_kind) else {
|
||
return Ok(());
|
||
};
|
||
runtime
|
||
.try_send_bytes(Bytes::from(bytes))
|
||
.map_err(|err| format!("terminal attach alternate scroll input failed: {err}"))?;
|
||
}
|
||
Some(crate::pane::WheelRouting::HostScroll) | None => match direction {
|
||
AttachScrollDirection::Up => runtime.scroll_up(lines.max(1) as usize),
|
||
AttachScrollDirection::Down => runtime.scroll_down(lines.max(1) as usize),
|
||
},
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn apply_terminal_attach_input(
|
||
runtime: &crate::terminal::TerminalRuntime,
|
||
data: Vec<u8>,
|
||
) -> Result<(), String> {
|
||
runtime.scroll_reset();
|
||
runtime
|
||
.try_send_bytes(Bytes::from(data))
|
||
.map_err(|err| format!("terminal attach input failed: {err}"))
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
fn spawn_windows_client_accept_thread(
|
||
listener: LocalListener,
|
||
should_quit: Arc<AtomicBool>,
|
||
server_event_tx: mpsc::Sender<ServerEvent>,
|
||
) {
|
||
std::thread::spawn(move || {
|
||
let mut next_client_id = 1_u64;
|
||
while !should_quit.load(Ordering::Acquire) {
|
||
let stream = match listener.accept() {
|
||
Ok(stream) => stream,
|
||
Err(err) => {
|
||
if should_quit.load(Ordering::Acquire) {
|
||
break;
|
||
}
|
||
error!(err = %err, "client listener accept failed");
|
||
std::thread::sleep(Duration::from_millis(50));
|
||
continue;
|
||
}
|
||
};
|
||
|
||
let client_id = next_client_id;
|
||
next_client_id = next_client_id.saturating_add(1);
|
||
|
||
if let Err(err) = stream.set_nonblocking(true) {
|
||
warn!(err = %err, "failed to set client stream nonblocking");
|
||
continue;
|
||
}
|
||
|
||
let should_quit = should_quit.clone();
|
||
let server_event_tx = server_event_tx.clone();
|
||
std::thread::spawn(move || {
|
||
if let Err(err) = crate::server::client_transport::handle_client_handshake(
|
||
stream,
|
||
client_id,
|
||
&server_event_tx,
|
||
&should_quit,
|
||
) {
|
||
debug!(client_id, err = %err, "client handshake failed");
|
||
}
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
impl HeadlessServer {
|
||
/// Creates and starts the headless server.
|
||
///
|
||
/// This:
|
||
/// 1. Prepares the client socket path (cleans up stale sockets)
|
||
/// 2. Binds the client socket listener
|
||
/// 3. Returns the server ready to run
|
||
pub fn new(
|
||
app: app::App,
|
||
config_diagnostics: &[String],
|
||
api_tx: Option<api::ApiRequestSender>,
|
||
api_server: Option<api::ServerHandle>,
|
||
) -> io::Result<Self> {
|
||
let client_path = client_socket_path();
|
||
prepare_socket_path(&client_path)?;
|
||
|
||
let listener = bind_local_listener(&client_path)?;
|
||
restrict_socket_permissions(&client_path)?;
|
||
let client_socket_identity = socket_file_identity(&client_path)?;
|
||
info!(path = %client_path.display(), "client protocol socket listening");
|
||
|
||
// Set non-blocking on Unix so we can poll it from the event loop.
|
||
#[cfg(unix)]
|
||
listener.set_nonblocking(ListenerNonblockingMode::Accept)?;
|
||
|
||
let should_quit = Arc::new(AtomicBool::new(false));
|
||
|
||
// Channel for server events from client threads.
|
||
let (server_event_tx, server_event_rx) = mpsc::channel(64);
|
||
#[cfg(windows)]
|
||
spawn_windows_client_accept_thread(listener, should_quit.clone(), server_event_tx.clone());
|
||
|
||
let server_keybindings = app_keybindings(&app);
|
||
let (server_config_diagnostic, server_config_diagnostic_without_keybindings) =
|
||
server_config_diagnostic_summaries(config_diagnostics);
|
||
#[cfg(not(unix))]
|
||
let _ = (&api_tx, &api_server);
|
||
|
||
Ok(Self {
|
||
app,
|
||
#[cfg(unix)]
|
||
api_tx,
|
||
#[cfg(unix)]
|
||
api_server,
|
||
#[cfg(unix)]
|
||
client_listener: listener,
|
||
client_socket_path: client_path,
|
||
client_socket_identity,
|
||
clients: HashMap::new(),
|
||
#[cfg(unix)]
|
||
next_client_id: 1,
|
||
foreground_client_id: None,
|
||
server_keybindings,
|
||
server_config_diagnostic,
|
||
server_config_diagnostic_without_keybindings,
|
||
terminal_attach_owners: HashMap::new(),
|
||
next_activity_stamp: 1,
|
||
effective_size: (MIN_COLS, MIN_ROWS),
|
||
shutting_down: false,
|
||
handoff_in_progress: false,
|
||
#[cfg(unix)]
|
||
pending_handoff_repaint_nudge: false,
|
||
should_quit,
|
||
server_event_rx,
|
||
server_event_tx,
|
||
})
|
||
}
|
||
|
||
/// Runs the headless server event loop until shutdown.
|
||
///
|
||
/// This is the server's main loop — analogous to `App::run()` but without
|
||
/// a real terminal. It:
|
||
/// - Drains internal events (pane death, state changes)
|
||
/// - Drains API requests (from the JSON socket)
|
||
/// - Accepts new client connections
|
||
/// - Reads client messages and routes input
|
||
/// - Handles scheduled tasks (resize poll, animation, session save, etc.)
|
||
/// - Renders virtually and streams frames to clients
|
||
pub async fn run(&mut self) -> io::Result<()> {
|
||
crate::logging::startup("server");
|
||
|
||
// Register SIGINT handler for graceful shutdown.
|
||
let should_quit = self.should_quit.clone();
|
||
let quit_notify = self.server_event_tx.clone();
|
||
ctrlc_handler(should_quit, quit_notify);
|
||
|
||
// No input_rx needed — server doesn't read stdin.
|
||
// We use None for input_rx so the event loop doesn't try to read from stdin.
|
||
self.app.input_rx = None;
|
||
|
||
let mut needs_render = true;
|
||
let mut needs_full_render = true;
|
||
|
||
loop {
|
||
crate::render_prof::event("loop.tick");
|
||
crate::render_prof::flush_if_due();
|
||
|
||
// If shutdown has been initiated, complete it and exit.
|
||
if self.shutting_down {
|
||
self.complete_shutdown()?;
|
||
break;
|
||
}
|
||
|
||
// Check if we should start shutting down.
|
||
if self.app.state.should_quit || self.should_quit.load(Ordering::Acquire) {
|
||
self.initiate_shutdown();
|
||
continue;
|
||
}
|
||
|
||
// 1. Check render_dirty flag from PTY reader tasks.
|
||
if self.app.render_dirty.load(Ordering::Acquire) {
|
||
needs_render = true;
|
||
crate::render_prof::event("render.request.pty_dirty");
|
||
}
|
||
|
||
// 2. Drain a bounded internal-event batch. API handlers perform an
|
||
// exhaustive forwarding-aware drain before reading pane/runtime state.
|
||
if self.drain_internal_events_with_forwarding() {
|
||
needs_render = true;
|
||
needs_full_render = true;
|
||
crate::render_prof::event("full_render_cause.internal_events");
|
||
}
|
||
|
||
// 3. Drain API requests.
|
||
if self.drain_api_requests_with_shutdown_check() {
|
||
needs_render = true;
|
||
needs_full_render = true;
|
||
crate::render_prof::event("full_render_cause.api_requests");
|
||
}
|
||
|
||
self.app.sync_focus_events();
|
||
self.app.sync_session_save_schedule();
|
||
|
||
// 4. Accept new client connections.
|
||
self.accept_client_connections()?;
|
||
|
||
// 5. Drain server events from client threads.
|
||
if self.drain_server_events() {
|
||
needs_render = true;
|
||
needs_full_render = true;
|
||
crate::render_prof::event("full_render_cause.server_events");
|
||
}
|
||
|
||
// 6. Handle scheduled tasks.
|
||
let now = Instant::now();
|
||
if self.handle_scheduled_tasks_headless(now, needs_render) {
|
||
needs_render = true;
|
||
needs_full_render = true;
|
||
crate::render_prof::event("full_render_cause.scheduled_tasks");
|
||
}
|
||
|
||
// Handle deferred requests.
|
||
if self.app.state.request_complete_onboarding {
|
||
self.app.state.request_complete_onboarding = false;
|
||
self.app.open_settings_from_onboarding();
|
||
needs_render = true;
|
||
needs_full_render = true;
|
||
crate::render_prof::event("full_render_cause.deferred_onboarding");
|
||
}
|
||
|
||
if self.app.state.request_new_workspace {
|
||
self.app.state.request_new_workspace = false;
|
||
self.app.create_workspace();
|
||
needs_render = true;
|
||
needs_full_render = true;
|
||
crate::render_prof::event("full_render_cause.deferred_new_workspace");
|
||
}
|
||
|
||
if self.app.state.request_new_tab {
|
||
self.app.state.request_new_tab = false;
|
||
self.app.create_tab();
|
||
needs_render = true;
|
||
needs_full_render = true;
|
||
crate::render_prof::event("full_render_cause.deferred_new_tab");
|
||
}
|
||
|
||
if let Some(ws_idx) = self.app.state.request_new_linked_worktree.take() {
|
||
self.app.open_new_linked_worktree_dialog(ws_idx);
|
||
needs_render = true;
|
||
needs_full_render = true;
|
||
crate::render_prof::event("full_render_cause.deferred_worktree_dialog");
|
||
}
|
||
|
||
if let Some(ws_idx) = self.app.state.request_open_existing_worktree.take() {
|
||
self.app.open_existing_worktree_dialog(ws_idx);
|
||
needs_render = true;
|
||
needs_full_render = true;
|
||
crate::render_prof::event("full_render_cause.deferred_worktree_dialog");
|
||
}
|
||
|
||
if let Some(cwd) = self.app.state.request_new_workspace_cwd.take() {
|
||
if let Err(err) = self.app.create_workspace_with_events(cwd, true) {
|
||
error!(err = %err, "failed to create workspace at requested cwd");
|
||
self.app.state.mode = app::Mode::Navigate;
|
||
}
|
||
needs_render = true;
|
||
needs_full_render = true;
|
||
crate::render_prof::event("full_render_cause.deferred_workspace_cwd");
|
||
}
|
||
|
||
if let Some(ws_idx) = self.app.state.request_remove_linked_worktree.take() {
|
||
self.app.open_remove_linked_worktree_confirmation(ws_idx);
|
||
needs_render = true;
|
||
needs_full_render = true;
|
||
crate::render_prof::event("full_render_cause.deferred_worktree_dialog");
|
||
}
|
||
|
||
if self.app.state.request_submit_worktree_create {
|
||
self.app.state.request_submit_worktree_create = false;
|
||
self.app.submit_worktree_create_via_api();
|
||
needs_render = true;
|
||
needs_full_render = true;
|
||
crate::render_prof::event("full_render_cause.deferred_worktree_submit");
|
||
}
|
||
|
||
if self.app.state.request_submit_worktree_open {
|
||
self.app.state.request_submit_worktree_open = false;
|
||
self.app.submit_worktree_open_via_api();
|
||
needs_render = true;
|
||
needs_full_render = true;
|
||
crate::render_prof::event("full_render_cause.deferred_worktree_submit");
|
||
}
|
||
|
||
if self.app.state.request_submit_worktree_remove {
|
||
self.app.state.request_submit_worktree_remove = false;
|
||
self.app.submit_worktree_remove_via_api();
|
||
needs_render = true;
|
||
needs_full_render = true;
|
||
crate::render_prof::event("full_render_cause.deferred_worktree_submit");
|
||
}
|
||
|
||
if self.app.state.request_reload_config {
|
||
self.app.state.request_reload_config = false;
|
||
self.reload_server_config(true);
|
||
needs_render = true;
|
||
needs_full_render = true;
|
||
crate::render_prof::event("full_render_cause.config_reload");
|
||
}
|
||
|
||
if latest_app_client(&self.clients).is_some() && self.app.ensure_default_workspace() {
|
||
needs_render = true;
|
||
needs_full_render = true;
|
||
crate::render_prof::event("full_render_cause.default_workspace");
|
||
}
|
||
|
||
self.drain_client_config_reload_request();
|
||
self.stream_host_mouse_capture_mode();
|
||
|
||
self.app.sync_headless_animation_timer(now);
|
||
|
||
// 7. Render virtually and stream frames.
|
||
if needs_render && self.app.can_render_now(now) {
|
||
crate::render_prof::event("render.attempt");
|
||
let pty_dirty = self.app.render_dirty.swap(false, Ordering::AcqRel);
|
||
if pty_dirty {
|
||
crate::render_prof::event("render.attempt.pty_dirty");
|
||
}
|
||
if needs_full_render {
|
||
crate::render_prof::event("retained_gate.needs_full_render");
|
||
} else if !pty_dirty {
|
||
crate::render_prof::event("retained_gate.not_pty_dirty");
|
||
}
|
||
let rendered_retained =
|
||
pty_dirty && !needs_full_render && self.render_retained_pty_update_and_stream();
|
||
if !rendered_retained {
|
||
crate::render_prof::event("full_render.invoke");
|
||
self.render_and_stream();
|
||
}
|
||
self.app.last_render_at = Some(now);
|
||
needs_render = false;
|
||
needs_full_render = false;
|
||
continue;
|
||
}
|
||
|
||
// 8. Wait for next event.
|
||
let next_deadline = self
|
||
.app
|
||
.next_headless_loop_deadline_with_git_refresh(
|
||
now,
|
||
needs_render,
|
||
self.has_app_client(),
|
||
)
|
||
.map(|deadline| deadline.min(now + CLIENT_ACCEPT_POLL_INTERVAL))
|
||
.or(Some(now + CLIENT_ACCEPT_POLL_INTERVAL));
|
||
let event = {
|
||
tokio::select! {
|
||
maybe_api = self.app.api_rx.recv() => match maybe_api {
|
||
Some(msg) => LoopEvent::Api(Box::new(msg)),
|
||
None => LoopEvent::Timer,
|
||
},
|
||
maybe_ev = self.app.event_rx.recv() => match maybe_ev {
|
||
Some(ev) => LoopEvent::Internal(ev),
|
||
None => LoopEvent::Timer,
|
||
},
|
||
maybe_server_ev = self.server_event_rx.recv() => match maybe_server_ev {
|
||
Some(ev) => LoopEvent::ServerEvent(ev),
|
||
None => LoopEvent::Timer,
|
||
},
|
||
_ = sleep_until_or_pending(next_deadline) => LoopEvent::Timer,
|
||
_ = self.app.render_notify.notified() => LoopEvent::RenderRequested,
|
||
}
|
||
};
|
||
|
||
match event {
|
||
LoopEvent::Timer => {}
|
||
LoopEvent::Internal(ev) => {
|
||
if self.handle_internal_event_with_forwarding(ev) {
|
||
needs_render = true;
|
||
needs_full_render = true;
|
||
}
|
||
}
|
||
LoopEvent::Api(msg) => {
|
||
if self.handle_api_request_with_shutdown_check(*msg) {
|
||
needs_render = true;
|
||
needs_full_render = true;
|
||
}
|
||
}
|
||
LoopEvent::ServerEvent(ev) => {
|
||
if self.handle_server_event(ev) {
|
||
needs_render = true;
|
||
needs_full_render = true;
|
||
}
|
||
}
|
||
LoopEvent::RenderRequested => {
|
||
if self.app.render_dirty.load(Ordering::Acquire) {
|
||
needs_render = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Save session on exit.
|
||
if !self.app.no_session {
|
||
self.app.save_session_now();
|
||
}
|
||
|
||
info!("headless server exiting");
|
||
Ok(())
|
||
}
|
||
|
||
fn allocate_activity_stamp(&mut self) -> u64 {
|
||
let stamp = self.next_activity_stamp;
|
||
self.next_activity_stamp = self.next_activity_stamp.saturating_add(1);
|
||
stamp
|
||
}
|
||
|
||
fn resize_shared_runtime_to_effective_size(&mut self) {
|
||
self.resize_shared_runtime_to_effective_size_with_pending_agent_resumes(true);
|
||
}
|
||
|
||
fn resize_shared_runtime_to_effective_size_before_input(&mut self) {
|
||
self.resize_shared_runtime_to_effective_size_with_pending_agent_resumes(false);
|
||
}
|
||
|
||
fn resize_shared_runtime_to_effective_size_with_pending_agent_resumes(
|
||
&mut self,
|
||
start_pending_agent_resumes: bool,
|
||
) {
|
||
if self.foreground_client_id.is_none() {
|
||
return;
|
||
}
|
||
let Some(client_id) = self.foreground_client_id else {
|
||
return;
|
||
};
|
||
let Some(client) = self.clients.get(&client_id) else {
|
||
return;
|
||
};
|
||
let (cols, rows) = self.effective_size;
|
||
let area = Rect::new(0, 0, cols, rows);
|
||
if self.app.state.kitty_graphics_enabled && client.cell_size.is_known() {
|
||
crate::ui::compute_view_with_cell_size(
|
||
&mut self.app.state,
|
||
&self.app.terminal_runtimes,
|
||
area,
|
||
client.cell_size,
|
||
);
|
||
} else {
|
||
crate::ui::compute_view_with_runtime_registry(
|
||
&mut self.app.state,
|
||
&self.app.terminal_runtimes,
|
||
area,
|
||
);
|
||
}
|
||
|
||
// Shared runtime size changes affect pane wrapping and foreground-driven
|
||
// rendering semantics. Force one fresh frame to every remaining client
|
||
// even if the next rendered buffer compares equal to its cached frame.
|
||
for client in self.clients.values_mut() {
|
||
client.request_full_redraw();
|
||
}
|
||
if !start_pending_agent_resumes {
|
||
self.app.pending_agent_resume_deadline = None;
|
||
return;
|
||
}
|
||
let now = Instant::now();
|
||
self.app.sync_pending_agent_resume_deadline(now);
|
||
if self
|
||
.app
|
||
.start_pending_agent_resumes(self.app.pending_agent_resume_due(now))
|
||
{
|
||
for client in self.clients.values_mut() {
|
||
client.request_full_redraw();
|
||
}
|
||
}
|
||
}
|
||
|
||
fn sync_foreground_client_state(&mut self) {
|
||
let Some(client_id) = self.foreground_client_id else {
|
||
self.effective_size = (MIN_COLS, MIN_ROWS);
|
||
self.app.state.outer_terminal_focus = None;
|
||
let server_keybindings = self.server_keybindings.clone();
|
||
apply_keybindings(&mut self.app, &server_keybindings);
|
||
self.sync_visible_server_config_diagnostic(false);
|
||
return;
|
||
};
|
||
let Some(client) = self.clients.get(&client_id) else {
|
||
self.foreground_client_id = None;
|
||
self.effective_size = (MIN_COLS, MIN_ROWS);
|
||
self.app.state.outer_terminal_focus = None;
|
||
let server_keybindings = self.server_keybindings.clone();
|
||
apply_keybindings(&mut self.app, &server_keybindings);
|
||
self.sync_visible_server_config_diagnostic(false);
|
||
return;
|
||
};
|
||
|
||
let terminal_size = client.terminal_size;
|
||
let outer_terminal_focus = client.outer_terminal_focus;
|
||
let host_terminal_theme = client.host_terminal_theme;
|
||
let host_terminal_appearance = client.host_terminal_appearance;
|
||
let host_terminal_appearance_explicit = client.host_terminal_appearance_explicit;
|
||
let uses_local_keybindings = client.keybindings.is_some();
|
||
let keybindings = client
|
||
.keybindings
|
||
.as_deref()
|
||
.unwrap_or(&self.server_keybindings)
|
||
.clone();
|
||
|
||
self.effective_size = terminal_size;
|
||
self.app.state.outer_terminal_focus = outer_terminal_focus;
|
||
apply_keybindings(&mut self.app, &keybindings);
|
||
self.sync_visible_server_config_diagnostic(uses_local_keybindings);
|
||
if outer_terminal_focus == Some(true) {
|
||
self.app.state.mark_active_tab_seen();
|
||
}
|
||
self.app.set_host_terminal_appearance_state(
|
||
host_terminal_appearance,
|
||
host_terminal_appearance_explicit,
|
||
);
|
||
self.app.set_host_terminal_theme(host_terminal_theme);
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
fn perform_live_handoff(
|
||
&mut self,
|
||
params: crate::api::schema::ServerLiveHandoffParams,
|
||
) -> io::Result<()> {
|
||
info!("starting live handoff");
|
||
let import_exe = params.import_exe.as_deref().map(std::path::PathBuf::from);
|
||
let socket_path = crate::server::handoff::handoff_socket_path();
|
||
let token = format!(
|
||
"{}-{}",
|
||
std::process::id(),
|
||
std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.unwrap_or_default()
|
||
.as_nanos()
|
||
);
|
||
let listener = match crate::server::handoff::bind_listener(&socket_path) {
|
||
Ok(listener) => listener,
|
||
Err(err) => {
|
||
self.handoff_in_progress = false;
|
||
return Err(err);
|
||
}
|
||
};
|
||
|
||
let mut pane_by_terminal = HashMap::new();
|
||
for ws in &self.app.state.workspaces {
|
||
for tab in &ws.tabs {
|
||
for (pane_id, pane) in &tab.panes {
|
||
pane_by_terminal.insert(pane.attached_terminal_id.clone(), pane_id.raw());
|
||
}
|
||
}
|
||
}
|
||
if pane_by_terminal.len() > crate::server::handoff::MAX_FDS_PER_HANDOFF {
|
||
let _ = std::fs::remove_file(&socket_path);
|
||
return Err(io::Error::new(
|
||
io::ErrorKind::InvalidInput,
|
||
format!(
|
||
"live handoff supports at most {} panes in one update; close panes or restart herdr normally",
|
||
crate::server::handoff::MAX_FDS_PER_HANDOFF
|
||
),
|
||
));
|
||
}
|
||
|
||
self.handoff_in_progress = true;
|
||
self.disconnect_all_clients_for_handoff();
|
||
let _ = reject_pending_client_connections(&self.client_listener);
|
||
|
||
let mut paused_terminal_ids = Vec::new();
|
||
for terminal_id in pane_by_terminal.keys() {
|
||
if let Some(runtime) = self.app.terminal_runtimes.get(terminal_id) {
|
||
if let Err(err) = runtime.pause_handoff_reader(Duration::from_secs(2)) {
|
||
self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids);
|
||
return Err(err);
|
||
}
|
||
paused_terminal_ids.push(terminal_id.clone());
|
||
}
|
||
}
|
||
|
||
let snapshot = crate::persist::capture(
|
||
&self.app.state.workspaces,
|
||
&self.app.state.terminals,
|
||
&self.app.terminal_runtimes,
|
||
self.app.state.active,
|
||
self.app.state.selected,
|
||
self.app.state.sidebar_width,
|
||
self.app.state.sidebar_section_split,
|
||
self.app.state.collapsed_space_keys.clone(),
|
||
);
|
||
|
||
let mut handoff_entries = Vec::new();
|
||
for (terminal_id, runtime) in self.app.terminal_runtimes.iter() {
|
||
let Some(pane_id) = pane_by_terminal.get(terminal_id).copied() else {
|
||
continue;
|
||
};
|
||
let mut handoff_runtime = runtime.handoff_runtime_state(pane_id);
|
||
let has_agent_session = self
|
||
.app
|
||
.state
|
||
.terminals
|
||
.get(terminal_id)
|
||
.is_some_and(|terminal| terminal.persisted_agent_session.is_some());
|
||
if !has_agent_session {
|
||
handoff_runtime.initial_history_ansi = runtime.handoff_history_ansi();
|
||
}
|
||
handoff_entries.push((terminal_id.clone(), handoff_runtime));
|
||
}
|
||
|
||
let panes = handoff_entries
|
||
.iter()
|
||
.map(|(_, runtime)| runtime.clone())
|
||
.collect();
|
||
let manifest = crate::server::handoff::manifest_for(
|
||
snapshot,
|
||
panes,
|
||
params.expected_protocol,
|
||
params.expected_version,
|
||
);
|
||
let mut import_child = match crate::server::handoff::spawn_handoff_import(
|
||
import_exe.as_deref(),
|
||
&socket_path,
|
||
&token,
|
||
) {
|
||
Ok(child) => child,
|
||
Err(err) => {
|
||
self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids);
|
||
return Err(err);
|
||
}
|
||
};
|
||
let child_pid = import_child.id();
|
||
info!(pid = child_pid, socket = %socket_path.display(), "spawned handoff import server");
|
||
|
||
let mut fds = Vec::new();
|
||
let duplicate_result = (|| {
|
||
for (terminal_id, _) in &handoff_entries {
|
||
let Some(runtime) = self.app.terminal_runtimes.get(terminal_id) else {
|
||
continue;
|
||
};
|
||
fds.push(runtime.duplicate_handoff_fd()?);
|
||
}
|
||
Ok::<(), io::Error>(())
|
||
})();
|
||
if let Err(err) = duplicate_result {
|
||
for fd in fds {
|
||
let _ = unsafe { libc::close(fd) };
|
||
}
|
||
crate::server::handoff::cleanup_failed_import_child(&mut import_child);
|
||
self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids);
|
||
return Err(err);
|
||
}
|
||
|
||
let mut stream = match crate::server::handoff::accept_and_validate_on(
|
||
listener,
|
||
&socket_path,
|
||
&token,
|
||
&manifest,
|
||
) {
|
||
Ok(stream) => stream,
|
||
Err(err) => {
|
||
for fd in fds {
|
||
let _ = unsafe { libc::close(fd) };
|
||
}
|
||
crate::server::handoff::cleanup_failed_import_child(&mut import_child);
|
||
self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids);
|
||
return Err(err);
|
||
}
|
||
};
|
||
|
||
let send_result = crate::server::handoff::send_fds_and_wait_restored(&mut stream, &fds);
|
||
for fd in fds {
|
||
let _ = unsafe { libc::close(fd) };
|
||
}
|
||
if let Err(err) = send_result {
|
||
crate::server::handoff::cleanup_failed_import_child(&mut import_child);
|
||
self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids);
|
||
return Err(err);
|
||
}
|
||
|
||
if let Some(api_server) = &self.api_server {
|
||
let _ = api_server.remove_socket_file_if_owned();
|
||
} else {
|
||
let _ = std::fs::remove_file(crate::api::socket_path());
|
||
}
|
||
let _ = remove_socket_file_if_owned(&self.client_socket_path, &self.client_socket_identity);
|
||
if let Err(err) = crate::server::handoff::wait_ready(&mut stream) {
|
||
crate::server::handoff::cleanup_failed_import_child(&mut import_child);
|
||
match self.wait_then_restore_public_sockets_after_failed_handoff() {
|
||
Ok(()) => {
|
||
self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids);
|
||
}
|
||
Err(restore_err) => {
|
||
self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids);
|
||
return Err(io::Error::other(format!(
|
||
"handoff replacement server did not become ready: {err}; old server could not restore public sockets: {restore_err}"
|
||
)));
|
||
}
|
||
}
|
||
return Err(io::Error::other(format!(
|
||
"handoff replacement server did not become ready: {err}"
|
||
)));
|
||
}
|
||
if let Err(err) = crate::server::handoff::report_committed(&mut stream) {
|
||
crate::server::handoff::cleanup_failed_import_child(&mut import_child);
|
||
match self.wait_then_restore_public_sockets_after_failed_handoff() {
|
||
Ok(()) => {
|
||
self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids);
|
||
}
|
||
Err(restore_err) => {
|
||
self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids);
|
||
return Err(io::Error::other(format!(
|
||
"handoff replacement server was ready, but commit failed: {err}; old server could not restore public sockets: {restore_err}"
|
||
)));
|
||
}
|
||
}
|
||
return Err(err);
|
||
}
|
||
|
||
for (terminal_id, runtime) in self.app.terminal_runtimes.drain_for_handoff() {
|
||
if !pane_by_terminal.contains_key(&terminal_id) {
|
||
continue;
|
||
}
|
||
debug!(terminal = %terminal_id, "preserving pane runtime for handoff");
|
||
runtime.preserve_for_handoff();
|
||
}
|
||
crate::server::handoff::wait_owned_ack(&mut stream);
|
||
|
||
self.shutting_down = true;
|
||
self.app.state.should_quit = true;
|
||
self.app.no_session = true;
|
||
info!("live handoff completed; old server exiting");
|
||
Ok(())
|
||
}
|
||
|
||
#[cfg(not(unix))]
|
||
fn perform_live_handoff(
|
||
&mut self,
|
||
_params: crate::api::schema::ServerLiveHandoffParams,
|
||
) -> io::Result<()> {
|
||
Err(io::Error::other("live handoff is only supported on Unix"))
|
||
}
|
||
|
||
fn sync_visible_server_config_diagnostic(&mut self, uses_local_keybindings: bool) {
|
||
let visible = if uses_local_keybindings {
|
||
&self.server_config_diagnostic_without_keybindings
|
||
} else {
|
||
&self.server_config_diagnostic
|
||
};
|
||
if self.app.state.config_diagnostic == self.server_config_diagnostic
|
||
|| self.app.state.config_diagnostic == self.server_config_diagnostic_without_keybindings
|
||
{
|
||
self.app.state.config_diagnostic = visible.clone();
|
||
}
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
fn restore_public_sockets_after_failed_handoff(&mut self) -> io::Result<()> {
|
||
let api_tx = self
|
||
.api_tx
|
||
.clone()
|
||
.ok_or_else(|| io::Error::other("cannot restore api socket without api sender"))?;
|
||
let api_server = api::start_server(api_tx, self.app.event_hub.clone())?;
|
||
|
||
let client_path = client_socket_path();
|
||
prepare_socket_path(&client_path)?;
|
||
let listener = bind_local_listener(&client_path)?;
|
||
restrict_socket_permissions(&client_path)?;
|
||
let client_socket_identity = socket_file_identity(&client_path)?;
|
||
listener.set_nonblocking(ListenerNonblockingMode::Accept)?;
|
||
|
||
self.api_server = Some(api_server);
|
||
self.client_listener = listener;
|
||
self.client_socket_path = client_path;
|
||
self.client_socket_identity = client_socket_identity;
|
||
Ok(())
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
fn wait_then_restore_public_sockets_after_failed_handoff(&mut self) -> io::Result<()> {
|
||
let timeout = crate::server::handoff::COMMIT_TIMEOUT + Duration::from_secs(2);
|
||
wait_for_old_public_sockets_to_close(timeout)?;
|
||
self.restore_public_sockets_after_failed_handoff()
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
fn rollback_handoff_before_commit(
|
||
&mut self,
|
||
socket_path: &Path,
|
||
paused_terminal_ids: &[crate::terminal::TerminalId],
|
||
) {
|
||
for terminal_id in paused_terminal_ids {
|
||
if let Some(runtime) = self.app.terminal_runtimes.get(terminal_id) {
|
||
runtime.set_handoff_reader_paused(false);
|
||
}
|
||
}
|
||
self.handoff_in_progress = false;
|
||
let _ = std::fs::remove_file(socket_path);
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
fn nudge_handoff_panes_on_first_client_attach(&mut self) {
|
||
if !self.pending_handoff_repaint_nudge {
|
||
return;
|
||
}
|
||
self.pending_handoff_repaint_nudge = false;
|
||
self.app
|
||
.terminal_runtimes
|
||
.nudge_child_redraw_after_handoff();
|
||
}
|
||
|
||
#[cfg(not(unix))]
|
||
fn nudge_handoff_panes_on_first_client_attach(&mut self) {}
|
||
|
||
fn reload_server_config(&mut self, notify_success: bool) -> crate::config::ConfigReloadReport {
|
||
let server_keybindings = self.server_keybindings.clone();
|
||
apply_keybindings(&mut self.app, &server_keybindings);
|
||
let report = self.app.apply_config_from_disk(notify_success);
|
||
self.app.take_config_reloaded_from_disk();
|
||
self.server_keybindings = app_keybindings(&self.app);
|
||
let (server_config_diagnostic, server_config_diagnostic_without_keybindings) =
|
||
server_config_diagnostic_summaries(&report.diagnostics);
|
||
self.server_config_diagnostic = server_config_diagnostic;
|
||
self.server_config_diagnostic_without_keybindings =
|
||
server_config_diagnostic_without_keybindings;
|
||
self.sync_foreground_client_state();
|
||
report
|
||
}
|
||
|
||
fn foreground_client_outer_focus(&self) -> Option<bool> {
|
||
let client_id = self.foreground_client_id?;
|
||
self.clients.get(&client_id)?.outer_terminal_focus
|
||
}
|
||
|
||
fn active_tab_suppresses_notifications(&self, is_active_tab: bool) -> bool {
|
||
crate::app::actions::active_tab_suppresses_notifications(
|
||
is_active_tab,
|
||
self.foreground_client_outer_focus(),
|
||
)
|
||
}
|
||
|
||
fn promote_client_to_foreground(&mut self, client_id: u64) -> bool {
|
||
let stamp = self.allocate_activity_stamp();
|
||
let Some(client) = self.clients.get_mut(&client_id) else {
|
||
return false;
|
||
};
|
||
client.last_activity = stamp;
|
||
|
||
let changed = self.foreground_client_id != Some(client_id);
|
||
self.foreground_client_id = Some(client_id);
|
||
self.sync_foreground_client_state();
|
||
changed
|
||
}
|
||
|
||
fn promote_latest_remaining_client(&mut self) -> bool {
|
||
let next_foreground = latest_app_client(&self.clients);
|
||
let changed = next_foreground != self.foreground_client_id;
|
||
self.foreground_client_id = next_foreground;
|
||
self.sync_foreground_client_state();
|
||
changed
|
||
}
|
||
|
||
fn app_client_count(&self) -> usize {
|
||
self.clients
|
||
.values()
|
||
.filter(|client| client.is_full_app_client() && client.writer.is_some())
|
||
.count()
|
||
}
|
||
|
||
fn has_app_client(&self) -> bool {
|
||
self.app_client_count() > 0
|
||
}
|
||
|
||
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);
|
||
let removed = self.clients.remove(&client_id);
|
||
if let Some(removed) = removed {
|
||
crate::server::clipboard_image::remove_files(removed.staged_clipboard_files);
|
||
if let ClientConnectionMode::TerminalAttach { terminal_id } = removed.mode {
|
||
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 {
|
||
false
|
||
}
|
||
}
|
||
|
||
fn client_removal_needs_shared_resize(&self, client_id: u64) -> bool {
|
||
if self.foreground_client_id == Some(client_id) {
|
||
return true;
|
||
}
|
||
matches!(
|
||
self.clients.get(&client_id).map(|client| &client.mode),
|
||
Some(
|
||
ClientConnectionMode::TerminalAttach { .. }
|
||
| ClientConnectionMode::TerminalObserve { .. }
|
||
)
|
||
) && self.foreground_client_id.is_some()
|
||
}
|
||
|
||
fn remove_client_and_resize_if_needed(&mut self, client_id: u64) {
|
||
let needs_shared_resize = self.client_removal_needs_shared_resize(client_id);
|
||
let foreground_changed = self.remove_client(client_id);
|
||
if needs_shared_resize || foreground_changed {
|
||
self.resize_shared_runtime_to_effective_size();
|
||
}
|
||
}
|
||
|
||
fn send_client_graphics_cleanup(&mut self, client_id: u64) {
|
||
let (writer, bytes) = match self.clients.get_mut(&client_id) {
|
||
Some(client) => {
|
||
let bytes = client.graphics_cache.clear_bytes();
|
||
(client.writer.as_ref().cloned(), bytes)
|
||
}
|
||
None => return,
|
||
};
|
||
if bytes.is_empty() {
|
||
return;
|
||
}
|
||
let Some(writer) = writer else {
|
||
return;
|
||
};
|
||
let Ok(serialized) = Self::frame_server_message(&ServerMessage::Graphics { bytes }) else {
|
||
return;
|
||
};
|
||
let _ = writer.control.send(serialized);
|
||
}
|
||
|
||
fn send_all_clients_graphics_cleanup(&mut self) {
|
||
let client_ids = self.clients.keys().copied().collect::<Vec<_>>();
|
||
for client_id in client_ids {
|
||
self.send_client_graphics_cleanup(client_id);
|
||
}
|
||
}
|
||
|
||
fn update_client_host_theme_from_events(
|
||
&mut self,
|
||
client_id: u64,
|
||
events: &[crate::raw_input::RawInputEvent],
|
||
) -> bool {
|
||
let Some(client) = self.clients.get_mut(&client_id) else {
|
||
return false;
|
||
};
|
||
|
||
if !client.update_host_theme_from_events(events) {
|
||
return false;
|
||
}
|
||
|
||
if self.foreground_client_id == Some(client_id) {
|
||
let mut changed = self.app.set_host_terminal_appearance_state(
|
||
client.host_terminal_appearance,
|
||
client.host_terminal_appearance_explicit,
|
||
);
|
||
changed |= self.app.set_host_terminal_theme(client.host_terminal_theme);
|
||
if changed {
|
||
self.resize_shared_runtime_to_effective_size_before_input();
|
||
}
|
||
changed
|
||
} else {
|
||
false
|
||
}
|
||
}
|
||
|
||
fn update_client_outer_focus_from_events(
|
||
&mut self,
|
||
client_id: u64,
|
||
events: &[crate::raw_input::RawInputEvent],
|
||
) {
|
||
let Some(client) = self.clients.get_mut(&client_id) else {
|
||
return;
|
||
};
|
||
let Some(next_focus) = client.update_outer_focus_from_events(events) else {
|
||
return;
|
||
};
|
||
if self.foreground_client_id == Some(client_id) {
|
||
self.app.state.outer_terminal_focus = Some(next_focus);
|
||
}
|
||
}
|
||
|
||
/// Accepts pending client connections from the non-blocking listener.
|
||
#[cfg(unix)]
|
||
fn accept_client_connections(&mut self) -> io::Result<()> {
|
||
if self.handoff_in_progress {
|
||
return reject_pending_client_connections(&self.client_listener);
|
||
}
|
||
accept_pending_client_connections(
|
||
&self.client_listener,
|
||
&mut self.next_client_id,
|
||
&self.should_quit,
|
||
&self.server_event_tx,
|
||
)
|
||
}
|
||
|
||
/// Windows named-pipe clients can block in connect unless the server has a
|
||
/// pending blocking accept. The dedicated accept thread handles that path.
|
||
#[cfg(windows)]
|
||
fn accept_client_connections(&mut self) -> io::Result<()> {
|
||
Ok(())
|
||
}
|
||
|
||
/// Drains server events from the dedicated channel.
|
||
///
|
||
/// Returns true if any input was processed (requiring a re-render).
|
||
fn drain_server_events(&mut self) -> bool {
|
||
let mut changed = false;
|
||
while let Ok(ev) = self.server_event_rx.try_recv() {
|
||
changed |= self.handle_server_event(ev);
|
||
}
|
||
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.terminal_runtimes.get(&terminal_id)
|
||
}
|
||
|
||
fn resolve_terminal_target_id_string(&self, target: &str) -> Option<String> {
|
||
if self.terminal_id_by_string(target).is_some() {
|
||
return Some(target.to_owned());
|
||
}
|
||
self.app
|
||
.resolve_terminal_target(target)
|
||
.ok()
|
||
.map(|resolved| resolved.terminal_id)
|
||
}
|
||
|
||
fn write_client_clipboard_image(
|
||
&mut self,
|
||
client_id: u64,
|
||
extension: &str,
|
||
data: &[u8],
|
||
) -> std::io::Result<String> {
|
||
let staged = crate::server::clipboard_image::stage(client_id, extension, data)?;
|
||
if let Some(client) = self.clients.get_mut(&client_id) {
|
||
client.staged_clipboard_files.push(staged.path);
|
||
}
|
||
info!(client_id, bytes = data.len(), path = %staged.paste_text, "staged client clipboard image");
|
||
Ok(staged.paste_text)
|
||
}
|
||
|
||
fn paste_client_clipboard_image_path(&mut self, client_id: u64, path: String) -> bool {
|
||
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) {
|
||
let payload = paste_payload_for_runtime(runtime, &path);
|
||
if let Err(err) = runtime.try_send_bytes(Bytes::from(payload)) {
|
||
warn!(client_id, terminal_id = %terminal_id, err = %err, "terminal attach clipboard image paste failed");
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
let foreground_changed = self.promote_client_to_foreground(client_id);
|
||
if foreground_changed {
|
||
self.resize_shared_runtime_to_effective_size_before_input();
|
||
}
|
||
if let Some(client) = self.clients.get_mut(&client_id) {
|
||
client.request_semantic_redraw_after_input();
|
||
}
|
||
self.app.route_client_events(
|
||
vec![crate::raw_input::RawInputEvent::Paste(path)],
|
||
self.foreground_client_id == Some(client_id),
|
||
);
|
||
true
|
||
}
|
||
|
||
fn resolve_terminal_session_target(
|
||
&mut self,
|
||
client_id: u64,
|
||
target: &str,
|
||
action: &str,
|
||
) -> Option<String> {
|
||
if !self.client_is_pending_terminal_mode(client_id) {
|
||
self.send_to_client(
|
||
client_id,
|
||
ServerMessage::ServerShutdown {
|
||
reason: Some(
|
||
format!(
|
||
"terminal session {action} failed: connection is not pending terminal session"
|
||
),
|
||
),
|
||
},
|
||
);
|
||
self.remove_client_and_resize_if_needed(client_id);
|
||
return None;
|
||
}
|
||
|
||
let Some(terminal_id) = self.resolve_terminal_target_id_string(target) else {
|
||
self.send_to_client(
|
||
client_id,
|
||
ServerMessage::ServerShutdown {
|
||
reason: Some(format!(
|
||
"terminal session {action} failed: terminal target {target} not found"
|
||
)),
|
||
},
|
||
);
|
||
self.remove_client_and_resize_if_needed(client_id);
|
||
return None;
|
||
};
|
||
|
||
Some(terminal_id)
|
||
}
|
||
|
||
fn observe_terminal_client(&mut self, client_id: u64, target: String) -> bool {
|
||
let Some(terminal_id) = self.resolve_terminal_session_target(client_id, &target, "observe")
|
||
else {
|
||
return false;
|
||
};
|
||
|
||
let stamp = self.allocate_activity_stamp();
|
||
let Some(client) = self.clients.get_mut(&client_id) else {
|
||
return false;
|
||
};
|
||
let (cols, rows) = client.terminal_size;
|
||
client.mode = ClientConnectionMode::TerminalObserve {
|
||
terminal_id: terminal_id.clone(),
|
||
};
|
||
client.pending_terminal_attach = false;
|
||
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 observe client connected");
|
||
true
|
||
}
|
||
|
||
fn control_terminal_client(&mut self, client_id: u64, target: String, takeover: bool) -> bool {
|
||
let Some(terminal_id) = self.resolve_terminal_session_target(client_id, &target, "control")
|
||
else {
|
||
return false;
|
||
};
|
||
|
||
self.attach_terminal_client(client_id, terminal_id, takeover)
|
||
}
|
||
|
||
fn handle_terminal_attach_scroll(
|
||
&mut self,
|
||
client_id: u64,
|
||
source: AttachScrollSource,
|
||
direction: AttachScrollDirection,
|
||
lines: u16,
|
||
column: Option<u16>,
|
||
row: Option<u16>,
|
||
modifiers: u8,
|
||
) -> bool {
|
||
let Some(ClientConnection {
|
||
mode: ClientConnectionMode::TerminalAttach { terminal_id },
|
||
..
|
||
}) = self.clients.get(&client_id)
|
||
else {
|
||
return false;
|
||
};
|
||
let Some(runtime) = self.runtime_for_terminal_id_string(terminal_id) else {
|
||
return false;
|
||
};
|
||
|
||
if let Err(err) =
|
||
apply_terminal_attach_scroll(runtime, source, direction, lines, column, row, modifiers)
|
||
{
|
||
warn!(client_id, terminal_id = %terminal_id, err = %err, "terminal attach scroll failed");
|
||
}
|
||
true
|
||
}
|
||
|
||
fn pane_effective_state(&self, pane_id: crate::layout::PaneId) -> crate::detect::AgentState {
|
||
self.app
|
||
.state
|
||
.workspaces
|
||
.iter()
|
||
.find_map(|ws| {
|
||
ws.tabs.iter().find_map(|tab| {
|
||
let pane = tab.panes.get(&pane_id)?;
|
||
self.app
|
||
.state
|
||
.terminals
|
||
.get(&pane.attached_terminal_id)
|
||
.map(|terminal| terminal.state)
|
||
})
|
||
})
|
||
.unwrap_or(crate::detect::AgentState::Unknown)
|
||
}
|
||
|
||
fn pane_effective_agent_label(&self, pane_id: crate::layout::PaneId) -> Option<String> {
|
||
self.app.state.workspaces.iter().find_map(|ws| {
|
||
ws.tabs.iter().find_map(|tab| {
|
||
let pane = tab.panes.get(&pane_id)?;
|
||
self.app
|
||
.state
|
||
.terminals
|
||
.get(&pane.attached_terminal_id)
|
||
.and_then(|terminal| terminal.effective_agent_label())
|
||
.map(str::to_string)
|
||
})
|
||
})
|
||
}
|
||
|
||
fn forward_pane_state_update_notifications_to_clients(
|
||
&mut self,
|
||
update: &crate::app::actions::PaneStateUpdate,
|
||
) {
|
||
if self.app.state.toast_config.delay_seconds != 0 {
|
||
return;
|
||
}
|
||
|
||
let is_active_tab = self
|
||
.app
|
||
.state
|
||
.pane_is_in_active_tab(update.ws_idx, update.pane_id);
|
||
let suppress_active_tab_notifications =
|
||
self.active_tab_suppresses_notifications(is_active_tab);
|
||
|
||
if self.app.state.sound.allows(update.known_agent) {
|
||
if let Some(sound) =
|
||
crate::app::actions::notification_sound_for_state_change_with_agent_labels(
|
||
suppress_active_tab_notifications,
|
||
update.previous_state,
|
||
update.state,
|
||
update.previous_agent_label.as_deref(),
|
||
update.agent_label.as_deref(),
|
||
)
|
||
{
|
||
self.send_notify_to_foreground_client(
|
||
protocol::NotifyKind::Sound,
|
||
sound_notify_message(sound),
|
||
None,
|
||
);
|
||
}
|
||
}
|
||
|
||
if !should_forward_toast_to_clients(self.app.state.toast_config.delivery) {
|
||
return;
|
||
}
|
||
let Some(kind) = crate::app::actions::notification_toast_for_pane_state_update(
|
||
suppress_active_tab_notifications,
|
||
update,
|
||
) else {
|
||
return;
|
||
};
|
||
let Some(ws) = self.app.state.workspaces.get(update.ws_idx) else {
|
||
return;
|
||
};
|
||
let Some(agent_label) = update.agent_label.as_deref() else {
|
||
return;
|
||
};
|
||
let event_text = match kind {
|
||
crate::app::state::ToastKind::NeedsAttention => "needs attention",
|
||
crate::app::state::ToastKind::Finished => "finished",
|
||
crate::app::state::ToastKind::UpdateInstalled => "updated",
|
||
};
|
||
let workspace_label =
|
||
ws.display_name_from(&self.app.state.terminals, &self.app.terminal_runtimes);
|
||
let context = crate::app::actions::notification_context(
|
||
ws,
|
||
&workspace_label,
|
||
update.ws_idx,
|
||
update.pane_id,
|
||
);
|
||
self.send_notify_to_foreground_client(
|
||
toast_notify_kind(self.app.state.toast_config.delivery)
|
||
.expect("toast forwarding requires a client notification kind"),
|
||
format!("{agent_label} {event_text}"),
|
||
non_empty_body(&context),
|
||
);
|
||
}
|
||
|
||
fn forward_agent_notification_delivery(
|
||
&mut self,
|
||
delivery: &crate::app::state::AgentNotificationDelivery,
|
||
) {
|
||
if let Some(sound) = delivery.sound {
|
||
self.send_notify_to_foreground_client(
|
||
protocol::NotifyKind::Sound,
|
||
sound_notify_message(sound),
|
||
None,
|
||
);
|
||
}
|
||
|
||
if should_forward_toast_to_clients(self.app.state.toast_config.delivery) {
|
||
if let Some(toast) = &delivery.client_notification {
|
||
self.send_notify_to_foreground_client(
|
||
toast_notify_kind(self.app.state.toast_config.delivery)
|
||
.expect("toast forwarding requires a client notification kind"),
|
||
&toast.title,
|
||
non_empty_body(&toast.context),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn send_notify_to_foreground_client(
|
||
&mut self,
|
||
kind: protocol::NotifyKind,
|
||
message: impl Into<String>,
|
||
body: Option<String>,
|
||
) -> bool {
|
||
self.send_to_foreground_client(ServerMessage::Notify {
|
||
kind,
|
||
message: message.into(),
|
||
body,
|
||
})
|
||
}
|
||
|
||
fn send_flat_toast_to_foreground_client(
|
||
&mut self,
|
||
kind: protocol::NotifyKind,
|
||
message: impl AsRef<str>,
|
||
) -> bool {
|
||
let (title, body) = crate::terminal_notify::split_message(message.as_ref());
|
||
self.send_notify_to_foreground_client(kind, title, body.map(str::to_string))
|
||
}
|
||
|
||
fn handle_notification_show_api(
|
||
&mut self,
|
||
id: String,
|
||
params: api::schema::NotificationShowParams,
|
||
) -> String {
|
||
use api::schema::{NotificationShowReason, ResponseResult};
|
||
|
||
let Some(title) = sanitize_notification_text(¶ms.title, 80) else {
|
||
return serde_json::to_string(&api::schema::ErrorResponse {
|
||
id,
|
||
error: api::schema::ErrorBody {
|
||
code: "invalid_params".into(),
|
||
message: "notification title is empty".into(),
|
||
},
|
||
})
|
||
.unwrap_or_else(|_| "{}".to_string());
|
||
};
|
||
|
||
match self.app.state.toast_config.delivery {
|
||
config::ToastDelivery::Off => {
|
||
return serde_json::to_string(&api::schema::SuccessResponse {
|
||
id,
|
||
result: ResponseResult::NotificationShow {
|
||
shown: false,
|
||
reason: NotificationShowReason::Disabled,
|
||
},
|
||
})
|
||
.unwrap_or_else(|_| "{}".to_string());
|
||
}
|
||
config::ToastDelivery::Herdr => {
|
||
let sound = params.sound;
|
||
let response = self.app.handle_api_request_after_internal_events_drained(
|
||
api::schema::Request {
|
||
id,
|
||
method: api::schema::Method::NotificationShow(params),
|
||
},
|
||
);
|
||
if notification_show_response_shown(&response) {
|
||
self.forward_api_notification_sound(sound);
|
||
}
|
||
return response;
|
||
}
|
||
config::ToastDelivery::Terminal | config::ToastDelivery::System => {}
|
||
}
|
||
|
||
let body = params
|
||
.body
|
||
.as_deref()
|
||
.and_then(|body| sanitize_notification_text(body, 240));
|
||
if self.app.api_notification_rate_limited(Instant::now()) {
|
||
return serde_json::to_string(&api::schema::SuccessResponse {
|
||
id,
|
||
result: ResponseResult::NotificationShow {
|
||
shown: false,
|
||
reason: NotificationShowReason::RateLimited,
|
||
},
|
||
})
|
||
.unwrap_or_else(|_| "{}".to_string());
|
||
}
|
||
let kind = toast_notify_kind(self.app.state.toast_config.delivery)
|
||
.expect("terminal/system delivery has notify kind");
|
||
let shown = self.send_notify_to_foreground_client(kind, title, body);
|
||
if shown {
|
||
self.app.mark_api_notification_shown(Instant::now());
|
||
self.forward_api_notification_sound(params.sound);
|
||
}
|
||
let reason = if shown {
|
||
NotificationShowReason::Shown
|
||
} else {
|
||
NotificationShowReason::NoForegroundClient
|
||
};
|
||
|
||
serde_json::to_string(&api::schema::SuccessResponse {
|
||
id,
|
||
result: ResponseResult::NotificationShow { shown, reason },
|
||
})
|
||
.unwrap_or_else(|_| "{}".to_string())
|
||
}
|
||
|
||
fn handle_client_window_title_api(&mut self, id: String, title: Option<String>) -> String {
|
||
use api::schema::{ClientWindowTitleReason, ResponseResult};
|
||
|
||
let title = match title {
|
||
Some(title) => match sanitize_window_title_text(&title, 200) {
|
||
Some(title) => Some(title),
|
||
None => {
|
||
return serde_json::to_string(&api::schema::ErrorResponse {
|
||
id,
|
||
error: api::schema::ErrorBody {
|
||
code: "invalid_params".into(),
|
||
message: "window title is empty".into(),
|
||
},
|
||
})
|
||
.unwrap_or_else(|_| "{}".to_string());
|
||
}
|
||
},
|
||
None => None,
|
||
};
|
||
let set_title = title.is_some();
|
||
let changed = self.send_to_foreground_client(ServerMessage::WindowTitle { title });
|
||
let reason = match (changed, set_title) {
|
||
(true, true) => ClientWindowTitleReason::Set,
|
||
(true, false) => ClientWindowTitleReason::Cleared,
|
||
(false, _) => ClientWindowTitleReason::NoForegroundClient,
|
||
};
|
||
serde_json::to_string(&api::schema::SuccessResponse {
|
||
id,
|
||
result: ResponseResult::ClientWindowTitle { changed, reason },
|
||
})
|
||
.unwrap_or_else(|_| "{}".to_string())
|
||
}
|
||
|
||
fn forward_api_notification_sound(&mut self, sound: api::schema::NotificationShowSound) {
|
||
let Some(sound) = sound.to_sound() else {
|
||
return;
|
||
};
|
||
self.send_notify_to_foreground_client(
|
||
protocol::NotifyKind::Sound,
|
||
sound_notify_message(sound),
|
||
None,
|
||
);
|
||
}
|
||
|
||
/// Handles a single internal event with forwarding logic for clipboard,
|
||
/// sound, and toast notifications to connected clients.
|
||
///
|
||
/// ALL internal events MUST be routed through this method to ensure
|
||
/// clipboard/notify forwarding is never bypassed. Do not call
|
||
/// `self.app.handle_internal_event()` directly for any internal event
|
||
/// in the headless server — use this method instead.
|
||
///
|
||
/// Returns true if the event changed visual state (requiring a re-render).
|
||
fn handle_internal_event_with_forwarding(&mut self, ev: AppEvent) -> bool {
|
||
match &ev {
|
||
AppEvent::ClipboardWrite { content } => {
|
||
// Clipboard writes are client-local side effects. Forward them only to
|
||
// the foreground client instead of broadcasting to every attached client.
|
||
let data = base64::engine::general_purpose::STANDARD.encode(content.as_slice());
|
||
if self.send_to_foreground_client(ServerMessage::Clipboard { data }) {
|
||
self.app.show_clipboard_feedback();
|
||
}
|
||
true
|
||
}
|
||
AppEvent::StateChanged { pane_id, agent, .. } => {
|
||
// Capture toast before handling.
|
||
let toast_before = self.app.state.toast.clone();
|
||
let pane_id_val = *pane_id;
|
||
let agent_val = *agent;
|
||
|
||
// Find the previous effective state of this pane before the event
|
||
// is processed. Notifications must follow effective state changes,
|
||
// not raw fallback reports that may be masked by hook authority.
|
||
let prev_state = self.pane_effective_state(pane_id_val);
|
||
let prev_agent_label = self.pane_effective_agent_label(pane_id_val);
|
||
|
||
// Handle the state change (updates pane state, sets toast on AppState).
|
||
// Headless mode disables local sound playback separately from the
|
||
// sound policy so reloads can keep server-side notification policy live.
|
||
self.sync_foreground_client_state();
|
||
self.app.handle_internal_event(ev);
|
||
|
||
// Forward sound notification to clients when server-side sound policy allows it.
|
||
let is_active_tab = self
|
||
.app
|
||
.state
|
||
.active
|
||
.and_then(|ws_idx| self.app.state.workspaces.get(ws_idx))
|
||
.is_some_and(|ws| {
|
||
ws.find_tab_index_for_pane(pane_id_val)
|
||
.is_some_and(|tab_idx| ws.active_tab_index() == tab_idx)
|
||
});
|
||
|
||
let suppress_active_tab_notifications =
|
||
self.active_tab_suppresses_notifications(is_active_tab);
|
||
|
||
let next_state = self.pane_effective_state(pane_id_val);
|
||
let next_agent_label = self.pane_effective_agent_label(pane_id_val);
|
||
|
||
if self.app.state.toast_config.delay_seconds == 0
|
||
&& self.app.state.sound.allows(agent_val)
|
||
{
|
||
if let Some(sound) =
|
||
crate::app::actions::notification_sound_for_state_change_with_agent_labels(
|
||
suppress_active_tab_notifications,
|
||
prev_state,
|
||
next_state,
|
||
prev_agent_label.as_deref(),
|
||
next_agent_label.as_deref(),
|
||
)
|
||
{
|
||
self.send_notify_to_foreground_client(
|
||
protocol::NotifyKind::Sound,
|
||
sound_notify_message(sound),
|
||
None,
|
||
);
|
||
}
|
||
}
|
||
|
||
let toast_msg = if self.app.state.toast_config.delay_seconds == 0
|
||
&& should_forward_toast_to_clients(self.app.state.toast_config.delivery)
|
||
{
|
||
if self.app.state.toast.is_some() && self.app.state.toast != toast_before {
|
||
self.app
|
||
.state
|
||
.toast
|
||
.as_ref()
|
||
.map(|toast| format!("{}: {}", toast.title, toast.context))
|
||
} else {
|
||
toast_message_from_state_change(
|
||
&self.app.state,
|
||
&self.app.terminal_runtimes,
|
||
pane_id_val,
|
||
suppress_active_tab_notifications,
|
||
prev_state,
|
||
next_state,
|
||
prev_agent_label.as_deref(),
|
||
)
|
||
}
|
||
} else {
|
||
None
|
||
};
|
||
|
||
if let Some(msg) = toast_msg {
|
||
self.send_flat_toast_to_foreground_client(
|
||
toast_notify_kind(self.app.state.toast_config.delivery)
|
||
.expect("toast forwarding requires a client notification kind"),
|
||
msg,
|
||
);
|
||
}
|
||
|
||
true
|
||
}
|
||
AppEvent::HookStateReported {
|
||
pane_id,
|
||
agent_label,
|
||
..
|
||
} => {
|
||
// Hook reports can be stale or no-op after sequence rejection.
|
||
// Forward only effective state changes observed after handling.
|
||
let toast_before = self.app.state.toast.clone();
|
||
let pane_id_val = *pane_id;
|
||
let agent_val = crate::detect::parse_agent_label(agent_label);
|
||
|
||
// Capture the previous effective state for this pane. Hook reports
|
||
// are already folded into pane.state; raw hook transitions must not
|
||
// produce a second notification path.
|
||
let prev_state = self.pane_effective_state(pane_id_val);
|
||
let prev_agent_label = self.pane_effective_agent_label(pane_id_val);
|
||
|
||
self.sync_foreground_client_state();
|
||
self.app.handle_internal_event(ev);
|
||
|
||
// Forward sound notification based on the effective transition when
|
||
// server-side sound policy allows it.
|
||
let is_active_tab = self
|
||
.app
|
||
.state
|
||
.active
|
||
.and_then(|ws_idx| self.app.state.workspaces.get(ws_idx))
|
||
.is_some_and(|ws| {
|
||
ws.find_tab_index_for_pane(pane_id_val)
|
||
.is_some_and(|tab_idx| ws.active_tab_index() == tab_idx)
|
||
});
|
||
|
||
let suppress_active_tab_notifications =
|
||
self.active_tab_suppresses_notifications(is_active_tab);
|
||
|
||
let next_state = self.pane_effective_state(pane_id_val);
|
||
let next_agent_label = self.pane_effective_agent_label(pane_id_val);
|
||
|
||
if self.app.state.toast_config.delay_seconds == 0
|
||
&& self.app.state.sound.allows(agent_val)
|
||
{
|
||
if let Some(sound) =
|
||
crate::app::actions::notification_sound_for_state_change_with_agent_labels(
|
||
suppress_active_tab_notifications,
|
||
prev_state,
|
||
next_state,
|
||
prev_agent_label.as_deref(),
|
||
next_agent_label.as_deref(),
|
||
)
|
||
{
|
||
self.send_notify_to_foreground_client(
|
||
protocol::NotifyKind::Sound,
|
||
sound_notify_message(sound),
|
||
None,
|
||
);
|
||
}
|
||
}
|
||
|
||
let toast_msg = if self.app.state.toast_config.delay_seconds == 0
|
||
&& should_forward_toast_to_clients(self.app.state.toast_config.delivery)
|
||
{
|
||
if self.app.state.toast.is_some() && self.app.state.toast != toast_before {
|
||
self.app
|
||
.state
|
||
.toast
|
||
.as_ref()
|
||
.map(|toast| format!("{}: {}", toast.title, toast.context))
|
||
} else {
|
||
toast_message_from_state_change(
|
||
&self.app.state,
|
||
&self.app.terminal_runtimes,
|
||
pane_id_val,
|
||
suppress_active_tab_notifications,
|
||
prev_state,
|
||
next_state,
|
||
prev_agent_label.as_deref(),
|
||
)
|
||
}
|
||
} else {
|
||
None
|
||
};
|
||
|
||
if let Some(msg) = toast_msg {
|
||
self.send_flat_toast_to_foreground_client(
|
||
toast_notify_kind(self.app.state.toast_config.delivery)
|
||
.expect("toast forwarding requires a client notification kind"),
|
||
msg,
|
||
);
|
||
}
|
||
|
||
true
|
||
}
|
||
AppEvent::UpdateReady {
|
||
version,
|
||
install_command,
|
||
} => {
|
||
let toast_before = self.app.state.toast.clone();
|
||
let version = version.clone();
|
||
let install_command = install_command.clone();
|
||
|
||
self.app.handle_internal_event(ev);
|
||
|
||
let toast_msg =
|
||
if should_forward_toast_to_clients(self.app.state.toast_config.delivery) {
|
||
if self.app.state.toast.is_some() && self.app.state.toast != toast_before {
|
||
self.app
|
||
.state
|
||
.toast
|
||
.as_ref()
|
||
.map(|toast| format!("{}: {}", toast.title, toast.context))
|
||
} else {
|
||
Some(format!(
|
||
"v{version} available: {}",
|
||
crate::update::update_install_instruction(&install_command)
|
||
))
|
||
}
|
||
} else {
|
||
None
|
||
};
|
||
|
||
if let Some(msg) = toast_msg {
|
||
self.send_flat_toast_to_foreground_client(
|
||
toast_notify_kind(self.app.state.toast_config.delivery)
|
||
.expect("toast forwarding requires a client notification kind"),
|
||
msg,
|
||
);
|
||
}
|
||
|
||
true
|
||
}
|
||
AppEvent::PaneDied { pane_id } => {
|
||
let pane_id_val = *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())
|
||
})
|
||
});
|
||
if let Some(update) = self
|
||
.app
|
||
.state
|
||
.publish_pane_process_exit_if_agent(pane_id_val)
|
||
{
|
||
self.app.emit_pane_state_update(&update);
|
||
self.forward_pane_state_update_notifications_to_clients(&update);
|
||
}
|
||
|
||
self.app.handle_internal_event(ev);
|
||
|
||
if self.app.find_pane(pane_id_val).is_none() {
|
||
if let Some(terminal_id) = terminal_id {
|
||
self.shutdown_terminal_stream_clients(
|
||
&terminal_id,
|
||
format!("terminal {terminal_id} exited"),
|
||
);
|
||
}
|
||
}
|
||
|
||
true
|
||
}
|
||
_ => {
|
||
self.app.handle_internal_event(ev);
|
||
true
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Drains internal events, forwarding clipboard, sound, and toast
|
||
/// notifications to connected clients instead of processing them locally.
|
||
///
|
||
/// In the monolithic mode:
|
||
/// - `ClipboardWrite` events are written to stdout via `write_osc52_bytes`.
|
||
/// - Sound notifications are played locally via `sound::play`.
|
||
/// - Toast notifications are set on AppState and rendered into the frame.
|
||
///
|
||
/// In the headless server, there is no stdout terminal or audio subsystem,
|
||
/// so we:
|
||
/// - Forward `ClipboardWrite` as `ServerMessage::Clipboard` to the
|
||
/// foreground client only.
|
||
/// - Detect when a sound would be played and forward as
|
||
/// `ServerMessage::Notify { kind: Sound }` to the foreground client.
|
||
/// - Detect when a toast is set on AppState and forward as
|
||
/// `ServerMessage::Notify` to the foreground client for terminal/system delivery.
|
||
fn drain_internal_events_with_forwarding(&mut self) -> bool {
|
||
self.drain_internal_events_with_forwarding_up_to(crate::app::APP_EVENT_DRAIN_LIMIT)
|
||
.1
|
||
}
|
||
|
||
fn drain_all_internal_events_with_forwarding(&mut self) -> bool {
|
||
let mut changed = false;
|
||
loop {
|
||
let (had_event, batch_changed) =
|
||
self.drain_internal_events_with_forwarding_up_to(crate::app::APP_EVENT_DRAIN_LIMIT);
|
||
changed |= batch_changed;
|
||
if !had_event {
|
||
break;
|
||
}
|
||
}
|
||
changed
|
||
}
|
||
|
||
fn drain_internal_events_with_forwarding_up_to(&mut self, limit: usize) -> (bool, bool) {
|
||
let mut had_event = false;
|
||
let mut changed = false;
|
||
for _ in 0..limit {
|
||
let Ok(ev) = self.app.event_rx.try_recv() else {
|
||
break;
|
||
};
|
||
had_event = true;
|
||
changed |= self.handle_internal_event_with_forwarding(ev);
|
||
}
|
||
(had_event, changed)
|
||
}
|
||
|
||
fn drain_client_config_reload_request(&mut self) {
|
||
if !self.app.state.request_client_config_reload {
|
||
return;
|
||
}
|
||
self.app.state.request_client_config_reload = false;
|
||
self.send_to_all_clients(ServerMessage::ReloadSoundConfig);
|
||
}
|
||
|
||
/// Encodes a server message into a length-prefixed frame.
|
||
fn frame_server_message(msg: &ServerMessage) -> Result<Vec<u8>, protocol::FramingError> {
|
||
Self::frame_server_message_with_max(msg, MAX_FRAME_SIZE)
|
||
}
|
||
|
||
/// Encodes a server message using an explicit payload cap.
|
||
fn frame_server_message_with_max(
|
||
msg: &ServerMessage,
|
||
max_frame_size: usize,
|
||
) -> Result<Vec<u8>, protocol::FramingError> {
|
||
let mut framed = Vec::new();
|
||
protocol::write_message(&mut framed, msg)?;
|
||
let payload_len = framed.len().saturating_sub(4);
|
||
if payload_len > max_frame_size {
|
||
return Err(protocol::FramingError::Oversized {
|
||
claimed: payload_len,
|
||
max: max_frame_size,
|
||
});
|
||
}
|
||
Ok(framed)
|
||
}
|
||
|
||
/// Sends a message to all connected clients.
|
||
/// Broken connections are tracked and cleaned up.
|
||
fn send_to_all_clients(&mut self, msg: ServerMessage) {
|
||
let serialized = match Self::frame_server_message(&msg) {
|
||
Ok(framed) => framed,
|
||
Err(err) => {
|
||
warn!(err = %err, "failed to serialize message for clients");
|
||
return;
|
||
}
|
||
};
|
||
|
||
let mut broken_clients: Vec<u64> = Vec::new();
|
||
for (&client_id, client) in &mut self.clients {
|
||
if let Some(writer) = &client.writer {
|
||
if writer.control.send(serialized.clone()).is_err() {
|
||
debug!(client_id, "client writer channel closed during broadcast");
|
||
broken_clients.push(client_id);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Remove broken clients.
|
||
for client_id in broken_clients {
|
||
self.remove_client_and_resize_if_needed(client_id);
|
||
}
|
||
}
|
||
|
||
/// Sends a client-local side effect to the foreground client only.
|
||
fn send_to_foreground_client(&mut self, msg: ServerMessage) -> bool {
|
||
let Some(client_id) = self.foreground_client_id else {
|
||
return false;
|
||
};
|
||
self.send_to_client(client_id, msg)
|
||
}
|
||
|
||
/// Sends a message to a specific client. Returns false if the client
|
||
/// was not found or the send failed (client removed).
|
||
fn send_to_client(&mut self, client_id: u64, msg: ServerMessage) -> bool {
|
||
let serialized = match Self::frame_server_message(&msg) {
|
||
Ok(framed) => framed,
|
||
Err(err) => {
|
||
warn!(client_id, err = %err, "failed to serialize message for client");
|
||
return false;
|
||
}
|
||
};
|
||
|
||
if let Some(client) = self.clients.get(&client_id) {
|
||
if let Some(writer) = &client.writer {
|
||
if writer.control.send(serialized).is_err() {
|
||
debug!(
|
||
client_id,
|
||
"client writer channel closed during targeted send"
|
||
);
|
||
self.remove_client_and_resize_if_needed(client_id);
|
||
return false;
|
||
}
|
||
}
|
||
true
|
||
} else {
|
||
false
|
||
}
|
||
}
|
||
|
||
fn shutdown_terminal_stream_clients(&mut self, terminal_id: &str, reason: String) {
|
||
let client_ids = terminal_stream_client_ids(&self.clients, terminal_id);
|
||
|
||
for client_id in client_ids {
|
||
self.send_to_client(
|
||
client_id,
|
||
ServerMessage::ServerShutdown {
|
||
reason: Some(reason.clone()),
|
||
},
|
||
);
|
||
self.remove_client_and_resize_if_needed(client_id);
|
||
}
|
||
}
|
||
|
||
fn send_terminal_stream_detach_shutdown(&mut self, client_id: u64) {
|
||
if matches!(
|
||
self.clients.get(&client_id).map(|client| &client.mode),
|
||
Some(
|
||
ClientConnectionMode::TerminalAttach { .. }
|
||
| ClientConnectionMode::TerminalObserve { .. }
|
||
)
|
||
) {
|
||
self.send_to_client(
|
||
client_id,
|
||
ServerMessage::ServerShutdown {
|
||
reason: Some("detached".to_owned()),
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
fn disconnect_all_clients_for_handoff(&mut self) {
|
||
let client_ids = self.clients.keys().copied().collect::<Vec<_>>();
|
||
for client_id in client_ids {
|
||
self.send_client_graphics_cleanup(client_id);
|
||
self.send_to_client(
|
||
client_id,
|
||
ServerMessage::ServerShutdown {
|
||
reason: Some(
|
||
"live update in progress; reconnect after handoff completes".to_owned(),
|
||
),
|
||
},
|
||
);
|
||
if let Some(client) = self.clients.get_mut(&client_id) {
|
||
client.writer = None;
|
||
}
|
||
let _ = self.remove_client(client_id);
|
||
}
|
||
self.foreground_client_id = None;
|
||
self.sync_foreground_client_state();
|
||
self.resize_shared_runtime_to_effective_size();
|
||
}
|
||
|
||
fn attach_terminal_client(
|
||
&mut self,
|
||
client_id: u64,
|
||
terminal_id: String,
|
||
takeover: bool,
|
||
) -> bool {
|
||
if !self.client_is_pending_terminal_mode(client_id) {
|
||
self.send_to_client(
|
||
client_id,
|
||
ServerMessage::ServerShutdown {
|
||
reason: Some(
|
||
"terminal attach failed: connection is not pending terminal attach"
|
||
.to_owned(),
|
||
),
|
||
},
|
||
);
|
||
self.remove_client_and_resize_if_needed(client_id);
|
||
return false;
|
||
}
|
||
|
||
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_and_resize_if_needed(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_and_resize_if_needed(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_and_resize_if_needed(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.pending_terminal_attach = false;
|
||
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());
|
||
self.app
|
||
.start_pending_agent_resume_for_terminal(&real_terminal_id, rows, cols, true);
|
||
if let Some(runtime) = self.app.terminal_runtimes.get(&real_terminal_id) {
|
||
runtime.resize(rows, cols, cell_size.width_px, cell_size.height_px);
|
||
}
|
||
true
|
||
}
|
||
|
||
fn client_is_pending_terminal_mode(&self, client_id: u64) -> bool {
|
||
self.clients.get(&client_id).is_some_and(|client| {
|
||
client.pending_terminal_attach && matches!(client.mode, ClientConnectionMode::App)
|
||
})
|
||
}
|
||
|
||
/// Handles a server event. Returns true if the event requires a re-render.
|
||
fn handle_client_input_events(
|
||
&mut self,
|
||
client_id: u64,
|
||
events: Vec<crate::raw_input::RawInputEvent>,
|
||
) -> bool {
|
||
let host_surface_redraw = crate::raw_input::events_require_host_surface_redraw(
|
||
&events,
|
||
self.app.state.redraw_on_focus_gained,
|
||
);
|
||
if let Some(client) = self.clients.get_mut(&client_id) {
|
||
if host_surface_redraw {
|
||
client.request_full_redraw();
|
||
client.render_pending = true;
|
||
} else {
|
||
// Ensure semantic clients receive one post-input frame even if the
|
||
// semantic buffer compares equal. Terminal-ANSI clients must keep their
|
||
// server-side blit baseline; resetting it here forces a full redraw on
|
||
// every keypress and makes remote sessions feel extremely slow.
|
||
client.request_semantic_redraw_after_input();
|
||
}
|
||
}
|
||
self.update_client_outer_focus_from_events(client_id, &events);
|
||
let interaction = events_include_interaction(&events);
|
||
let foreground_changed = if interaction {
|
||
self.promote_client_to_foreground(client_id)
|
||
} else {
|
||
false
|
||
};
|
||
if foreground_changed {
|
||
self.resize_shared_runtime_to_effective_size_before_input();
|
||
}
|
||
let theme_changed = self.update_client_host_theme_from_events(client_id, &events);
|
||
self.app
|
||
.route_client_events(events, self.foreground_client_id == Some(client_id));
|
||
if self.app.take_config_reloaded_from_disk() {
|
||
self.reload_server_config(false);
|
||
} else {
|
||
self.sync_foreground_client_state();
|
||
}
|
||
|
||
if self.app.state.detach_requested {
|
||
self.app.state.detach_requested = false;
|
||
info!(client_id, "client detach requested via keybind");
|
||
|
||
self.send_client_graphics_cleanup(client_id);
|
||
self.send_to_client(
|
||
client_id,
|
||
ServerMessage::ServerShutdown {
|
||
reason: Some("detached".to_owned()),
|
||
},
|
||
);
|
||
|
||
if let Some(client) = self.clients.get_mut(&client_id) {
|
||
client.writer = None;
|
||
}
|
||
|
||
false
|
||
} else {
|
||
foreground_changed || theme_changed || interaction
|
||
}
|
||
}
|
||
|
||
fn handle_server_event(&mut self, ev: ServerEvent) -> bool {
|
||
if self.handoff_in_progress && Self::ignore_client_event_during_handoff(&ev) {
|
||
return false;
|
||
}
|
||
|
||
match ev {
|
||
ServerEvent::ClientConnected {
|
||
client_id,
|
||
cols,
|
||
rows,
|
||
cell_width_px,
|
||
cell_height_px,
|
||
keybindings,
|
||
writer,
|
||
render_encoding,
|
||
direct_attach_requested,
|
||
} => {
|
||
if self.handoff_in_progress {
|
||
if let Ok(message) =
|
||
Self::frame_server_message(&ServerMessage::ServerShutdown {
|
||
reason: Some(
|
||
"live update in progress; reconnect after handoff completes"
|
||
.to_owned(),
|
||
),
|
||
})
|
||
{
|
||
let _ = writer.control.send(message);
|
||
}
|
||
return false;
|
||
}
|
||
let first_app_client = !direct_attach_requested && self.app_client_count() == 0;
|
||
info!(
|
||
client_id,
|
||
cols,
|
||
rows,
|
||
cell_width_px,
|
||
cell_height_px,
|
||
?render_encoding,
|
||
"client connected"
|
||
);
|
||
let last_activity = self.allocate_activity_stamp();
|
||
self.clients.insert(
|
||
client_id,
|
||
ClientConnection::new_with_mode(
|
||
ClientConnectionMode::App,
|
||
keybindings,
|
||
(cols, rows),
|
||
crate::kitty_graphics::HostCellSize {
|
||
width_px: cell_width_px,
|
||
height_px: cell_height_px,
|
||
},
|
||
crate::terminal_theme::TerminalTheme::default(),
|
||
None,
|
||
last_activity,
|
||
render_encoding,
|
||
direct_attach_requested,
|
||
Some(writer),
|
||
),
|
||
);
|
||
if !direct_attach_requested {
|
||
self.foreground_client_id = Some(client_id);
|
||
}
|
||
if first_app_client {
|
||
self.app.mark_git_status_refresh_due(Instant::now());
|
||
}
|
||
self.sync_foreground_client_state();
|
||
self.resize_shared_runtime_to_effective_size();
|
||
self.nudge_handoff_panes_on_first_client_attach();
|
||
true
|
||
}
|
||
ServerEvent::ClientAttachTerminal {
|
||
client_id,
|
||
terminal_id,
|
||
takeover,
|
||
} => self.attach_terminal_client(client_id, terminal_id, takeover),
|
||
ServerEvent::ClientObserveTerminal { client_id, target } => {
|
||
self.observe_terminal_client(client_id, target)
|
||
}
|
||
ServerEvent::ClientControlTerminal {
|
||
client_id,
|
||
target,
|
||
takeover,
|
||
} => self.control_terminal_client(client_id, target, takeover),
|
||
ServerEvent::ClientAttachScroll {
|
||
client_id,
|
||
source,
|
||
direction,
|
||
lines,
|
||
column,
|
||
row,
|
||
modifiers,
|
||
} => self.handle_terminal_attach_scroll(
|
||
client_id, source, direction, lines, column, row, modifiers,
|
||
),
|
||
ServerEvent::ClientInput { client_id, data } => {
|
||
if self.handoff_in_progress {
|
||
debug!(
|
||
client_id,
|
||
len = data.len(),
|
||
"ignored client input during handoff"
|
||
);
|
||
return false;
|
||
}
|
||
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) = apply_terminal_attach_input(runtime, data) {
|
||
warn!(client_id, terminal_id = %terminal_id, err = %err);
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
if matches!(
|
||
self.clients.get(&client_id).map(|client| &client.mode),
|
||
Some(ClientConnectionMode::TerminalObserve { .. })
|
||
) {
|
||
return false;
|
||
}
|
||
let events = if let Some(client) = self.clients.get_mut(&client_id) {
|
||
let mut events = client.raw_input.push(&data);
|
||
// The thin client only forwards a bare ESC after its local input timeout.
|
||
if data.as_slice() == b"\x1b" {
|
||
events.extend(client.raw_input.flush_timeout());
|
||
}
|
||
events
|
||
} else {
|
||
Vec::new()
|
||
};
|
||
self.handle_client_input_events(client_id, events)
|
||
}
|
||
ServerEvent::ClientInputEvents { client_id, events } => {
|
||
if self.handoff_in_progress {
|
||
debug!(
|
||
client_id,
|
||
len = events.len(),
|
||
"ignored client input events during handoff"
|
||
);
|
||
return false;
|
||
}
|
||
debug!(
|
||
client_id,
|
||
len = events.len(),
|
||
"client input events received"
|
||
);
|
||
if matches!(
|
||
self.clients.get(&client_id).map(|client| &client.mode),
|
||
Some(ClientConnectionMode::TerminalObserve { .. })
|
||
) {
|
||
return false;
|
||
}
|
||
let events = events
|
||
.iter()
|
||
.map(crate::protocol::ClientInputEvent::to_raw_input_event)
|
||
.collect();
|
||
self.handle_client_input_events(client_id, events)
|
||
}
|
||
ServerEvent::ClientClipboardImage {
|
||
client_id,
|
||
extension,
|
||
data,
|
||
} => {
|
||
debug!(
|
||
client_id,
|
||
len = data.len(),
|
||
extension = %extension,
|
||
"client clipboard image received"
|
||
);
|
||
if matches!(
|
||
self.clients.get(&client_id).map(|client| &client.mode),
|
||
Some(ClientConnectionMode::TerminalObserve { .. })
|
||
) {
|
||
return false;
|
||
}
|
||
match self.write_client_clipboard_image(client_id, &extension, &data) {
|
||
Ok(path) => self.paste_client_clipboard_image_path(client_id, path),
|
||
Err(err) => {
|
||
warn!(client_id, err = %err, "failed to stage client clipboard image");
|
||
true
|
||
}
|
||
}
|
||
}
|
||
ServerEvent::ClientResize {
|
||
client_id,
|
||
cols,
|
||
rows,
|
||
cell_width_px,
|
||
cell_height_px,
|
||
} => {
|
||
info!(
|
||
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(ClientConnection {
|
||
mode: ClientConnectionMode::TerminalObserve { .. },
|
||
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();
|
||
return true;
|
||
}
|
||
if let Some(client) = self.clients.get_mut(&client_id) {
|
||
client.terminal_size = (cols, rows);
|
||
client.cell_size = crate::kitty_graphics::HostCellSize {
|
||
width_px: cell_width_px,
|
||
height_px: cell_height_px,
|
||
};
|
||
}
|
||
self.promote_client_to_foreground(client_id);
|
||
self.resize_shared_runtime_to_effective_size();
|
||
true
|
||
}
|
||
ServerEvent::ClientDetach { client_id } => {
|
||
info!(client_id, "client detached");
|
||
self.send_terminal_stream_detach_shutdown(client_id);
|
||
self.remove_client_and_resize_if_needed(client_id);
|
||
true
|
||
}
|
||
ServerEvent::ClientDisconnected { client_id } => {
|
||
info!(client_id, "client disconnected");
|
||
self.remove_client_and_resize_if_needed(client_id);
|
||
true
|
||
}
|
||
ServerEvent::ClientWriterDrained { client_id } => {
|
||
let Some(client) = self.clients.get_mut(&client_id) else {
|
||
return false;
|
||
};
|
||
if client.render_pending {
|
||
client.render_pending = false;
|
||
true
|
||
} else {
|
||
false
|
||
}
|
||
}
|
||
ServerEvent::QuitSignal => {
|
||
// The quit check at the top of the loop handles this.
|
||
// No render needed — the next iteration will initiate shutdown.
|
||
false
|
||
}
|
||
}
|
||
}
|
||
|
||
fn ignore_client_event_during_handoff(ev: &ServerEvent) -> bool {
|
||
!matches!(
|
||
ev,
|
||
ServerEvent::ClientConnected { .. }
|
||
| ServerEvent::ClientDisconnected { .. }
|
||
| ServerEvent::ClientWriterDrained { .. }
|
||
| ServerEvent::QuitSignal
|
||
)
|
||
}
|
||
|
||
/// Drains API requests with shutdown awareness.
|
||
///
|
||
/// During shutdown, remaining requests get a `server_unavailable` error.
|
||
fn drain_api_requests_with_shutdown_check(&mut self) -> bool {
|
||
let mut changed = false;
|
||
while let Ok(msg) = self.app.api_rx.try_recv() {
|
||
changed |= self.handle_api_request_with_shutdown_check(msg);
|
||
}
|
||
changed
|
||
}
|
||
|
||
/// Handles a single API request with shutdown awareness.
|
||
///
|
||
/// Also forwards any toast/sound notifications that result from the API
|
||
/// request to connected clients. API methods like `pane.report_agent`
|
||
/// trigger internal events that may set toast state or would normally
|
||
/// play sounds — in headless mode we forward these to clients instead.
|
||
fn handle_api_request_with_shutdown_check(&mut self, msg: api::ApiRequestMessage) -> bool {
|
||
if self.shutting_down {
|
||
// During shutdown, respond with server_unavailable.
|
||
let response = serde_json::to_string(&api::schema::ErrorResponse {
|
||
id: msg.request.id,
|
||
error: api::schema::ErrorBody {
|
||
code: "server_unavailable".into(),
|
||
message: "server is shutting down".into(),
|
||
},
|
||
})
|
||
.unwrap_or_else(|_| {
|
||
r#"{"id":"","error":{"code":"server_unavailable","message":"server is shutting down"}}"#
|
||
.to_string()
|
||
});
|
||
let _ = msg.respond_to.send(response);
|
||
return false;
|
||
}
|
||
|
||
if let api::schema::Method::ServerLiveHandoff(params) = &msg.request.method {
|
||
let response = match self.perform_live_handoff(params.clone()) {
|
||
Ok(()) => serde_json::to_string(&api::schema::SuccessResponse {
|
||
id: msg.request.id,
|
||
result: api::schema::ResponseResult::Ok {},
|
||
}),
|
||
Err(err) => serde_json::to_string(&api::schema::ErrorResponse {
|
||
id: msg.request.id,
|
||
error: api::schema::ErrorBody {
|
||
code: "handoff_failed".into(),
|
||
message: err.to_string(),
|
||
},
|
||
}),
|
||
}
|
||
.unwrap_or_else(|_| "{}".to_string());
|
||
let _ = msg.respond_to.send(response);
|
||
return true;
|
||
}
|
||
|
||
if let api::schema::Method::NotificationShow(params) = &msg.request.method {
|
||
let response =
|
||
self.handle_notification_show_api(msg.request.id.clone(), params.clone());
|
||
let _ = msg.respond_to.send(response);
|
||
return true;
|
||
}
|
||
|
||
match &msg.request.method {
|
||
api::schema::Method::ClientWindowTitleSet(params) => {
|
||
let response = self.handle_client_window_title_api(
|
||
msg.request.id.clone(),
|
||
Some(params.title.clone()),
|
||
);
|
||
let _ = msg.respond_to.send(response);
|
||
return true;
|
||
}
|
||
api::schema::Method::ClientWindowTitleClear(_) => {
|
||
let response = self.handle_client_window_title_api(msg.request.id.clone(), None);
|
||
let _ = msg.respond_to.send(response);
|
||
return true;
|
||
}
|
||
_ => {}
|
||
}
|
||
|
||
let mut changed = api::request_changes_ui(&msg.request);
|
||
let skip_default_workspace = matches!(
|
||
&msg.request.method,
|
||
api::schema::Method::ServerStop(_) | api::schema::Method::ServerLiveHandoff(_)
|
||
);
|
||
changed |= self.drain_all_internal_events_with_forwarding();
|
||
|
||
// Capture toast and effective pane states before the API call so we can
|
||
// forward resulting client-local notifications. API requests like
|
||
// pane.report_agent trigger handle_internal_event internally, which
|
||
// bypasses drain_internal_events_with_forwarding. Headless mode disables
|
||
// local sound playback, so sound notifications need to be forwarded here.
|
||
let toast_before = self.app.state.toast.clone();
|
||
let pane_states_before: Vec<(
|
||
usize,
|
||
crate::layout::PaneId,
|
||
crate::detect::AgentState,
|
||
Option<String>,
|
||
)> = {
|
||
let terminals = &self.app.state.terminals;
|
||
self.app
|
||
.state
|
||
.workspaces
|
||
.iter()
|
||
.enumerate()
|
||
.flat_map(|(ws_idx, ws)| {
|
||
ws.tabs.iter().flat_map(move |tab| {
|
||
tab.panes.iter().filter_map(move |(&pane_id, pane)| {
|
||
terminals.get(&pane.attached_terminal_id).map(|terminal| {
|
||
(
|
||
ws_idx,
|
||
pane_id,
|
||
terminal.state,
|
||
terminal.effective_agent_label().map(str::to_string),
|
||
)
|
||
})
|
||
})
|
||
})
|
||
})
|
||
.collect()
|
||
};
|
||
|
||
self.sync_foreground_client_state();
|
||
if matches!(
|
||
&msg.request.method,
|
||
api::schema::Method::WorktreeCreate(_) | api::schema::Method::WorktreeRemove(_)
|
||
) {
|
||
let deferred_changed = self
|
||
.app
|
||
.handle_deferred_worktree_api_request(msg.request, msg.respond_to);
|
||
return changed | deferred_changed;
|
||
}
|
||
let response = if matches!(
|
||
&msg.request.method,
|
||
api::schema::Method::ServerReloadConfig(_)
|
||
) {
|
||
let report = self.reload_server_config(true);
|
||
serde_json::to_string(&api::schema::SuccessResponse {
|
||
id: msg.request.id.clone(),
|
||
result: api::schema::ResponseResult::ConfigReload {
|
||
status: report.status,
|
||
diagnostics: report.diagnostics,
|
||
},
|
||
})
|
||
.unwrap_or_else(|err| {
|
||
serde_json::to_string(&api::schema::ErrorResponse {
|
||
id: String::new(),
|
||
error: api::schema::ErrorBody {
|
||
code: "serialization_error".into(),
|
||
message: err.to_string(),
|
||
},
|
||
})
|
||
.unwrap_or_else(|_| "{}".to_string())
|
||
})
|
||
} else {
|
||
self.app
|
||
.handle_api_request_after_internal_events_drained(msg.request)
|
||
};
|
||
let _ = msg.respond_to.send(response);
|
||
|
||
// Forward new toast state only when a client-local delivery mode is selected.
|
||
// Herdr delivery renders the toast in-frame and must not ask clients to
|
||
// show a terminal or system notification.
|
||
let toast_after = self.app.state.toast.clone();
|
||
let forwarded_toast_from_state = if should_forward_toast_to_clients(
|
||
self.app.state.toast_config.delivery,
|
||
) && toast_after.is_some()
|
||
&& toast_after != toast_before
|
||
{
|
||
if let Some(toast) = &toast_after {
|
||
debug!(title = %toast.title, body = %toast.context, "forwarding toast notification from API request");
|
||
self.send_notify_to_foreground_client(
|
||
toast_notify_kind(self.app.state.toast_config.delivery)
|
||
.expect("toast forwarding requires a client notification kind"),
|
||
&toast.title,
|
||
non_empty_body(&toast.context),
|
||
);
|
||
true
|
||
} else {
|
||
false
|
||
}
|
||
} else {
|
||
false
|
||
};
|
||
|
||
// Forward notifications for effective pane state changes that occurred
|
||
// during the API request. Hook authority is already folded into
|
||
// pane.state, so raw hook transitions must not produce separate sounds.
|
||
for (ws_idx, pane_id, prev_state, prev_agent_label) in &pane_states_before {
|
||
let pane_after = self
|
||
.app
|
||
.state
|
||
.workspaces
|
||
.get(*ws_idx)
|
||
.and_then(|ws| ws.tabs.iter().find_map(|tab| tab.panes.get(pane_id)));
|
||
|
||
let Some(pane_after) = pane_after else {
|
||
continue;
|
||
};
|
||
|
||
let Some(terminal_after) = self
|
||
.app
|
||
.state
|
||
.terminals
|
||
.get(&pane_after.attached_terminal_id)
|
||
else {
|
||
continue;
|
||
};
|
||
|
||
let new_state = terminal_after.state;
|
||
if new_state == *prev_state {
|
||
continue;
|
||
}
|
||
|
||
let is_active_tab = self.app.state.pane_is_in_active_tab(*ws_idx, *pane_id);
|
||
let suppress_active_tab_notifications =
|
||
self.active_tab_suppresses_notifications(is_active_tab);
|
||
|
||
let agent = terminal_after.effective_known_agent();
|
||
let agent_label = terminal_after.effective_agent_label().map(str::to_string);
|
||
|
||
debug!(
|
||
ws_idx,
|
||
pane_id = pane_id.raw(),
|
||
prev_state = ?prev_state,
|
||
new_state = ?new_state,
|
||
agent = ?agent,
|
||
"pane effective state changed during API request, checking notification"
|
||
);
|
||
|
||
if !forwarded_toast_from_state
|
||
&& self.app.state.toast_config.delay_seconds == 0
|
||
&& should_forward_toast_to_clients(self.app.state.toast_config.delivery)
|
||
{
|
||
if let Some(kind) =
|
||
crate::app::actions::notification_toast_for_state_change_with_agent_labels(
|
||
suppress_active_tab_notifications,
|
||
*prev_state,
|
||
new_state,
|
||
prev_agent_label.as_deref(),
|
||
agent_label.as_deref(),
|
||
)
|
||
{
|
||
if let Some(agent_label) = self
|
||
.app
|
||
.state
|
||
.terminals
|
||
.get(&pane_after.attached_terminal_id)
|
||
.and_then(|terminal| terminal.effective_agent_label())
|
||
{
|
||
let event_text = match kind {
|
||
crate::app::state::ToastKind::NeedsAttention => "needs attention",
|
||
crate::app::state::ToastKind::Finished => "finished",
|
||
crate::app::state::ToastKind::UpdateInstalled => "updated",
|
||
};
|
||
let workspace_label = self.app.state.workspaces[*ws_idx].display_name_from(
|
||
&self.app.state.terminals,
|
||
&self.app.terminal_runtimes,
|
||
);
|
||
let context = crate::app::actions::notification_context(
|
||
&self.app.state.workspaces[*ws_idx],
|
||
&workspace_label,
|
||
*ws_idx,
|
||
*pane_id,
|
||
);
|
||
self.send_notify_to_foreground_client(
|
||
toast_notify_kind(self.app.state.toast_config.delivery)
|
||
.expect("toast forwarding requires a client notification kind"),
|
||
format!("{agent_label} {event_text}"),
|
||
non_empty_body(&context),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Forward sound notification when server-side sound policy allows it.
|
||
// Clients still decide locally whether they can execute the side effect.
|
||
if self.app.state.toast_config.delay_seconds == 0 && self.app.state.sound.allows(agent)
|
||
{
|
||
if let Some(sound) =
|
||
crate::app::actions::notification_sound_for_state_change_with_agent_labels(
|
||
suppress_active_tab_notifications,
|
||
*prev_state,
|
||
new_state,
|
||
prev_agent_label.as_deref(),
|
||
agent_label.as_deref(),
|
||
)
|
||
{
|
||
debug!(sound = ?sound, "forwarding sound notification from API request");
|
||
self.send_notify_to_foreground_client(
|
||
protocol::NotifyKind::Sound,
|
||
sound_notify_message(sound),
|
||
None,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
if !skip_default_workspace && latest_app_client(&self.clients).is_some() {
|
||
changed |= self.app.ensure_default_workspace();
|
||
}
|
||
|
||
changed
|
||
}
|
||
|
||
fn stream_host_mouse_capture_mode(&mut self) {
|
||
let enabled = self
|
||
.app
|
||
.state
|
||
.should_capture_host_mouse_from(&self.app.terminal_runtimes);
|
||
let serialized = match Self::frame_server_message(&ServerMessage::MouseCapture { enabled })
|
||
{
|
||
Ok(framed) => framed,
|
||
Err(err) => {
|
||
warn!(err = %err, "failed to serialize mouse capture mode for clients");
|
||
return;
|
||
}
|
||
};
|
||
|
||
let mut broken_clients: Vec<u64> = Vec::new();
|
||
for (&client_id, client) in &mut self.clients {
|
||
if !client.is_full_app_client() {
|
||
continue;
|
||
}
|
||
if client.host_mouse_capture_active == Some(enabled) {
|
||
continue;
|
||
}
|
||
let Some(writer) = &client.writer else {
|
||
continue;
|
||
};
|
||
if writer.control.send(serialized.clone()).is_err() {
|
||
debug!(
|
||
client_id,
|
||
"client writer channel closed during mouse capture update"
|
||
);
|
||
broken_clients.push(client_id);
|
||
continue;
|
||
}
|
||
client.host_mouse_capture_active = Some(enabled);
|
||
}
|
||
|
||
for client_id in broken_clients {
|
||
self.remove_client_and_resize_if_needed(client_id);
|
||
}
|
||
}
|
||
|
||
/// Renders the current state to client-sized virtual buffers and streams
|
||
/// frames to all connected clients.
|
||
fn render_retained_pty_update_and_stream(&mut self) -> bool {
|
||
crate::render_prof::event("retained.attempt");
|
||
let retained_started = crate::render_prof::timer();
|
||
macro_rules! retained_fallback {
|
||
($reason:literal) => {{
|
||
crate::render_prof::event(concat!("retained_fallback.", $reason));
|
||
crate::render_prof::duration_since("retained.total", retained_started);
|
||
return false;
|
||
}};
|
||
}
|
||
macro_rules! retained_success {
|
||
($reason:literal) => {{
|
||
crate::render_prof::event("retained.success");
|
||
crate::render_prof::event(concat!("retained_success.", $reason));
|
||
crate::render_prof::duration_since("retained.total", retained_started);
|
||
return true;
|
||
}};
|
||
}
|
||
|
||
if !self.retained_pty_update_allowed_by_app_state() {
|
||
retained_fallback!("unsafe_app_state");
|
||
}
|
||
|
||
let render_targets = render_targets(&self.clients, self.foreground_client_id);
|
||
let [(client_id, (cols, rows), cell_size, _is_foreground, mode)] =
|
||
render_targets.as_slice()
|
||
else {
|
||
retained_fallback!("multiple_or_no_target");
|
||
};
|
||
if !matches!(mode, ClientConnectionMode::App) {
|
||
retained_fallback!("not_app_client");
|
||
}
|
||
let Some(client) = self.clients.get(client_id) else {
|
||
retained_fallback!("client_missing");
|
||
};
|
||
if client.render_pending {
|
||
retained_fallback!("render_pending");
|
||
}
|
||
if self.app.state.kitty_graphics_enabled && !client.graphics_cache.is_empty() {
|
||
retained_fallback!("graphics_cache_active");
|
||
}
|
||
if client.graphics_surface_reset_pending {
|
||
retained_fallback!("graphics_surface_reset");
|
||
}
|
||
if self.app.state.kitty_graphics_enabled
|
||
&& cell_size.is_known()
|
||
&& crate::kitty_graphics::has_visible_pane_graphics(
|
||
&self.app.state,
|
||
&self.app.terminal_runtimes,
|
||
*cell_size,
|
||
)
|
||
{
|
||
retained_fallback!("visible_kitty_graphics");
|
||
}
|
||
let Some(mut frame) = client.render_state.last_frame().cloned() else {
|
||
retained_fallback!("no_last_frame");
|
||
};
|
||
if frame.width != *cols || frame.height != *rows {
|
||
retained_fallback!("frame_size_mismatch");
|
||
}
|
||
frame.graphics.clear();
|
||
|
||
let Some(ws_idx) = self.app.state.active else {
|
||
retained_fallback!("no_active_workspace");
|
||
};
|
||
let pane_infos = self.app.state.view.pane_infos.clone();
|
||
if pane_infos.is_empty() {
|
||
retained_fallback!("no_pane_info");
|
||
}
|
||
|
||
let mut touched = false;
|
||
for info in pane_infos {
|
||
if !rect_fits_frame(info.inner_rect, &frame) {
|
||
retained_fallback!("pane_rect_outside_frame");
|
||
}
|
||
let Some(runtime) = self.app.state.runtime_for_pane_in_workspace(
|
||
&self.app.terminal_runtimes,
|
||
ws_idx,
|
||
info.id,
|
||
) else {
|
||
retained_fallback!("missing_runtime");
|
||
};
|
||
match runtime.collect_dirty_patch(info.inner_rect.width, info.inner_rect.height) {
|
||
crate::pane::TerminalDirtyPatchOutcome::Clean => {
|
||
crate::render_prof::event("retained.pane_clean");
|
||
}
|
||
crate::pane::TerminalDirtyPatchOutcome::Fallback => {
|
||
retained_fallback!("dirty_patch_fallback");
|
||
}
|
||
crate::pane::TerminalDirtyPatchOutcome::Patch(patch) => {
|
||
crate::render_prof::event("retained.pane_patch");
|
||
crate::render_prof::counter("retained.patch_rows", patch.rows.len() as u64);
|
||
if dirty_patch_intersects_hyperlinks(&frame, info.inner_rect, &patch) {
|
||
retained_fallback!("hyperlink_intersection");
|
||
}
|
||
if !apply_terminal_dirty_patch(&mut frame, info.inner_rect, patch) {
|
||
retained_fallback!("patch_apply_failed");
|
||
}
|
||
touched = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
let previous_cursor = frame.cursor.clone();
|
||
frame.cursor = crate::server::render_stream::focused_terminal_cursor(
|
||
&self.app.state,
|
||
&self.app.terminal_runtimes,
|
||
);
|
||
let cursor_changed = frame.cursor != previous_cursor;
|
||
|
||
if !touched && !cursor_changed {
|
||
retained_success!("clean_no_cursor_change");
|
||
}
|
||
|
||
let mut broken_clients = Vec::new();
|
||
let sent = self.send_retained_frame_to_client(*client_id, frame, &mut broken_clients);
|
||
for broken_client in broken_clients {
|
||
self.remove_client_and_resize_if_needed(broken_client);
|
||
}
|
||
if sent {
|
||
retained_success!("sent");
|
||
}
|
||
retained_fallback!("send_failed");
|
||
}
|
||
|
||
fn retained_pty_update_allowed_by_app_state(&self) -> bool {
|
||
self.app.state.mode == app::Mode::Terminal
|
||
&& self.app.state.selection.is_none()
|
||
&& self.app.state.copy_mode.is_none()
|
||
&& self.app.state.context_menu.is_none()
|
||
&& self.app.state.toast.is_none()
|
||
&& !self.app.full_redraw_pending
|
||
}
|
||
|
||
fn send_retained_frame_to_client(
|
||
&mut self,
|
||
client_id: u64,
|
||
frame: FrameData,
|
||
broken_clients: &mut Vec<u64>,
|
||
) -> bool {
|
||
let Some(client) = self.clients.get_mut(&client_id) else {
|
||
crate::render_prof::event("retained_send_fallback.client_missing");
|
||
return false;
|
||
};
|
||
let Some(writer) = client.writer.as_ref().cloned() else {
|
||
crate::render_prof::event("retained_send_fallback.writer_missing");
|
||
return false;
|
||
};
|
||
let prepare_started = crate::render_prof::timer();
|
||
let Some(prepared) = client.render_state.prepare_frame(frame) else {
|
||
client.render_pending = false;
|
||
crate::render_prof::event("retained_send.skip_identical");
|
||
crate::render_prof::duration_since("retained_send.prepare_frame", prepare_started);
|
||
return true;
|
||
};
|
||
crate::render_prof::duration_since("retained_send.prepare_frame", prepare_started);
|
||
let serialize_started = crate::render_prof::timer();
|
||
let serialized = match Self::frame_server_message(prepared.message()) {
|
||
Ok(framed) => {
|
||
crate::render_prof::duration_since("retained_send.serialize", serialize_started);
|
||
framed
|
||
}
|
||
Err(protocol::FramingError::Oversized { claimed, max }) => {
|
||
warn!(
|
||
client_id,
|
||
claimed, max, "skipping oversized retained frame for client"
|
||
);
|
||
crate::render_prof::event("retained_send_fallback.serialize_oversized");
|
||
crate::render_prof::duration_since("retained_send.serialize", serialize_started);
|
||
return false;
|
||
}
|
||
Err(err) => {
|
||
warn!(client_id, err = %err, "failed to serialize retained frame for client");
|
||
broken_clients.push(client_id);
|
||
crate::render_prof::event("retained_send_fallback.serialize_error");
|
||
crate::render_prof::duration_since("retained_send.serialize", serialize_started);
|
||
return false;
|
||
}
|
||
};
|
||
crate::render_prof::counter("retained_send.bytes", serialized.len() as u64);
|
||
|
||
let send_started = crate::render_prof::timer();
|
||
match writer.render.try_send(serialized) {
|
||
Ok(()) => {
|
||
client.render_pending = false;
|
||
client.render_state.commit_sent_frame(prepared);
|
||
crate::render_prof::event("retained_send.sent");
|
||
crate::render_prof::duration_since("retained_send.try_send", send_started);
|
||
true
|
||
}
|
||
Err(std::sync::mpsc::TrySendError::Full(_)) => {
|
||
client.render_pending = true;
|
||
crate::render_prof::event("retained_send_fallback.queue_full");
|
||
crate::render_prof::duration_since("retained_send.try_send", send_started);
|
||
debug!(
|
||
client_id,
|
||
"render queue full, deferring latest retained frame"
|
||
);
|
||
false
|
||
}
|
||
Err(std::sync::mpsc::TrySendError::Disconnected(_)) => {
|
||
debug!(client_id, "client writer channel closed, marking as broken");
|
||
broken_clients.push(client_id);
|
||
crate::render_prof::event("retained_send_fallback.writer_disconnected");
|
||
crate::render_prof::duration_since("retained_send.try_send", send_started);
|
||
false
|
||
}
|
||
}
|
||
}
|
||
|
||
fn render_and_stream(&mut self) {
|
||
let full_started = crate::render_prof::timer();
|
||
let render_targets = render_targets(&self.clients, self.foreground_client_id);
|
||
|
||
if render_targets.is_empty() {
|
||
let (cols, rows) = self.effective_size;
|
||
let area = Rect::new(0, 0, cols, rows);
|
||
let resize_panes = self.app.state.view.pane_infos.is_empty();
|
||
let render_started = crate::render_prof::timer();
|
||
let _ = crate::server::render_stream::render_virtual_with_runtime_registry(
|
||
&mut self.app.state,
|
||
&self.app.terminal_runtimes,
|
||
area,
|
||
resize_panes,
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
);
|
||
crate::render_prof::duration_since("full_render.render_virtual", render_started);
|
||
self.app.full_redraw_pending = false;
|
||
crate::render_prof::duration_since("full_render.total", full_started);
|
||
debug!(
|
||
cols,
|
||
rows, resize_panes, "rendered virtual frame with no attached clients"
|
||
);
|
||
return;
|
||
}
|
||
|
||
let mut broken_clients: Vec<u64> = Vec::new();
|
||
let mut deferred_frame = false;
|
||
for (client_id, (cols, rows), cell_size, is_foreground, mode) in render_targets {
|
||
let area = Rect::new(0, 0, cols, rows);
|
||
let is_app_client = matches!(mode, ClientConnectionMode::App);
|
||
let mut frame = match mode {
|
||
ClientConnectionMode::App => {
|
||
let render_started = crate::render_prof::timer();
|
||
let (buffer, cursor) =
|
||
if self.app.state.kitty_graphics_enabled && cell_size.is_known() {
|
||
crate::server::render_stream::render_virtual_with_runtime_registry(
|
||
&mut self.app.state,
|
||
&self.app.terminal_runtimes,
|
||
area,
|
||
is_foreground,
|
||
cell_size,
|
||
)
|
||
} else {
|
||
crate::server::render_stream::render_virtual_with_runtime_registry(
|
||
&mut self.app.state,
|
||
&self.app.terminal_runtimes,
|
||
area,
|
||
is_foreground,
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
)
|
||
};
|
||
crate::render_prof::duration_since(
|
||
"full_render.render_virtual",
|
||
render_started,
|
||
);
|
||
let hyperlinks_started = crate::render_prof::timer();
|
||
let hyperlinks = crate::server::render_stream::visible_hyperlinks(
|
||
&self.app.state,
|
||
&self.app.terminal_runtimes,
|
||
);
|
||
crate::render_prof::duration_since(
|
||
"full_render.visible_hyperlinks",
|
||
hyperlinks_started,
|
||
);
|
||
let frame_started = crate::render_prof::timer();
|
||
let frame = FrameData::from_ratatui_buffer_with_hyperlinks(
|
||
&buffer,
|
||
cursor,
|
||
&hyperlinks,
|
||
);
|
||
crate::render_prof::duration_since("full_render.frame_build", frame_started);
|
||
frame
|
||
}
|
||
ClientConnectionMode::TerminalAttach { terminal_id }
|
||
| ClientConnectionMode::TerminalObserve { 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 render_started = crate::render_prof::timer();
|
||
let (buffer, cursor) =
|
||
crate::server::render_stream::render_terminal_virtual(runtime, area);
|
||
crate::render_prof::duration_since(
|
||
"full_render.render_terminal_virtual",
|
||
render_started,
|
||
);
|
||
let hyperlinks_started = crate::render_prof::timer();
|
||
let hyperlinks = runtime.visible_hyperlinks(area);
|
||
crate::render_prof::duration_since(
|
||
"full_render.visible_hyperlinks",
|
||
hyperlinks_started,
|
||
);
|
||
let frame_started = crate::render_prof::timer();
|
||
let frame = FrameData::from_ratatui_buffer_with_hyperlinks(
|
||
&buffer,
|
||
cursor,
|
||
&hyperlinks,
|
||
);
|
||
crate::render_prof::duration_since("full_render.frame_build", frame_started);
|
||
frame
|
||
}
|
||
};
|
||
|
||
let Some(client) = self.clients.get_mut(&client_id) else {
|
||
continue;
|
||
};
|
||
let mut next_graphics_cache = client.graphics_cache.clone();
|
||
let graphics_surface_reset_pending = client.graphics_surface_reset_pending;
|
||
if is_app_client && self.app.state.kitty_graphics_enabled && cell_size.is_known() {
|
||
if graphics_surface_reset_pending {
|
||
frame.graphics = next_graphics_cache.clear_bytes();
|
||
}
|
||
let graphics_started = crate::render_prof::timer();
|
||
frame
|
||
.graphics
|
||
.extend(crate::kitty_graphics::encode_local_pane_graphics(
|
||
&self.app.state,
|
||
&self.app.terminal_runtimes,
|
||
cell_size,
|
||
&mut next_graphics_cache,
|
||
));
|
||
crate::render_prof::duration_since("full_render.graphics_encode", graphics_started);
|
||
} else {
|
||
frame.graphics = next_graphics_cache.clear_bytes();
|
||
}
|
||
|
||
let Some(writer) = client.writer.as_ref().cloned() else {
|
||
crate::render_prof::event("full_render.writer_missing");
|
||
continue;
|
||
};
|
||
|
||
let mut commit_graphics_cache = true;
|
||
if frame.graphics.len() > MAX_GRAPHICS_FRAME_SIZE {
|
||
warn!(
|
||
client_id,
|
||
graphics_bytes = frame.graphics.len(),
|
||
max = MAX_GRAPHICS_FRAME_SIZE,
|
||
"dropping oversized graphics payload for client frame"
|
||
);
|
||
frame.graphics.clear();
|
||
commit_graphics_cache = false;
|
||
}
|
||
|
||
let max_frame_size = if frame.graphics.is_empty() {
|
||
MAX_FRAME_SIZE
|
||
} else {
|
||
MAX_GRAPHICS_FRAME_SIZE
|
||
};
|
||
let has_graphics = !frame.graphics.is_empty();
|
||
let prepare_started = crate::render_prof::timer();
|
||
let Some(mut prepared) = client.render_state.prepare_frame(frame) else {
|
||
client.render_pending = false;
|
||
crate::render_prof::event("full_render.skip_identical");
|
||
crate::render_prof::duration_since("full_render.prepare_frame", prepare_started);
|
||
continue;
|
||
};
|
||
crate::render_prof::duration_since("full_render.prepare_frame", prepare_started);
|
||
|
||
let serialize_started = crate::render_prof::timer();
|
||
let serialized = match Self::frame_server_message_with_max(
|
||
prepared.message(),
|
||
max_frame_size,
|
||
) {
|
||
Ok(framed) => {
|
||
crate::render_prof::duration_since("full_render.serialize", serialize_started);
|
||
framed
|
||
}
|
||
Err(protocol::FramingError::Oversized { claimed, max }) if has_graphics => {
|
||
warn!(
|
||
client_id,
|
||
claimed, max, "dropping graphics from oversized frame for client"
|
||
);
|
||
let Some(mut text_only_frame) = prepared.into_frame() else {
|
||
crate::render_prof::event("full_render.serialize_error");
|
||
crate::render_prof::duration_since(
|
||
"full_render.serialize",
|
||
serialize_started,
|
||
);
|
||
continue;
|
||
};
|
||
text_only_frame.graphics.clear();
|
||
let Some(text_only_prepared) =
|
||
client.render_state.prepare_frame(text_only_frame)
|
||
else {
|
||
client.render_pending = false;
|
||
crate::render_prof::event("full_render.skip_identical_text_only");
|
||
crate::render_prof::duration_since(
|
||
"full_render.serialize",
|
||
serialize_started,
|
||
);
|
||
continue;
|
||
};
|
||
let framed = match Self::frame_server_message(text_only_prepared.message()) {
|
||
Ok(framed) => framed,
|
||
Err(err) => {
|
||
warn!(client_id, err = %err, "failed to serialize text-only frame for client");
|
||
broken_clients.push(client_id);
|
||
crate::render_prof::event("full_render.serialize_error");
|
||
crate::render_prof::duration_since(
|
||
"full_render.serialize",
|
||
serialize_started,
|
||
);
|
||
continue;
|
||
}
|
||
};
|
||
prepared = text_only_prepared;
|
||
commit_graphics_cache = false;
|
||
crate::render_prof::duration_since("full_render.serialize", serialize_started);
|
||
framed
|
||
}
|
||
Err(protocol::FramingError::Oversized { claimed, max }) => {
|
||
warn!(
|
||
client_id,
|
||
claimed, max, "skipping oversized frame for client"
|
||
);
|
||
crate::render_prof::event("full_render.serialize_oversized");
|
||
crate::render_prof::duration_since("full_render.serialize", serialize_started);
|
||
continue;
|
||
}
|
||
Err(err) => {
|
||
warn!(client_id, err = %err, "failed to serialize frame for client");
|
||
broken_clients.push(client_id);
|
||
crate::render_prof::event("full_render.serialize_error");
|
||
crate::render_prof::duration_since("full_render.serialize", serialize_started);
|
||
continue;
|
||
}
|
||
};
|
||
crate::render_prof::counter("full_render.bytes", serialized.len() as u64);
|
||
|
||
let send_started = crate::render_prof::timer();
|
||
match writer.render.try_send(serialized) {
|
||
Ok(()) => {
|
||
client.render_pending = false;
|
||
if commit_graphics_cache {
|
||
client.graphics_cache = next_graphics_cache;
|
||
client.graphics_surface_reset_pending = false;
|
||
}
|
||
client.render_state.commit_sent_frame(prepared);
|
||
crate::render_prof::event("full_render.sent");
|
||
crate::render_prof::duration_since("full_render.try_send", send_started);
|
||
}
|
||
Err(std::sync::mpsc::TrySendError::Full(_)) => {
|
||
client.render_pending = true;
|
||
deferred_frame = true;
|
||
crate::render_prof::event("full_render.queue_full");
|
||
crate::render_prof::duration_since("full_render.try_send", send_started);
|
||
debug!(client_id, "render queue full, deferring latest frame");
|
||
continue;
|
||
}
|
||
Err(std::sync::mpsc::TrySendError::Disconnected(_)) => {
|
||
debug!(client_id, "client writer channel closed, marking as broken");
|
||
broken_clients.push(client_id);
|
||
crate::render_prof::event("full_render.writer_disconnected");
|
||
crate::render_prof::duration_since("full_render.try_send", send_started);
|
||
continue;
|
||
}
|
||
}
|
||
}
|
||
|
||
if !broken_clients.is_empty() {
|
||
for client_id in broken_clients {
|
||
self.remove_client_and_resize_if_needed(client_id);
|
||
}
|
||
}
|
||
|
||
let (cols, rows) = self.effective_size;
|
||
if !deferred_frame {
|
||
self.app.full_redraw_pending = false;
|
||
}
|
||
crate::render_prof::duration_since("full_render.total", full_started);
|
||
debug!(cols, rows, foreground_client_id = ?self.foreground_client_id, "rendered virtual frame(s)");
|
||
}
|
||
|
||
/// Handle scheduled tasks for the headless server.
|
||
///
|
||
/// Similar to `App::handle_scheduled_tasks` but without resize polling
|
||
/// (the server doesn't have a terminal to resize).
|
||
fn handle_scheduled_tasks_headless(&mut self, now: Instant, geometry_dirty: bool) -> bool {
|
||
let mut changed = false;
|
||
|
||
self.app.sync_headless_animation_timer(now);
|
||
|
||
// No resize polling needed — server has no terminal.
|
||
// Client resize messages drive size changes instead.
|
||
|
||
if self
|
||
.app
|
||
.config_diagnostic_deadline
|
||
.is_some_and(|deadline| now >= deadline)
|
||
{
|
||
self.app.config_diagnostic_deadline = None;
|
||
self.app.state.config_diagnostic = None;
|
||
changed = true;
|
||
}
|
||
|
||
if self
|
||
.app
|
||
.toast_deadline
|
||
.is_some_and(|deadline| now >= deadline)
|
||
{
|
||
self.app.toast_deadline = None;
|
||
self.app.state.toast = None;
|
||
changed = true;
|
||
}
|
||
|
||
if self
|
||
.app
|
||
.state
|
||
.next_pending_agent_notification_deadline()
|
||
.is_some_and(|deadline| now >= deadline)
|
||
{
|
||
let previous_toast = self.app.state.toast.clone();
|
||
let mut deliveries = self.app.state.drain_due_agent_notifications(now);
|
||
if !deliveries.is_empty() {
|
||
self.app
|
||
.refresh_agent_notification_delivery_contexts(&mut deliveries);
|
||
self.app.sync_toast_deadline(previous_toast);
|
||
for delivery in &deliveries {
|
||
self.forward_agent_notification_delivery(delivery);
|
||
}
|
||
changed = true;
|
||
}
|
||
}
|
||
|
||
if self
|
||
.app
|
||
.copy_feedback_deadline
|
||
.is_some_and(|deadline| now >= deadline)
|
||
{
|
||
self.app.copy_feedback_deadline = None;
|
||
self.app.state.copy_feedback = None;
|
||
changed = true;
|
||
}
|
||
|
||
if self
|
||
.app
|
||
.next_animation_tick
|
||
.is_some_and(|deadline| now >= deadline)
|
||
{
|
||
self.app.state.spinner_tick = self
|
||
.app
|
||
.state
|
||
.spinner_tick
|
||
.wrapping_add(app::HEADLESS_ANIMATION_TICK_STEP);
|
||
self.app.next_animation_tick = Some(now + app::HEADLESS_ANIMATION_INTERVAL);
|
||
changed = true;
|
||
}
|
||
|
||
if self
|
||
.app
|
||
.selection_autoscroll_deadline
|
||
.is_some_and(|deadline| now >= deadline)
|
||
{
|
||
self.app.tick_selection_autoscroll(now);
|
||
changed = true;
|
||
}
|
||
|
||
changed |= self.app.clear_due_selection_highlight(now);
|
||
|
||
if self.has_app_client() {
|
||
self.app.start_git_status_refresh_if_due(now);
|
||
}
|
||
|
||
if self
|
||
.app
|
||
.next_auto_update_check
|
||
.is_some_and(|deadline| now >= deadline)
|
||
{
|
||
self.app.run_auto_update_check();
|
||
}
|
||
|
||
if self
|
||
.app
|
||
.next_agent_manifest_update_check
|
||
.is_some_and(|deadline| now >= deadline)
|
||
{
|
||
self.app.run_agent_manifest_update_check();
|
||
}
|
||
|
||
if self
|
||
.app
|
||
.session_save_deadline
|
||
.is_some_and(|deadline| now >= deadline)
|
||
{
|
||
self.app.save_session_now();
|
||
}
|
||
|
||
if let Some(deadline) = self
|
||
.app
|
||
.agent_metadata_deadline
|
||
.filter(|deadline| now >= *deadline)
|
||
{
|
||
let previous_toast = self.app.state.toast.clone();
|
||
for update in self.app.state.expire_agent_metadata_at(deadline, now) {
|
||
self.app
|
||
.refresh_new_herdr_toast_context_for_update(&update, &previous_toast);
|
||
self.app.emit_pane_state_update(&update);
|
||
}
|
||
self.app.sync_agent_metadata_deadline();
|
||
changed = true;
|
||
}
|
||
|
||
if geometry_dirty || self.foreground_client_id.is_none() {
|
||
self.app.pending_agent_resume_deadline = None;
|
||
} else {
|
||
self.app.sync_pending_agent_resume_deadline(now);
|
||
changed |= self
|
||
.app
|
||
.start_pending_agent_resumes(self.app.pending_agent_resume_due(now));
|
||
}
|
||
self.app.sync_headless_animation_timer(now);
|
||
changed
|
||
}
|
||
|
||
/// Initiates graceful shutdown.
|
||
fn initiate_shutdown(&mut self) {
|
||
if self.shutting_down {
|
||
return;
|
||
}
|
||
info!("server shutdown initiated");
|
||
self.shutting_down = true;
|
||
|
||
// Clear client-local host graphics, then send ServerShutdown to all connected clients.
|
||
self.send_all_clients_graphics_cleanup();
|
||
let shutdown_msg = ServerMessage::ServerShutdown {
|
||
reason: Some("server is shutting down".to_owned()),
|
||
};
|
||
self.send_to_all_clients(shutdown_msg);
|
||
|
||
// Give client writer threads a moment to flush the shutdown message.
|
||
// A short sleep ensures the message is written to the socket before
|
||
// we close the connections.
|
||
std::thread::sleep(Duration::from_millis(50));
|
||
|
||
// Signal the main loop to exit.
|
||
self.should_quit.store(true, Ordering::Release);
|
||
self.app.state.should_quit = true;
|
||
}
|
||
|
||
/// Completes the shutdown sequence: send ServerShutdown to clients,
|
||
/// close client connections, remove socket files, and clean up.
|
||
fn complete_shutdown(&mut self) -> io::Result<()> {
|
||
info!("completing server shutdown");
|
||
|
||
// Send ServerShutdown to all remaining clients.
|
||
if !self.clients.is_empty() {
|
||
self.send_all_clients_graphics_cleanup();
|
||
let shutdown_msg = ServerMessage::ServerShutdown {
|
||
reason: Some("server is shutting down".to_owned()),
|
||
};
|
||
self.send_to_all_clients(shutdown_msg);
|
||
|
||
// Give writer threads a moment to flush before closing.
|
||
std::thread::sleep(Duration::from_millis(50));
|
||
}
|
||
|
||
// Drain remaining API requests with server_unavailable.
|
||
self.drain_api_requests_with_shutdown_check();
|
||
|
||
// Close all client connections.
|
||
let staged_files = self
|
||
.clients
|
||
.drain()
|
||
.flat_map(|(_, client)| client.staged_clipboard_files)
|
||
.collect::<Vec<_>>();
|
||
crate::server::clipboard_image::remove_files(staged_files);
|
||
|
||
// Remove socket files.
|
||
self.cleanup_sockets()?;
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// Removes socket files created by the server.
|
||
fn cleanup_sockets(&self) -> io::Result<()> {
|
||
if let Err(err) =
|
||
remove_socket_file_if_owned(&self.client_socket_path, &self.client_socket_identity)
|
||
{
|
||
if err.kind() != io::ErrorKind::NotFound {
|
||
warn!(
|
||
path = %self.client_socket_path.display(),
|
||
err = %err,
|
||
"failed to remove client socket on shutdown"
|
||
);
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
impl Drop for HeadlessServer {
|
||
fn drop(&mut self) {
|
||
let staged_files = self
|
||
.clients
|
||
.drain()
|
||
.flat_map(|(_, client)| client.staged_clipboard_files)
|
||
.collect::<Vec<_>>();
|
||
crate::server::clipboard_image::remove_files(staged_files);
|
||
let _ = self.cleanup_sockets();
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Installs a Ctrl+C handler that sets the should_quit flag and wakes up
|
||
/// the event loop by sending a QuitSignal on the server event channel.
|
||
fn ctrlc_handler(should_quit: Arc<AtomicBool>, server_event_tx: mpsc::Sender<ServerEvent>) {
|
||
let _ = ctrlc::set_handler(move || {
|
||
should_quit.store(true, Ordering::Release);
|
||
// Wake up the event loop so the quit flag is checked promptly.
|
||
let _ = server_event_tx.try_send(ServerEvent::QuitSignal);
|
||
});
|
||
}
|
||
|
||
/// Sleep until a deadline, or return pending if none.
|
||
async fn sleep_until_or_pending(deadline: Option<Instant>) {
|
||
match deadline {
|
||
Some(deadline) => tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)).await,
|
||
None => std::future::pending().await,
|
||
}
|
||
}
|
||
|
||
fn sanitize_notification_text(value: &str, max_chars: usize) -> Option<String> {
|
||
let mut sanitized = String::new();
|
||
let mut previous_space = false;
|
||
for ch in value.chars() {
|
||
let replacement = if ch == '\n' || ch == '\r' || ch == '\t' {
|
||
Some(' ')
|
||
} else if ch.is_control() {
|
||
None
|
||
} else {
|
||
Some(ch)
|
||
};
|
||
let Some(ch) = replacement else {
|
||
continue;
|
||
};
|
||
if ch.is_whitespace() {
|
||
if previous_space {
|
||
continue;
|
||
}
|
||
previous_space = true;
|
||
sanitized.push(' ');
|
||
} else {
|
||
previous_space = false;
|
||
sanitized.push(ch);
|
||
}
|
||
if sanitized.chars().count() >= max_chars {
|
||
break;
|
||
}
|
||
}
|
||
let sanitized = sanitized.trim().to_string();
|
||
(!sanitized.is_empty()).then_some(sanitized)
|
||
}
|
||
|
||
fn sanitize_window_title_text(value: &str, max_chars: usize) -> Option<String> {
|
||
let sanitized = value
|
||
.chars()
|
||
.filter(|ch| !matches!(*ch, '\u{1b}' | '\u{7}' | '\u{9c}') && !ch.is_control())
|
||
.take(max_chars)
|
||
.collect::<String>()
|
||
.trim()
|
||
.to_string();
|
||
(!sanitized.is_empty()).then_some(sanitized)
|
||
}
|
||
|
||
fn server_config_diagnostic_summaries(diagnostics: &[String]) -> (Option<String>, Option<String>) {
|
||
let without_keybindings = diagnostics
|
||
.iter()
|
||
.filter(|diagnostic| !is_keybinding_config_diagnostic(diagnostic))
|
||
.cloned()
|
||
.collect::<Vec<_>>();
|
||
(
|
||
config::config_diagnostic_summary(diagnostics),
|
||
config::config_diagnostic_summary(&without_keybindings),
|
||
)
|
||
}
|
||
|
||
fn is_keybinding_config_diagnostic(diagnostic: &str) -> bool {
|
||
diagnostic.contains("keybinding") || diagnostic.contains("keys.")
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Entry point
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Run the headless server. This is the entry point called from main.rs.
|
||
pub fn run_server() -> io::Result<()> {
|
||
init_logging();
|
||
crate::platform::raise_server_nofile_limit();
|
||
|
||
let args: Vec<String> = std::env::args().collect();
|
||
if args.get(2).map(String::as_str) == Some("--handoff-import") {
|
||
let socket_path = args
|
||
.get(3)
|
||
.map(PathBuf::from)
|
||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing handoff socket"))?;
|
||
let token = args
|
||
.get(4)
|
||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing handoff token"))?;
|
||
return run_handoff_import_server(&socket_path, token);
|
||
}
|
||
|
||
let loaded_config = config::Config::load();
|
||
let (api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||
let event_hub = api::EventHub::default();
|
||
|
||
// Start the JSON API socket server.
|
||
let _api_server = match api::start_server(api_tx.clone(), event_hub.clone()) {
|
||
Ok(server) => server,
|
||
Err(err) if err.kind() == io::ErrorKind::AddrInUse => {
|
||
eprintln!("error: herdr server is already running");
|
||
eprintln!("api socket: {}", api::socket_path().display());
|
||
std::process::exit(1);
|
||
}
|
||
Err(err) => return Err(err),
|
||
};
|
||
|
||
let no_session = false; // Server always does session persistence.
|
||
|
||
let rt = tokio::runtime::Builder::new_multi_thread()
|
||
.enable_all()
|
||
.build()
|
||
.map_err(io::Error::other)?;
|
||
|
||
let result = rt.block_on(async {
|
||
// Create the App (with AppState, event channels, etc.).
|
||
let mut app = app::App::new(
|
||
&loaded_config.config,
|
||
no_session,
|
||
config::config_diagnostic_summary(&loaded_config.diagnostics),
|
||
api_rx,
|
||
event_hub,
|
||
);
|
||
seed_startup_workspace_if_empty(&mut app);
|
||
|
||
// The server runs headless — disable local notification side effects.
|
||
// Sound and terminal notifications are forwarded to connected clients
|
||
// as ServerMessage::Notify instead of emitted by the server process.
|
||
app.state.local_sound_playback = false;
|
||
app.local_terminal_notifications = false;
|
||
|
||
// Create the headless server.
|
||
let mut server = match HeadlessServer::new(
|
||
app,
|
||
&loaded_config.diagnostics,
|
||
Some(api_tx.clone()),
|
||
Some(_api_server),
|
||
) {
|
||
Ok(server) => server,
|
||
Err(err) if err.kind() == io::ErrorKind::AddrInUse => {
|
||
eprintln!("error: herdr server is already running");
|
||
eprintln!("client socket: {}", client_socket_path().display());
|
||
std::process::exit(1);
|
||
}
|
||
Err(err) => return Err(err),
|
||
};
|
||
|
||
info!(
|
||
api_socket = %api::socket_path().display(),
|
||
client_socket = %client_socket_path().display(),
|
||
"herdr server started"
|
||
);
|
||
print_ready_message(&api::socket_path(), &client_socket_path());
|
||
|
||
server.run().await
|
||
});
|
||
|
||
rt.shutdown_timeout(Duration::from_millis(100));
|
||
crate::logging::shutdown("server");
|
||
result
|
||
}
|
||
|
||
fn seed_startup_workspace_if_empty(app: &mut app::App) {
|
||
let Some(cwd) = take_startup_cwd() else {
|
||
return;
|
||
};
|
||
|
||
if !app.state.workspaces.is_empty() {
|
||
info!(
|
||
cwd = %cwd.display(),
|
||
"restored session already has workspaces; ignoring startup cwd"
|
||
);
|
||
return;
|
||
}
|
||
|
||
match app.create_workspace_with_options(cwd.clone(), true) {
|
||
Ok(_) => {
|
||
info!(cwd = %cwd.display(), "created startup workspace");
|
||
}
|
||
Err(err) => {
|
||
warn!(cwd = %cwd.display(), err = %err, "failed to create startup workspace");
|
||
app.state.mode = app::Mode::Navigate;
|
||
}
|
||
}
|
||
}
|
||
|
||
fn take_startup_cwd() -> Option<PathBuf> {
|
||
let cwd = std::env::var_os(crate::server::autodetect::STARTUP_CWD_ENV_VAR)?;
|
||
std::env::remove_var(crate::server::autodetect::STARTUP_CWD_ENV_VAR);
|
||
(!cwd.is_empty()).then(|| PathBuf::from(cwd))
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
fn run_handoff_import_server(socket_path: &Path, token: &str) -> io::Result<()> {
|
||
let loaded_config = config::Config::load();
|
||
let mut received = crate::server::handoff::receive(socket_path, token)?;
|
||
crate::server::handoff::log_import_result(received.manifest.panes.len());
|
||
|
||
let (api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||
let event_hub = api::EventHub::default();
|
||
|
||
let mut imports = HashMap::new();
|
||
for (pane, fd) in received.manifest.panes.into_iter().zip(received.fds) {
|
||
let pane_id = pane.pane_id;
|
||
imports.insert(
|
||
pane_id,
|
||
crate::handoff_runtime::ImportedHandoffRuntime {
|
||
master_fd: fd,
|
||
state: pane,
|
||
},
|
||
);
|
||
}
|
||
|
||
let rt = tokio::runtime::Builder::new_multi_thread()
|
||
.enable_all()
|
||
.build()
|
||
.map_err(io::Error::other)?;
|
||
|
||
let result = rt.block_on(async {
|
||
let mut app = app::App::new_from_handoff(
|
||
&loaded_config.config,
|
||
config::config_diagnostic_summary(&loaded_config.diagnostics),
|
||
api_rx,
|
||
event_hub.clone(),
|
||
&received.manifest.snapshot,
|
||
&mut imports,
|
||
)?;
|
||
app.state.local_sound_playback = false;
|
||
app.local_terminal_notifications = false;
|
||
crate::server::handoff::report_restored(&mut received.stream)?;
|
||
if std::env::var("HERDR_TEST_HANDOFF_IMPORT_FAIL").as_deref() == Ok("after_restored") {
|
||
return Err(io::Error::other(
|
||
"test handoff import failure after restored",
|
||
));
|
||
}
|
||
wait_for_old_public_sockets_to_close(Duration::from_secs(5))?;
|
||
|
||
let api_server = api::start_server(api_tx.clone(), event_hub.clone())?;
|
||
let mut server = HeadlessServer::new(
|
||
app,
|
||
&loaded_config.diagnostics,
|
||
Some(api_tx.clone()),
|
||
Some(api_server),
|
||
)?;
|
||
crate::server::handoff::report_ready(&mut received.stream)?;
|
||
crate::server::handoff::wait_committed(&mut received.stream)?;
|
||
server.app.assume_handoff_ownership();
|
||
server.app.unpause_handoff_readers();
|
||
server.pending_handoff_repaint_nudge = true;
|
||
if let Err(err) = crate::server::handoff::report_owned(&mut received.stream) {
|
||
warn!(err = %err, "failed to report handoff ownership; continuing as owner");
|
||
}
|
||
info!("handoff import server started");
|
||
print_ready_message(&api::socket_path(), &client_socket_path());
|
||
server.run().await
|
||
});
|
||
|
||
rt.shutdown_timeout(Duration::from_millis(100));
|
||
crate::logging::shutdown("server");
|
||
result
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
fn wait_for_old_public_sockets_to_close(timeout: Duration) -> io::Result<()> {
|
||
let deadline = Instant::now() + timeout;
|
||
let api_socket = api::socket_path();
|
||
let client_socket = client_socket_path();
|
||
while Instant::now() < deadline {
|
||
let api_open = api_socket.exists() && crate::ipc::connect_local_stream(&api_socket).is_ok();
|
||
let client_open =
|
||
client_socket.exists() && crate::ipc::connect_local_stream(&client_socket).is_ok();
|
||
if !api_open && !client_open {
|
||
return Ok(());
|
||
}
|
||
std::thread::sleep(Duration::from_millis(50));
|
||
}
|
||
Err(io::Error::new(
|
||
io::ErrorKind::TimedOut,
|
||
"old server sockets did not close before handoff import bind",
|
||
))
|
||
}
|
||
|
||
#[cfg(not(unix))]
|
||
fn run_handoff_import_server(_socket_path: &Path, _token: &str) -> io::Result<()> {
|
||
Err(io::Error::other("live handoff is only supported on Unix"))
|
||
}
|
||
|
||
fn print_ready_message(api_socket: &Path, client_socket: &Path) {
|
||
eprintln!("herdr server running; you can use any herdr CLI command in another terminal.");
|
||
eprintln!("api socket: {}", api_socket.display());
|
||
eprintln!("client socket: {}", client_socket.display());
|
||
eprintln!(
|
||
"logs: {}",
|
||
crate::session::data_dir()
|
||
.join("herdr-server.log")
|
||
.display()
|
||
);
|
||
eprintln!("did you mean to open the Herdr TUI? run `herdr`; you do not need `herdr server`.");
|
||
}
|
||
|
||
/// Initialize logging for the server process.
|
||
fn init_logging() {
|
||
crate::logging::init_file_logging("herdr-server.log");
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tests
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
use crate::app::AppState;
|
||
use crate::protocol::CursorState;
|
||
|
||
fn test_headless_server() -> HeadlessServer {
|
||
let config = crate::config::Config::default();
|
||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||
let mut app = crate::app::App::new(&config, true, None, api_rx, api::EventHub::default());
|
||
app.state.local_sound_playback = false;
|
||
app.local_terminal_notifications = false;
|
||
|
||
let dir = std::env::temp_dir().join(format!(
|
||
"hh-{}-{}",
|
||
std::process::id(),
|
||
std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.map(|d| d.as_nanos())
|
||
.unwrap_or(0)
|
||
));
|
||
let _ = fs::create_dir_all(&dir);
|
||
let socket_path = dir.join("client.sock");
|
||
let _ = fs::remove_file(&socket_path);
|
||
let listener = bind_local_listener(&socket_path).expect("bind test listener");
|
||
let client_socket_identity =
|
||
socket_file_identity(&socket_path).expect("test listener socket identity");
|
||
#[cfg(unix)]
|
||
listener
|
||
.set_nonblocking(ListenerNonblockingMode::Accept)
|
||
.expect("set listener nonblocking");
|
||
let (server_event_tx, server_event_rx) = mpsc::channel(64);
|
||
#[cfg(windows)]
|
||
let should_quit = Arc::new(AtomicBool::new(false));
|
||
#[cfg(windows)]
|
||
spawn_windows_client_accept_thread(listener, should_quit.clone(), server_event_tx.clone());
|
||
let server_keybindings = app_keybindings(&app);
|
||
|
||
HeadlessServer {
|
||
app,
|
||
#[cfg(unix)]
|
||
api_tx: None,
|
||
#[cfg(unix)]
|
||
api_server: None,
|
||
#[cfg(unix)]
|
||
client_listener: listener,
|
||
client_socket_path: socket_path,
|
||
client_socket_identity,
|
||
clients: HashMap::new(),
|
||
#[cfg(unix)]
|
||
next_client_id: 1,
|
||
foreground_client_id: None,
|
||
server_keybindings,
|
||
server_config_diagnostic: None,
|
||
server_config_diagnostic_without_keybindings: None,
|
||
terminal_attach_owners: HashMap::new(),
|
||
next_activity_stamp: 1,
|
||
effective_size: (MIN_COLS, MIN_ROWS),
|
||
shutting_down: false,
|
||
handoff_in_progress: false,
|
||
#[cfg(unix)]
|
||
pending_handoff_repaint_nudge: false,
|
||
#[cfg(unix)]
|
||
should_quit: Arc::new(AtomicBool::new(false)),
|
||
#[cfg(windows)]
|
||
should_quit,
|
||
server_event_rx,
|
||
server_event_tx,
|
||
}
|
||
}
|
||
|
||
fn read_server_message(bytes: Vec<u8>) -> ServerMessage {
|
||
let mut cursor = std::io::Cursor::new(bytes);
|
||
protocol::read_message(&mut cursor, MAX_FRAME_SIZE).expect("decode server message")
|
||
}
|
||
|
||
fn read_server_frame(bytes: Vec<u8>) -> FrameData {
|
||
match read_server_message(bytes) {
|
||
ServerMessage::Frame(frame) => frame,
|
||
other => panic!("expected frame, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
fn frame_text(frame: &FrameData) -> String {
|
||
frame
|
||
.cells
|
||
.chunks(usize::from(frame.width))
|
||
.map(|row| {
|
||
row.iter()
|
||
.map(|cell| cell.symbol.as_str())
|
||
.collect::<String>()
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join("\n")
|
||
}
|
||
|
||
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:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn headless_api_request_drains_all_pending_internal_events_before_reading_state() {
|
||
let mut server = test_headless_server();
|
||
for i in 0..=crate::app::APP_EVENT_DRAIN_LIMIT {
|
||
server
|
||
.app
|
||
.event_tx
|
||
.try_send(AppEvent::UpdateReady {
|
||
version: format!("4.0.{i}"),
|
||
install_command: "herdr install".into(),
|
||
})
|
||
.unwrap();
|
||
}
|
||
|
||
let (respond_to, response_rx) = std::sync::mpsc::channel();
|
||
assert!(
|
||
server.handle_api_request_with_shutdown_check(api::ApiRequestMessage {
|
||
request: api::schema::Request {
|
||
id: "headless_stop_after_events".into(),
|
||
method: api::schema::Method::ServerStop(api::schema::EmptyParams::default()),
|
||
},
|
||
respond_to,
|
||
})
|
||
);
|
||
let response = response_rx
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.unwrap();
|
||
let response: serde_json::Value = serde_json::from_str(&response).unwrap();
|
||
|
||
assert_eq!(response["result"]["type"], "ok");
|
||
let expected_version = format!("4.0.{}", crate::app::APP_EVENT_DRAIN_LIMIT);
|
||
assert_eq!(
|
||
server.app.state.update_available.as_deref(),
|
||
Some(expected_version.as_str())
|
||
);
|
||
assert!(server.app.event_rx.try_recv().is_err());
|
||
}
|
||
|
||
fn test_client_writer() -> (
|
||
ClientWriter,
|
||
std::sync::mpsc::Receiver<Vec<u8>>,
|
||
std::sync::mpsc::Receiver<Vec<u8>>,
|
||
) {
|
||
let (control_tx, control_rx) = std::sync::mpsc::channel();
|
||
let (render_tx, render_rx) = std::sync::mpsc::sync_channel(1);
|
||
(
|
||
ClientWriter::test_channel(control_tx, render_tx),
|
||
control_rx,
|
||
render_rx,
|
||
)
|
||
}
|
||
|
||
fn retained_test_server(
|
||
initial_screen: &[u8],
|
||
) -> (
|
||
HeadlessServer,
|
||
std::sync::mpsc::Receiver<Vec<u8>>,
|
||
crate::layout::PaneId,
|
||
) {
|
||
let mut server = test_headless_server();
|
||
let mut workspace = crate::workspace::Workspace::test_new("test");
|
||
let pane_id = workspace.focused_pane_id().expect("focused pane");
|
||
workspace.insert_test_runtime(
|
||
pane_id,
|
||
crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, initial_screen),
|
||
);
|
||
server.app.state.workspaces = vec![workspace];
|
||
server.app.state.active = Some(0);
|
||
server.app.state.selected = 0;
|
||
server.app.state.mode = crate::app::Mode::Terminal;
|
||
|
||
let (client_tx, _client_control_rx, client_rx) = test_client_writer();
|
||
server.clients.insert(
|
||
1,
|
||
ClientConnection::new(
|
||
(80, 24),
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
crate::terminal_theme::TerminalTheme::default(),
|
||
None,
|
||
1,
|
||
RenderEncoding::SemanticFrame,
|
||
Some(client_tx),
|
||
),
|
||
);
|
||
server.foreground_client_id = Some(1);
|
||
server.sync_foreground_client_state();
|
||
server.resize_shared_runtime_to_effective_size();
|
||
|
||
(server, client_rx, pane_id)
|
||
}
|
||
|
||
fn assert_frame_data_eq(actual: &FrameData, expected: &FrameData) {
|
||
assert_eq!(
|
||
(actual.width, actual.height),
|
||
(expected.width, expected.height)
|
||
);
|
||
assert_eq!(actual.cursor, expected.cursor, "cursor mismatch");
|
||
assert_eq!(actual.hyperlinks, expected.hyperlinks, "hyperlink mismatch");
|
||
assert_eq!(actual.graphics, expected.graphics, "graphics mismatch");
|
||
assert_eq!(
|
||
actual.cells.len(),
|
||
expected.cells.len(),
|
||
"cell length mismatch"
|
||
);
|
||
for (idx, (actual_cell, expected_cell)) in
|
||
actual.cells.iter().zip(expected.cells.iter()).enumerate()
|
||
{
|
||
assert_eq!(
|
||
actual_cell,
|
||
expected_cell,
|
||
"cell mismatch at index {idx} (x={}, y={})",
|
||
idx % usize::from(actual.width),
|
||
idx / usize::from(actual.width),
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn foreground_client_applies_client_keybindings() {
|
||
let mut server = test_headless_server();
|
||
let local_config: crate::config::Config = toml::from_str(
|
||
r#"
|
||
[keys]
|
||
prefix = "ctrl+a"
|
||
new_tab = "prefix+t"
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let local_keybindings = local_config.live_keybinds().unwrap();
|
||
let (writer_a, _control_a, _render_a) = test_client_writer();
|
||
let (writer_b, _control_b, _render_b) = test_client_writer();
|
||
|
||
assert!(server.handle_server_event(ServerEvent::ClientConnected {
|
||
client_id: 1,
|
||
cols: 80,
|
||
rows: 24,
|
||
cell_width_px: 0,
|
||
cell_height_px: 0,
|
||
render_encoding: RenderEncoding::SemanticFrame,
|
||
keybindings: Some(Box::new(local_keybindings)),
|
||
direct_attach_requested: false,
|
||
writer: writer_a,
|
||
}));
|
||
assert_eq!(
|
||
server.app.state.prefix_code,
|
||
crossterm::event::KeyCode::Char('a')
|
||
);
|
||
assert!(server
|
||
.app
|
||
.state
|
||
.keybinds
|
||
.new_tab
|
||
.bindings
|
||
.iter()
|
||
.any(|binding| binding.label == "prefix+t"));
|
||
|
||
assert!(server.handle_server_event(ServerEvent::ClientConnected {
|
||
client_id: 2,
|
||
cols: 80,
|
||
rows: 24,
|
||
cell_width_px: 0,
|
||
cell_height_px: 0,
|
||
render_encoding: RenderEncoding::SemanticFrame,
|
||
keybindings: None,
|
||
direct_attach_requested: false,
|
||
writer: writer_b,
|
||
}));
|
||
assert_eq!(
|
||
server.app.state.prefix_code,
|
||
crossterm::event::KeyCode::Char('b')
|
||
);
|
||
assert!(server
|
||
.app
|
||
.state
|
||
.keybinds
|
||
.new_tab
|
||
.bindings
|
||
.iter()
|
||
.any(|binding| binding.label == "prefix+c"));
|
||
}
|
||
|
||
#[test]
|
||
fn local_keybinding_client_hides_server_keybinding_warnings() {
|
||
let mut server = test_headless_server();
|
||
let diagnostics = vec![
|
||
"unsafe direct keybinding: keys.close_pane = \"x\" would intercept typing".to_owned(),
|
||
"theme warning".to_owned(),
|
||
];
|
||
let (full, without_keybindings) = server_config_diagnostic_summaries(&diagnostics);
|
||
server.server_config_diagnostic = full.clone();
|
||
server.server_config_diagnostic_without_keybindings = without_keybindings.clone();
|
||
server.app.state.config_diagnostic = full;
|
||
let local_keybindings = crate::config::Config::default().live_keybinds().unwrap();
|
||
let (writer_a, _control_a, _render_a) = test_client_writer();
|
||
let (writer_b, _control_b, _render_b) = test_client_writer();
|
||
|
||
assert!(server.handle_server_event(ServerEvent::ClientConnected {
|
||
client_id: 1,
|
||
cols: 80,
|
||
rows: 24,
|
||
cell_width_px: 0,
|
||
cell_height_px: 0,
|
||
render_encoding: RenderEncoding::SemanticFrame,
|
||
keybindings: Some(Box::new(local_keybindings)),
|
||
direct_attach_requested: false,
|
||
writer: writer_a,
|
||
}));
|
||
assert_eq!(server.app.state.config_diagnostic, without_keybindings);
|
||
|
||
assert!(server.handle_server_event(ServerEvent::ClientConnected {
|
||
client_id: 2,
|
||
cols: 80,
|
||
rows: 24,
|
||
cell_width_px: 0,
|
||
cell_height_px: 0,
|
||
render_encoding: RenderEncoding::SemanticFrame,
|
||
keybindings: None,
|
||
direct_attach_requested: false,
|
||
writer: writer_b,
|
||
}));
|
||
assert_eq!(
|
||
server.app.state.config_diagnostic,
|
||
server.server_config_diagnostic
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn local_keybinding_client_keeps_local_keybindings_after_settings_save() {
|
||
let path = std::env::temp_dir().join(format!(
|
||
"herdr-headless-settings-{}-{}.toml",
|
||
std::process::id(),
|
||
std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.map(|d| d.as_nanos())
|
||
.unwrap_or(0)
|
||
));
|
||
std::fs::write(&path, "onboarding = false\n").unwrap();
|
||
let _guard = crate::config::test_config_env_lock().lock().unwrap();
|
||
std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
|
||
|
||
let mut server = test_headless_server();
|
||
let local_config: crate::config::Config = toml::from_str(
|
||
r#"
|
||
[keys]
|
||
prefix = "ctrl+a"
|
||
new_workspace = "prefix+n"
|
||
next_tab = ""
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let local_keybindings = local_config.live_keybinds().unwrap();
|
||
let (writer, _control, _render) = test_client_writer();
|
||
assert!(server.handle_server_event(ServerEvent::ClientConnected {
|
||
client_id: 1,
|
||
cols: 80,
|
||
rows: 24,
|
||
cell_width_px: 0,
|
||
cell_height_px: 0,
|
||
render_encoding: RenderEncoding::SemanticFrame,
|
||
keybindings: Some(Box::new(local_keybindings)),
|
||
direct_attach_requested: false,
|
||
writer,
|
||
}));
|
||
server.app.state.mode = crate::app::Mode::Settings;
|
||
server.app.state.settings.section = crate::app::state::SettingsSection::Toast;
|
||
server.app.state.settings.list.selected = 1;
|
||
|
||
assert!(server.handle_server_event(ServerEvent::ClientInput {
|
||
client_id: 1,
|
||
data: b"\r".to_vec(),
|
||
}));
|
||
|
||
assert_eq!(
|
||
server.app.state.prefix_code,
|
||
crossterm::event::KeyCode::Char('a')
|
||
);
|
||
assert!(server
|
||
.app
|
||
.state
|
||
.keybinds
|
||
.new_workspace
|
||
.bindings
|
||
.iter()
|
||
.any(|binding| binding.label == "prefix+n"));
|
||
assert!(server.app.state.toast.is_none());
|
||
let content = std::fs::read_to_string(&path).unwrap();
|
||
assert!(content.contains("delivery = \"herdr\""));
|
||
|
||
std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR);
|
||
let _ = std::fs::remove_file(path);
|
||
}
|
||
|
||
#[test]
|
||
fn invalid_server_keybindings_apply_valid_subset_after_settings_save_without_caching_local_keybindings(
|
||
) {
|
||
let path = std::env::temp_dir().join(format!(
|
||
"herdr-headless-invalid-settings-{}-{}.toml",
|
||
std::process::id(),
|
||
std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.map(|d| d.as_nanos())
|
||
.unwrap_or(0)
|
||
));
|
||
std::fs::write(
|
||
&path,
|
||
"onboarding = false\n[keys]\nnew_workspace = \"x\"\n[ui.toast]\ndelivery = \"off\"\n",
|
||
)
|
||
.unwrap();
|
||
let _guard = crate::config::test_config_env_lock().lock().unwrap();
|
||
std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
|
||
|
||
let mut server = test_headless_server();
|
||
let previous_server_config: crate::config::Config =
|
||
toml::from_str("[keys]\nprefix = \"ctrl+c\"\nnew_workspace = \"prefix+m\"\n").unwrap();
|
||
server.server_keybindings = previous_server_config.live_keybinds().unwrap();
|
||
let local_config: crate::config::Config = toml::from_str(
|
||
r#"
|
||
[keys]
|
||
prefix = "ctrl+a"
|
||
new_workspace = "prefix+n"
|
||
next_tab = ""
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let (writer_a, _control_a, _render_a) = test_client_writer();
|
||
let (writer_b, _control_b, _render_b) = test_client_writer();
|
||
|
||
assert!(server.handle_server_event(ServerEvent::ClientConnected {
|
||
client_id: 1,
|
||
cols: 80,
|
||
rows: 24,
|
||
cell_width_px: 0,
|
||
cell_height_px: 0,
|
||
render_encoding: RenderEncoding::SemanticFrame,
|
||
keybindings: Some(Box::new(local_config.live_keybinds().unwrap())),
|
||
direct_attach_requested: false,
|
||
writer: writer_a,
|
||
}));
|
||
server.app.state.mode = crate::app::Mode::Settings;
|
||
server.app.state.settings.section = crate::app::state::SettingsSection::Toast;
|
||
server.app.state.settings.list.selected = 1;
|
||
|
||
assert!(server.handle_server_event(ServerEvent::ClientInput {
|
||
client_id: 1,
|
||
data: b"\r".to_vec(),
|
||
}));
|
||
|
||
assert!(server.handle_server_event(ServerEvent::ClientConnected {
|
||
client_id: 2,
|
||
cols: 80,
|
||
rows: 24,
|
||
cell_width_px: 0,
|
||
cell_height_px: 0,
|
||
render_encoding: RenderEncoding::SemanticFrame,
|
||
keybindings: None,
|
||
direct_attach_requested: false,
|
||
writer: writer_b,
|
||
}));
|
||
assert_eq!(
|
||
server.app.state.prefix_code,
|
||
crossterm::event::KeyCode::Char('b')
|
||
);
|
||
assert!(!server
|
||
.app
|
||
.state
|
||
.keybinds
|
||
.new_workspace
|
||
.bindings
|
||
.iter()
|
||
.any(|binding| binding.label == "prefix+n"));
|
||
assert!(server.app.state.keybinds.new_workspace.bindings.is_empty());
|
||
|
||
std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR);
|
||
let _ = std::fs::remove_file(path);
|
||
}
|
||
|
||
#[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,
|
||
keybindings: None,
|
||
direct_attach_requested: true,
|
||
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())
|
||
);
|
||
}
|
||
|
||
fn with_terminal_session_test_server(
|
||
test: impl FnOnce(&mut HeadlessServer, crate::terminal::TerminalId, String, String),
|
||
) {
|
||
let rt = tokio::runtime::Builder::new_current_thread()
|
||
.enable_all()
|
||
.build()
|
||
.expect("test runtime");
|
||
let _runtime_guard = rt.enter();
|
||
let mut server = test_headless_server();
|
||
let workspace = crate::workspace::Workspace::test_new("test");
|
||
let pane_id = workspace.tabs[0].root_pane;
|
||
let terminal_id = workspace.terminal_id(pane_id).expect("terminal id").clone();
|
||
let terminal_id_string = terminal_id.to_string();
|
||
let public_pane_id = format!("{}:p1", workspace.id);
|
||
server.app.state.workspaces = vec![workspace];
|
||
server.app.state.ensure_test_terminals();
|
||
server.app.terminal_runtimes.insert(
|
||
terminal_id.clone(),
|
||
crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""),
|
||
);
|
||
|
||
test(&mut server, terminal_id, terminal_id_string, public_pane_id);
|
||
|
||
drop(server);
|
||
drop(_runtime_guard);
|
||
rt.shutdown_timeout(Duration::from_millis(100));
|
||
}
|
||
|
||
fn connect_pending_terminal_client(server: &mut HeadlessServer, client_id: u64) {
|
||
let _control_rx = connect_pending_terminal_client_with_control_rx(server, client_id);
|
||
}
|
||
|
||
fn connect_pending_terminal_client_with_control_rx(
|
||
server: &mut HeadlessServer,
|
||
client_id: u64,
|
||
) -> std::sync::mpsc::Receiver<Vec<u8>> {
|
||
let (writer, control_rx, _render_rx) = test_client_writer();
|
||
assert!(server.handle_server_event(ServerEvent::ClientConnected {
|
||
client_id,
|
||
cols: 100,
|
||
rows: 30,
|
||
cell_width_px: 0,
|
||
cell_height_px: 0,
|
||
render_encoding: RenderEncoding::TerminalAnsi,
|
||
keybindings: None,
|
||
direct_attach_requested: true,
|
||
writer,
|
||
}));
|
||
control_rx
|
||
}
|
||
|
||
#[test]
|
||
fn terminal_observe_allows_multiple_clients_without_attach_ownership() {
|
||
with_terminal_session_test_server(|server, terminal_id, terminal_id_string, _| {
|
||
let initial_size = server
|
||
.app
|
||
.terminal_runtimes
|
||
.get(&terminal_id)
|
||
.expect("runtime")
|
||
.current_size();
|
||
|
||
for client_id in [7, 8] {
|
||
connect_pending_terminal_client(server, client_id);
|
||
assert!(
|
||
server.handle_server_event(ServerEvent::ClientObserveTerminal {
|
||
client_id,
|
||
target: terminal_id_string.clone(),
|
||
})
|
||
);
|
||
}
|
||
|
||
assert!(server.terminal_attach_owners.is_empty());
|
||
assert!(!server
|
||
.app
|
||
.state
|
||
.direct_attach_resize_locks
|
||
.contains(&terminal_id));
|
||
assert_eq!(
|
||
server
|
||
.app
|
||
.terminal_runtimes
|
||
.get(&terminal_id)
|
||
.expect("runtime")
|
||
.current_size(),
|
||
initial_size
|
||
);
|
||
assert_eq!(
|
||
terminal_stream_client_ids(&server.clients, &terminal_id_string).len(),
|
||
2
|
||
);
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
fn terminal_observe_resolves_public_pane_id() {
|
||
with_terminal_session_test_server(|server, terminal_id, _, public_pane_id| {
|
||
connect_pending_terminal_client(server, 7);
|
||
assert!(
|
||
server.handle_server_event(ServerEvent::ClientObserveTerminal {
|
||
client_id: 7,
|
||
target: public_pane_id,
|
||
})
|
||
);
|
||
|
||
assert!(matches!(
|
||
server.clients.get(&7).map(|client| &client.mode),
|
||
Some(ClientConnectionMode::TerminalObserve { terminal_id: observed })
|
||
if observed == &terminal_id.to_string()
|
||
));
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
fn terminal_control_resolves_public_pane_id_and_takes_ownership() {
|
||
with_terminal_session_test_server(
|
||
|server, terminal_id, terminal_id_string, public_pane_id| {
|
||
connect_pending_terminal_client(server, 7);
|
||
assert!(
|
||
server.handle_server_event(ServerEvent::ClientControlTerminal {
|
||
client_id: 7,
|
||
target: public_pane_id,
|
||
takeover: false,
|
||
})
|
||
);
|
||
|
||
assert!(matches!(
|
||
server.clients.get(&7).map(|client| &client.mode),
|
||
Some(ClientConnectionMode::TerminalAttach { terminal_id: attached })
|
||
if attached == &terminal_id_string
|
||
));
|
||
assert_eq!(
|
||
server.terminal_attach_owners.get(&terminal_id_string),
|
||
Some(&7)
|
||
);
|
||
assert!(server
|
||
.app
|
||
.state
|
||
.direct_attach_resize_locks
|
||
.contains(&terminal_id));
|
||
},
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn terminal_control_rejects_second_controller_without_takeover() {
|
||
with_terminal_session_test_server(|server, _terminal_id, terminal_id_string, _| {
|
||
connect_pending_terminal_client(server, 7);
|
||
assert!(
|
||
server.handle_server_event(ServerEvent::ClientControlTerminal {
|
||
client_id: 7,
|
||
target: terminal_id_string.clone(),
|
||
takeover: false,
|
||
})
|
||
);
|
||
|
||
connect_pending_terminal_client(server, 8);
|
||
assert!(
|
||
!server.handle_server_event(ServerEvent::ClientControlTerminal {
|
||
client_id: 8,
|
||
target: terminal_id_string.clone(),
|
||
takeover: false,
|
||
})
|
||
);
|
||
|
||
assert!(server.clients.contains_key(&7));
|
||
assert!(!server.clients.contains_key(&8));
|
||
assert_eq!(
|
||
server.terminal_attach_owners.get(&terminal_id_string),
|
||
Some(&7)
|
||
);
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
fn terminal_control_takeover_replaces_existing_controller() {
|
||
with_terminal_session_test_server(|server, _terminal_id, terminal_id_string, _| {
|
||
connect_pending_terminal_client(server, 7);
|
||
assert!(
|
||
server.handle_server_event(ServerEvent::ClientControlTerminal {
|
||
client_id: 7,
|
||
target: terminal_id_string.clone(),
|
||
takeover: false,
|
||
})
|
||
);
|
||
|
||
connect_pending_terminal_client(server, 8);
|
||
assert!(
|
||
server.handle_server_event(ServerEvent::ClientControlTerminal {
|
||
client_id: 8,
|
||
target: terminal_id_string.clone(),
|
||
takeover: true,
|
||
})
|
||
);
|
||
|
||
assert!(!server.clients.contains_key(&7));
|
||
assert!(server.clients.contains_key(&8));
|
||
assert_eq!(
|
||
server.terminal_attach_owners.get(&terminal_id_string),
|
||
Some(&8)
|
||
);
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
fn terminal_observe_can_coexist_with_terminal_control() {
|
||
with_terminal_session_test_server(|server, _terminal_id, terminal_id_string, _| {
|
||
connect_pending_terminal_client(server, 7);
|
||
assert!(
|
||
server.handle_server_event(ServerEvent::ClientControlTerminal {
|
||
client_id: 7,
|
||
target: terminal_id_string.clone(),
|
||
takeover: false,
|
||
})
|
||
);
|
||
|
||
connect_pending_terminal_client(server, 8);
|
||
assert!(
|
||
server.handle_server_event(ServerEvent::ClientObserveTerminal {
|
||
client_id: 8,
|
||
target: terminal_id_string.clone(),
|
||
})
|
||
);
|
||
|
||
assert_eq!(
|
||
server.terminal_attach_owners.get(&terminal_id_string),
|
||
Some(&7)
|
||
);
|
||
assert!(matches!(
|
||
server.clients.get(&8).map(|client| &client.mode),
|
||
Some(ClientConnectionMode::TerminalObserve { terminal_id })
|
||
if terminal_id == &terminal_id_string
|
||
));
|
||
assert_eq!(
|
||
terminal_stream_client_ids(&server.clients, &terminal_id_string).len(),
|
||
2
|
||
);
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
fn terminal_control_detach_sends_shutdown_before_removal() {
|
||
with_terminal_session_test_server(|server, _terminal_id, terminal_id_string, _| {
|
||
let control_rx = connect_pending_terminal_client_with_control_rx(server, 7);
|
||
assert!(
|
||
server.handle_server_event(ServerEvent::ClientControlTerminal {
|
||
client_id: 7,
|
||
target: terminal_id_string.clone(),
|
||
takeover: false,
|
||
})
|
||
);
|
||
|
||
assert!(server.handle_server_event(ServerEvent::ClientDetach { client_id: 7 }));
|
||
|
||
assert!(!server.clients.contains_key(&7));
|
||
assert!(!server
|
||
.terminal_attach_owners
|
||
.contains_key(&terminal_id_string));
|
||
let reason = read_server_shutdown_reason(control_rx.recv().expect("shutdown message"));
|
||
assert_eq!(reason, Some("detached".to_owned()));
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
fn terminal_observe_rejects_later_attach_upgrade() {
|
||
with_terminal_session_test_server(|server, terminal_id, terminal_id_string, _| {
|
||
connect_pending_terminal_client(server, 7);
|
||
assert!(
|
||
server.handle_server_event(ServerEvent::ClientObserveTerminal {
|
||
client_id: 7,
|
||
target: terminal_id_string.clone(),
|
||
})
|
||
);
|
||
assert!(
|
||
!server.handle_server_event(ServerEvent::ClientAttachTerminal {
|
||
client_id: 7,
|
||
terminal_id: terminal_id_string,
|
||
takeover: true,
|
||
})
|
||
);
|
||
|
||
assert!(!server.clients.contains_key(&7));
|
||
assert!(server.terminal_attach_owners.is_empty());
|
||
assert!(!server
|
||
.app
|
||
.state
|
||
.direct_attach_resize_locks
|
||
.contains(&terminal_id));
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
fn terminal_attach_rejects_later_observe_and_clears_ownership() {
|
||
with_terminal_session_test_server(|server, terminal_id, terminal_id_string, _| {
|
||
connect_pending_terminal_client(server, 7);
|
||
assert!(
|
||
server.handle_server_event(ServerEvent::ClientAttachTerminal {
|
||
client_id: 7,
|
||
terminal_id: terminal_id_string.clone(),
|
||
takeover: false,
|
||
})
|
||
);
|
||
assert_eq!(
|
||
server.terminal_attach_owners.get(&terminal_id_string),
|
||
Some(&7)
|
||
);
|
||
assert!(server
|
||
.app
|
||
.state
|
||
.direct_attach_resize_locks
|
||
.contains(&terminal_id));
|
||
|
||
assert!(
|
||
!server.handle_server_event(ServerEvent::ClientObserveTerminal {
|
||
client_id: 7,
|
||
target: terminal_id_string.clone(),
|
||
})
|
||
);
|
||
|
||
assert!(!server.clients.contains_key(&7));
|
||
assert!(server.terminal_attach_owners.is_empty());
|
||
assert!(!server
|
||
.app
|
||
.state
|
||
.direct_attach_resize_locks
|
||
.contains(&terminal_id));
|
||
});
|
||
}
|
||
|
||
fn app_client_marks_git_refresh_due_on_first_attach(render_encoding: RenderEncoding) {
|
||
let mut server = test_headless_server();
|
||
server
|
||
.app
|
||
.state
|
||
.workspaces
|
||
.push(crate::workspace::Workspace::test_new("test"));
|
||
let future = Instant::now() + Duration::from_secs(60);
|
||
server.app.last_git_remote_status_refresh = future;
|
||
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,
|
||
keybindings: None,
|
||
direct_attach_requested: false,
|
||
writer,
|
||
}));
|
||
|
||
assert!(server.has_app_client());
|
||
assert!(server
|
||
.app
|
||
.git_refresh_deadline()
|
||
.is_some_and(|deadline| deadline <= Instant::now()));
|
||
}
|
||
|
||
#[test]
|
||
fn terminal_ansi_app_client_enables_headless_git_refresh() {
|
||
app_client_marks_git_refresh_due_on_first_attach(RenderEncoding::TerminalAnsi);
|
||
}
|
||
|
||
#[test]
|
||
fn pending_terminal_attach_client_does_not_enable_headless_git_refresh() {
|
||
let mut server = test_headless_server();
|
||
server
|
||
.app
|
||
.state
|
||
.workspaces
|
||
.push(crate::workspace::Workspace::test_new("test"));
|
||
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,
|
||
keybindings: None,
|
||
direct_attach_requested: true,
|
||
writer,
|
||
}));
|
||
|
||
assert!(!server.has_app_client());
|
||
assert_eq!(
|
||
server.app.next_headless_loop_deadline_with_git_refresh(
|
||
Instant::now(),
|
||
false,
|
||
server.has_app_client()
|
||
),
|
||
None
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn writerless_app_client_does_not_enable_headless_git_refresh() {
|
||
let mut server = test_headless_server();
|
||
server
|
||
.app
|
||
.state
|
||
.workspaces
|
||
.push(crate::workspace::Workspace::test_new("test"));
|
||
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::SemanticFrame,
|
||
keybindings: None,
|
||
direct_attach_requested: false,
|
||
writer,
|
||
}));
|
||
assert!(server.has_app_client());
|
||
|
||
server.clients.get_mut(&7).expect("client").writer = None;
|
||
|
||
assert!(!server.has_app_client());
|
||
assert_eq!(
|
||
server.app.next_headless_loop_deadline_with_git_refresh(
|
||
Instant::now(),
|
||
false,
|
||
server.has_app_client()
|
||
),
|
||
None
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn semantic_app_client_marks_git_refresh_due_on_first_attach() {
|
||
app_client_marks_git_refresh_due_on_first_attach(RenderEncoding::SemanticFrame);
|
||
}
|
||
|
||
#[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,
|
||
keybindings: None,
|
||
direct_attach_requested: true,
|
||
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 terminal_attach_scroll_moves_attached_runtime_viewport() {
|
||
let rt = tokio::runtime::Builder::new_current_thread()
|
||
.enable_all()
|
||
.build()
|
||
.expect("test runtime");
|
||
let _runtime_guard = rt.enter();
|
||
let mut bytes = Vec::new();
|
||
for line in 0..80 {
|
||
bytes.extend_from_slice(format!("line {line:02}\r\n").as_bytes());
|
||
}
|
||
let runtime =
|
||
crate::terminal::TerminalRuntime::test_with_scrollback_bytes(20, 5, 4096, &bytes);
|
||
|
||
apply_terminal_attach_scroll(
|
||
&runtime,
|
||
AttachScrollSource::Wheel,
|
||
AttachScrollDirection::Up,
|
||
3,
|
||
None,
|
||
None,
|
||
0,
|
||
)
|
||
.expect("scroll up");
|
||
let metrics = runtime.scroll_metrics().expect("scroll metrics");
|
||
assert_eq!(metrics.offset_from_bottom, 3);
|
||
|
||
apply_terminal_attach_scroll(
|
||
&runtime,
|
||
AttachScrollSource::Wheel,
|
||
AttachScrollDirection::Down,
|
||
2,
|
||
None,
|
||
None,
|
||
0,
|
||
)
|
||
.expect("scroll down");
|
||
let metrics = runtime.scroll_metrics().expect("scroll metrics");
|
||
assert_eq!(metrics.offset_from_bottom, 1);
|
||
drop(runtime);
|
||
drop(_runtime_guard);
|
||
rt.shutdown_timeout(Duration::from_millis(100));
|
||
}
|
||
|
||
#[test]
|
||
fn terminal_attach_input_resets_scrolled_viewport() {
|
||
let rt = tokio::runtime::Builder::new_current_thread()
|
||
.enable_all()
|
||
.build()
|
||
.expect("test runtime");
|
||
let _runtime_guard = rt.enter();
|
||
let mut bytes = Vec::new();
|
||
for line in 0..80 {
|
||
bytes.extend_from_slice(format!("line {line:02}\r\n").as_bytes());
|
||
}
|
||
let (runtime, mut input_rx) =
|
||
crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
|
||
20, 5, 4096, &bytes, 4,
|
||
);
|
||
|
||
runtime.scroll_up(4);
|
||
assert_eq!(
|
||
runtime
|
||
.scroll_metrics()
|
||
.expect("scroll metrics")
|
||
.offset_from_bottom,
|
||
4
|
||
);
|
||
|
||
apply_terminal_attach_input(&runtime, b"x".to_vec()).expect("attach input");
|
||
assert_eq!(
|
||
runtime
|
||
.scroll_metrics()
|
||
.expect("scroll metrics")
|
||
.offset_from_bottom,
|
||
0
|
||
);
|
||
assert_eq!(
|
||
input_rx.try_recv().expect("forwarded input"),
|
||
Bytes::from("x")
|
||
);
|
||
|
||
drop(runtime);
|
||
drop(_runtime_guard);
|
||
rt.shutdown_timeout(Duration::from_millis(100));
|
||
}
|
||
|
||
#[test]
|
||
fn terminal_attach_page_key_host_scrolls_plain_terminal() {
|
||
let rt = tokio::runtime::Builder::new_current_thread()
|
||
.enable_all()
|
||
.build()
|
||
.expect("test runtime");
|
||
let _runtime_guard = rt.enter();
|
||
let mut bytes = Vec::new();
|
||
for line in 0..80 {
|
||
bytes.extend_from_slice(format!("line {line:02}\r\n").as_bytes());
|
||
}
|
||
let (runtime, mut input_rx) =
|
||
crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
|
||
20, 5, 4096, &bytes, 4,
|
||
);
|
||
|
||
apply_terminal_attach_scroll(
|
||
&runtime,
|
||
AttachScrollSource::PageKey {
|
||
input: b"\x1b[5~".to_vec(),
|
||
},
|
||
AttachScrollDirection::Up,
|
||
4,
|
||
None,
|
||
None,
|
||
0,
|
||
)
|
||
.expect("page key scroll");
|
||
|
||
assert_eq!(
|
||
runtime
|
||
.scroll_metrics()
|
||
.expect("scroll metrics")
|
||
.offset_from_bottom,
|
||
4
|
||
);
|
||
assert!(input_rx.try_recv().is_err());
|
||
drop(runtime);
|
||
drop(_runtime_guard);
|
||
rt.shutdown_timeout(Duration::from_millis(100));
|
||
}
|
||
|
||
#[test]
|
||
fn terminal_attach_page_key_forwards_when_mouse_reporting() {
|
||
let rt = tokio::runtime::Builder::new_current_thread()
|
||
.enable_all()
|
||
.build()
|
||
.expect("test runtime");
|
||
let _runtime_guard = rt.enter();
|
||
let mut bytes = b"\x1b[?1000h".to_vec();
|
||
for line in 0..80 {
|
||
bytes.extend_from_slice(format!("line {line:02}\r\n").as_bytes());
|
||
}
|
||
let (runtime, mut input_rx) =
|
||
crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
|
||
20, 5, 4096, &bytes, 4,
|
||
);
|
||
runtime.scroll_up(3);
|
||
|
||
apply_terminal_attach_scroll(
|
||
&runtime,
|
||
AttachScrollSource::PageKey {
|
||
input: b"\x1b[5~".to_vec(),
|
||
},
|
||
AttachScrollDirection::Up,
|
||
4,
|
||
None,
|
||
None,
|
||
0,
|
||
)
|
||
.expect("page key forward");
|
||
|
||
assert_eq!(
|
||
runtime
|
||
.scroll_metrics()
|
||
.expect("scroll metrics")
|
||
.offset_from_bottom,
|
||
0
|
||
);
|
||
assert_eq!(
|
||
input_rx.try_recv().expect("forwarded page key"),
|
||
Bytes::from_static(b"\x1b[5~")
|
||
);
|
||
drop(runtime);
|
||
drop(_runtime_guard);
|
||
rt.shutdown_timeout(Duration::from_millis(100));
|
||
}
|
||
|
||
#[test]
|
||
fn terminal_attach_page_key_forwards_in_alternate_screen_without_mouse_reporting() {
|
||
let rt = tokio::runtime::Builder::new_current_thread()
|
||
.enable_all()
|
||
.build()
|
||
.expect("test runtime");
|
||
let _runtime_guard = rt.enter();
|
||
let mut bytes = b"\x1b[?1049h".to_vec();
|
||
for line in 0..80 {
|
||
bytes.extend_from_slice(format!("line {line:02}\r\n").as_bytes());
|
||
}
|
||
let (runtime, mut input_rx) =
|
||
crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
|
||
20, 5, 4096, &bytes, 4,
|
||
);
|
||
runtime.scroll_up(3);
|
||
|
||
apply_terminal_attach_scroll(
|
||
&runtime,
|
||
AttachScrollSource::PageKey {
|
||
input: b"\x1b[5~".to_vec(),
|
||
},
|
||
AttachScrollDirection::Up,
|
||
4,
|
||
None,
|
||
None,
|
||
0,
|
||
)
|
||
.expect("page key forward");
|
||
|
||
assert_eq!(
|
||
runtime
|
||
.scroll_metrics()
|
||
.expect("scroll metrics")
|
||
.offset_from_bottom,
|
||
0
|
||
);
|
||
assert_eq!(
|
||
input_rx.try_recv().expect("forwarded page key"),
|
||
Bytes::from_static(b"\x1b[5~")
|
||
);
|
||
drop(runtime);
|
||
drop(_runtime_guard);
|
||
rt.shutdown_timeout(Duration::from_millis(100));
|
||
}
|
||
|
||
#[test]
|
||
fn headless_scheduled_tasks_expire_agent_metadata() {
|
||
let mut server = test_headless_server();
|
||
let workspace = crate::workspace::Workspace::test_new("metadata");
|
||
let pane_id = workspace.tabs[0].root_pane;
|
||
server.app.state.workspaces = vec![workspace];
|
||
server.app.state.ensure_test_terminals();
|
||
|
||
assert!(
|
||
server.handle_internal_event_with_forwarding(AppEvent::HookStateReported {
|
||
pane_id,
|
||
source: "herdr:pi".into(),
|
||
agent_label: "pi".into(),
|
||
state: crate::detect::AgentState::Working,
|
||
message: None,
|
||
custom_status: None,
|
||
seq: None,
|
||
session_ref: None,
|
||
})
|
||
);
|
||
assert!(
|
||
server.handle_internal_event_with_forwarding(AppEvent::HookMetadataReported {
|
||
pane_id,
|
||
source: "user:pi-display".into(),
|
||
agent_label: Some("pi".into()),
|
||
applies_to_source: Some("herdr:pi".into()),
|
||
title: None,
|
||
display_agent: None,
|
||
custom_status: Some("short lived".into()),
|
||
state_labels: HashMap::new(),
|
||
clear_title: false,
|
||
clear_display_agent: false,
|
||
clear_custom_status: false,
|
||
clear_state_labels: false,
|
||
seq: None,
|
||
ttl: Some(Duration::from_millis(1)),
|
||
})
|
||
);
|
||
|
||
let deadline = server
|
||
.app
|
||
.agent_metadata_deadline
|
||
.expect("metadata deadline");
|
||
let terminal_id = server.app.state.workspaces[0]
|
||
.pane_state(pane_id)
|
||
.expect("pane")
|
||
.attached_terminal_id
|
||
.clone();
|
||
assert_eq!(
|
||
server
|
||
.app
|
||
.state
|
||
.terminals
|
||
.get(&terminal_id)
|
||
.expect("terminal")
|
||
.effective_custom_status()
|
||
.as_deref(),
|
||
Some("short lived")
|
||
);
|
||
|
||
assert!(server.handle_scheduled_tasks_headless(deadline + Duration::from_millis(1), false));
|
||
|
||
assert_eq!(server.app.agent_metadata_deadline, None);
|
||
assert_eq!(
|
||
server
|
||
.app
|
||
.state
|
||
.terminals
|
||
.get(&terminal_id)
|
||
.expect("terminal")
|
||
.effective_custom_status(),
|
||
None
|
||
);
|
||
assert!(server
|
||
.app
|
||
.event_hub
|
||
.events_after(0)
|
||
.iter()
|
||
.any(|(_, event)| {
|
||
event.event == crate::api::schema::EventKind::PaneAgentStatusChanged
|
||
&& matches!(
|
||
&event.data,
|
||
crate::api::schema::EventData::PaneAgentStatusChanged {
|
||
custom_status,
|
||
..
|
||
} if custom_status.is_none()
|
||
)
|
||
}));
|
||
}
|
||
|
||
#[test]
|
||
fn headless_scheduled_tasks_clears_disabled_agent_manifest_update_deadline() {
|
||
let mut server = test_headless_server();
|
||
let now = Instant::now();
|
||
server.app.next_agent_manifest_update_check = Some(now - Duration::from_millis(1));
|
||
|
||
assert!(!server.handle_scheduled_tasks_headless(now, false));
|
||
assert_eq!(server.app.next_agent_manifest_update_check, None);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn headless_scheduled_tasks_do_not_start_pending_agent_resume_when_geometry_dirty() {
|
||
let mut server = test_headless_server();
|
||
let workspace = crate::workspace::Workspace::test_new("restored");
|
||
let pane_id = workspace.tabs[0].root_pane;
|
||
let terminal_id = workspace.terminal_id(pane_id).cloned().unwrap();
|
||
server.app.state.view.pane_infos = workspace.tabs[0]
|
||
.layout
|
||
.panes(ratatui::layout::Rect::new(0, 0, 100, 30));
|
||
server.app.state.workspaces = vec![workspace];
|
||
server.app.state.active = Some(0);
|
||
server.app.state.ensure_test_terminals();
|
||
server.clients.insert(
|
||
1,
|
||
ClientConnection::new(
|
||
(100, 30),
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
server.app.state.host_terminal_theme,
|
||
Some(true),
|
||
1,
|
||
RenderEncoding::SemanticFrame,
|
||
None,
|
||
),
|
||
);
|
||
server.foreground_client_id = Some(1);
|
||
server.effective_size = (100, 30);
|
||
server.app.state.host_terminal_theme = crate::terminal_theme::TerminalTheme {
|
||
foreground: Some(crate::terminal_theme::RgbColor {
|
||
r: 220,
|
||
g: 220,
|
||
b: 220,
|
||
}),
|
||
background: Some(crate::terminal_theme::RgbColor {
|
||
r: 20,
|
||
g: 20,
|
||
b: 20,
|
||
}),
|
||
};
|
||
server
|
||
.app
|
||
.state
|
||
.terminals
|
||
.get_mut(&terminal_id)
|
||
.expect("test terminal should exist")
|
||
.pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan {
|
||
agent: "codex".into(),
|
||
argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()],
|
||
dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(),
|
||
});
|
||
server.app.pending_agent_resume_deadline = Some(Instant::now() - Duration::from_millis(1));
|
||
|
||
assert!(!server.handle_scheduled_tasks_headless(Instant::now(), true));
|
||
assert!(server.app.terminal_runtimes.get(&terminal_id).is_none());
|
||
assert!(server
|
||
.app
|
||
.state
|
||
.terminals
|
||
.get(&terminal_id)
|
||
.expect("test terminal should still exist")
|
||
.pending_agent_resume_plan
|
||
.is_some());
|
||
assert!(server.app.pending_agent_resume_deadline.is_none());
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn headless_scheduled_tasks_do_not_start_pending_agent_resume_without_foreground_client()
|
||
{
|
||
let mut server = test_headless_server();
|
||
let workspace = crate::workspace::Workspace::test_new("restored");
|
||
let pane_id = workspace.tabs[0].root_pane;
|
||
let terminal_id = workspace.terminal_id(pane_id).cloned().unwrap();
|
||
server.app.state.view.pane_infos = workspace.tabs[0]
|
||
.layout
|
||
.panes(ratatui::layout::Rect::new(0, 0, 80, 24));
|
||
server.app.state.workspaces = vec![workspace];
|
||
server.app.state.active = Some(0);
|
||
server.app.state.ensure_test_terminals();
|
||
server.foreground_client_id = None;
|
||
server.effective_size = (80, 24);
|
||
server.app.state.host_terminal_theme = crate::terminal_theme::TerminalTheme {
|
||
foreground: Some(crate::terminal_theme::RgbColor {
|
||
r: 220,
|
||
g: 220,
|
||
b: 220,
|
||
}),
|
||
background: Some(crate::terminal_theme::RgbColor {
|
||
r: 20,
|
||
g: 20,
|
||
b: 20,
|
||
}),
|
||
};
|
||
server
|
||
.app
|
||
.state
|
||
.terminals
|
||
.get_mut(&terminal_id)
|
||
.expect("test terminal should exist")
|
||
.pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan {
|
||
agent: "codex".into(),
|
||
argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()],
|
||
dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(),
|
||
});
|
||
server.app.pending_agent_resume_deadline = Some(Instant::now() - Duration::from_millis(1));
|
||
|
||
assert!(!server.handle_scheduled_tasks_headless(Instant::now(), false));
|
||
assert!(server.app.terminal_runtimes.get(&terminal_id).is_none());
|
||
assert!(server
|
||
.app
|
||
.state
|
||
.terminals
|
||
.get(&terminal_id)
|
||
.expect("test terminal should still exist")
|
||
.pending_agent_resume_plan
|
||
.is_some());
|
||
assert!(server.app.pending_agent_resume_deadline.is_none());
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn headless_pre_input_resize_does_not_start_pending_agent_resume() {
|
||
let mut server = test_headless_server();
|
||
let workspace = crate::workspace::Workspace::test_new("restored");
|
||
let pane_id = workspace.tabs[0].root_pane;
|
||
let terminal_id = workspace.terminal_id(pane_id).cloned().unwrap();
|
||
server.app.state.view.pane_infos = workspace.tabs[0]
|
||
.layout
|
||
.panes(ratatui::layout::Rect::new(0, 0, 100, 30));
|
||
server.app.state.workspaces = vec![workspace];
|
||
server.app.state.active = Some(0);
|
||
server.app.state.ensure_test_terminals();
|
||
server.clients.insert(
|
||
1,
|
||
ClientConnection::new(
|
||
(100, 30),
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
server.app.state.host_terminal_theme,
|
||
Some(true),
|
||
1,
|
||
RenderEncoding::SemanticFrame,
|
||
None,
|
||
),
|
||
);
|
||
server.foreground_client_id = Some(1);
|
||
server.effective_size = (100, 30);
|
||
server.app.state.host_terminal_theme = crate::terminal_theme::TerminalTheme {
|
||
foreground: Some(crate::terminal_theme::RgbColor {
|
||
r: 220,
|
||
g: 220,
|
||
b: 220,
|
||
}),
|
||
background: Some(crate::terminal_theme::RgbColor {
|
||
r: 20,
|
||
g: 20,
|
||
b: 20,
|
||
}),
|
||
};
|
||
server
|
||
.app
|
||
.state
|
||
.terminals
|
||
.get_mut(&terminal_id)
|
||
.expect("test terminal should exist")
|
||
.pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan {
|
||
agent: "codex".into(),
|
||
argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()],
|
||
dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(),
|
||
});
|
||
server.app.pending_agent_resume_deadline = Some(Instant::now() - Duration::from_millis(1));
|
||
|
||
server.resize_shared_runtime_to_effective_size_before_input();
|
||
|
||
assert!(server.app.terminal_runtimes.get(&terminal_id).is_none());
|
||
assert!(server
|
||
.app
|
||
.state
|
||
.terminals
|
||
.get(&terminal_id)
|
||
.expect("test terminal should still exist")
|
||
.pending_agent_resume_plan
|
||
.is_some());
|
||
assert!(server.app.pending_agent_resume_deadline.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn virtual_render_produces_nonempty_buffer() {
|
||
let mut state = AppState::test_new();
|
||
let area = Rect::new(0, 0, 80, 24);
|
||
let (buffer, _cursor) =
|
||
crate::server::render_stream::render_virtual(&mut state, area, true);
|
||
assert_eq!(buffer.area.width, 80);
|
||
assert_eq!(buffer.area.height, 24);
|
||
}
|
||
|
||
#[test]
|
||
fn virtual_render_without_frame_cursor_keeps_cursor_hidden() {
|
||
let mut state = AppState::test_new();
|
||
let area = Rect::new(0, 0, 80, 24);
|
||
let (_buffer, cursor) =
|
||
crate::server::render_stream::render_virtual(&mut state, area, true);
|
||
|
||
assert_eq!(cursor, None);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn virtual_render_preserves_explicit_frame_cursor_position() {
|
||
let mut state = AppState::test_new();
|
||
let mut ws = crate::workspace::Workspace::test_new("test");
|
||
let pane_id = ws.tabs[0].root_pane;
|
||
ws.insert_test_runtime(
|
||
pane_id,
|
||
crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"left"),
|
||
);
|
||
|
||
state.workspaces = vec![ws];
|
||
state.active = Some(0);
|
||
state.selected = 0;
|
||
state.mode = crate::app::Mode::Terminal;
|
||
|
||
let area = Rect::new(0, 0, 80, 24);
|
||
let (_buffer, cursor) =
|
||
crate::server::render_stream::render_virtual(&mut state, area, true);
|
||
let pane = state
|
||
.view
|
||
.pane_infos
|
||
.iter()
|
||
.find(|info| info.id == pane_id)
|
||
.expect("focused pane info");
|
||
|
||
assert_eq!(
|
||
cursor,
|
||
Some(CursorState {
|
||
x: pane.inner_rect.x + 4,
|
||
y: pane.inner_rect.y,
|
||
visible: true,
|
||
shape: cursor.as_ref().map(|c| c.shape).unwrap_or(0),
|
||
})
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn virtual_render_preserves_hidden_focused_pane_cursor_position() {
|
||
let mut state = AppState::test_new();
|
||
let mut ws = crate::workspace::Workspace::test_new("test");
|
||
let pane_id = ws.tabs[0].root_pane;
|
||
ws.insert_test_runtime(
|
||
pane_id,
|
||
crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"left\x1b[?25l"),
|
||
);
|
||
|
||
state.workspaces = vec![ws];
|
||
state.active = Some(0);
|
||
state.selected = 0;
|
||
state.mode = crate::app::Mode::Terminal;
|
||
|
||
let area = Rect::new(0, 0, 80, 24);
|
||
let (_buffer, cursor) =
|
||
crate::server::render_stream::render_virtual(&mut state, area, true);
|
||
let pane = state
|
||
.view
|
||
.pane_infos
|
||
.iter()
|
||
.find(|info| info.id == pane_id)
|
||
.expect("focused pane info");
|
||
|
||
assert_eq!(
|
||
cursor,
|
||
Some(CursorState {
|
||
x: pane.inner_rect.x + 4,
|
||
y: pane.inner_rect.y,
|
||
visible: false,
|
||
shape: cursor.as_ref().map(|c| c.shape).unwrap_or(0),
|
||
})
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn virtual_render_hides_focused_pane_cursor_during_synchronized_output() {
|
||
let mut state = AppState::test_new();
|
||
state.reveal_hidden_cursor_for_cjk_ime = true;
|
||
let mut ws = crate::workspace::Workspace::test_new("test");
|
||
let pane_id = ws.tabs[0].root_pane;
|
||
let runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"left");
|
||
ws.insert_test_runtime(pane_id, runtime);
|
||
|
||
state.workspaces = vec![ws];
|
||
state.active = Some(0);
|
||
state.selected = 0;
|
||
state.mode = crate::app::Mode::Terminal;
|
||
|
||
let area = Rect::new(0, 0, 80, 24);
|
||
let _ = crate::server::render_stream::render_virtual(&mut state, area, true);
|
||
let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
|
||
let runtime = state
|
||
.runtime_for_pane(&terminal_runtimes, pane_id)
|
||
.expect("pane runtime after initial render");
|
||
runtime.test_process_pty_bytes(b"\x1b[?2026h\x1b[2;3H");
|
||
assert!(runtime.synchronized_output_active());
|
||
|
||
let (_buffer, cursor) =
|
||
crate::server::render_stream::render_virtual(&mut state, area, false);
|
||
|
||
assert_eq!(
|
||
cursor, None,
|
||
"child cursor positions are unstable while synchronized output is active"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn virtual_render_hides_focused_pane_cursor_during_synchronized_output_resize() {
|
||
let mut state = AppState::test_new();
|
||
let mut ws = crate::workspace::Workspace::test_new("test");
|
||
let pane_id = ws.tabs[0].root_pane;
|
||
let runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"left");
|
||
ws.insert_test_runtime(pane_id, runtime);
|
||
|
||
state.workspaces = vec![ws];
|
||
state.active = Some(0);
|
||
state.selected = 0;
|
||
state.mode = crate::app::Mode::Terminal;
|
||
|
||
let initial_area = Rect::new(0, 0, 80, 24);
|
||
let _ = crate::server::render_stream::render_virtual(&mut state, initial_area, true);
|
||
let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
|
||
let runtime = state
|
||
.runtime_for_pane(&terminal_runtimes, pane_id)
|
||
.expect("pane runtime after initial render");
|
||
runtime.test_process_pty_bytes(b"\x1b[?2026h\x1b[2;3H");
|
||
assert!(runtime.synchronized_output_active());
|
||
|
||
let resized_area = Rect::new(0, 0, 100, 30);
|
||
let (_buffer, cursor) =
|
||
crate::server::render_stream::render_virtual(&mut state, resized_area, true);
|
||
|
||
assert_eq!(
|
||
cursor, None,
|
||
"pre-resize synchronized output should suppress the cursor even if resize clears the mode"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn virtual_render_exposes_hidden_pane_cursor_when_reveal_hidden_for_cjk_ime() {
|
||
let mut state = AppState::test_new();
|
||
state.reveal_hidden_cursor_for_cjk_ime = true;
|
||
let mut ws = crate::workspace::Workspace::test_new("test");
|
||
let pane_id = ws.tabs[0].root_pane;
|
||
ws.insert_test_runtime(
|
||
pane_id,
|
||
crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"left\x1b[?25l"),
|
||
);
|
||
|
||
state.workspaces = vec![ws];
|
||
state.active = Some(0);
|
||
state.selected = 0;
|
||
state.mode = crate::app::Mode::Terminal;
|
||
|
||
let area = Rect::new(0, 0, 80, 24);
|
||
let (_buffer, cursor) =
|
||
crate::server::render_stream::render_virtual(&mut state, area, true);
|
||
let pane = state
|
||
.view
|
||
.pane_infos
|
||
.iter()
|
||
.find(|info| info.id == pane_id)
|
||
.expect("focused pane info");
|
||
|
||
assert_eq!(
|
||
cursor,
|
||
Some(CursorState {
|
||
x: pane.inner_rect.x + 4,
|
||
y: pane.inner_rect.y,
|
||
visible: true,
|
||
shape: state.cjk_ime_cursor_shape,
|
||
})
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn virtual_render_keeps_cursor_hidden_when_scrolled_back_even_with_reveal_hidden_for_cjk_ime(
|
||
) {
|
||
let mut state = AppState::test_new();
|
||
state.reveal_hidden_cursor_for_cjk_ime = true;
|
||
let mut ws = crate::workspace::Workspace::test_new("test");
|
||
let pane_id = ws.tabs[0].root_pane;
|
||
let mut bytes = Vec::new();
|
||
for line in 0..80 {
|
||
bytes.extend_from_slice(format!("line {line:02}\r\n").as_bytes());
|
||
}
|
||
let runtime =
|
||
crate::terminal::TerminalRuntime::test_with_scrollback_bytes(20, 5, 4096, &bytes);
|
||
ws.insert_test_runtime(pane_id, runtime);
|
||
|
||
state.workspaces = vec![ws];
|
||
state.active = Some(0);
|
||
state.selected = 0;
|
||
state.mode = crate::app::Mode::Terminal;
|
||
|
||
let area = Rect::new(0, 0, 80, 24);
|
||
let _ = crate::server::render_stream::render_virtual(&mut state, area, true);
|
||
let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
|
||
let runtime = state
|
||
.runtime_for_pane(&terminal_runtimes, pane_id)
|
||
.expect("pane runtime after initial render");
|
||
runtime.scroll_up(6);
|
||
assert!(crate::ui::pane_is_scrolled_back(runtime));
|
||
|
||
let (_buffer, cursor) =
|
||
crate::server::render_stream::render_virtual(&mut state, area, true);
|
||
|
||
assert!(
|
||
cursor.as_ref().is_none_or(|cursor| !cursor.visible),
|
||
"scrolled-back focused pane should keep the cursor hidden even when reveal_hidden_cursor_for_cjk_ime is true; got {cursor:?}",
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn virtual_render_fallback_cursor_when_viewport_none_and_reveal_hidden_for_cjk_ime() {
|
||
let mut state = AppState::test_new();
|
||
state.reveal_hidden_cursor_for_cjk_ime = true;
|
||
let mut ws = crate::workspace::Workspace::test_new("test");
|
||
let pane_id = ws.tabs[0].root_pane;
|
||
// Feed only ?25l with no prior cursor movement — exercises the fallback
|
||
// path for TUIs whose viewport has no cursor position.
|
||
ws.insert_test_runtime(
|
||
pane_id,
|
||
crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"\x1b[?25l"),
|
||
);
|
||
|
||
state.workspaces = vec![ws];
|
||
state.active = Some(0);
|
||
state.selected = 0;
|
||
state.mode = crate::app::Mode::Terminal;
|
||
|
||
let area = Rect::new(0, 0, 80, 24);
|
||
let (_buffer, cursor) =
|
||
crate::server::render_stream::render_virtual(&mut state, area, true);
|
||
let pane = state
|
||
.view
|
||
.pane_infos
|
||
.iter()
|
||
.find(|info| info.id == pane_id)
|
||
.expect("focused pane info");
|
||
|
||
assert_eq!(
|
||
cursor,
|
||
Some(CursorState {
|
||
x: pane.inner_rect.x,
|
||
y: pane.inner_rect.y,
|
||
visible: true,
|
||
shape: state.cjk_ime_cursor_shape,
|
||
}),
|
||
"fallback should anchor at pane top-left with the configured shape",
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn virtual_render_skips_reveal_when_focused_pane_has_no_detected_agent() {
|
||
let mut state = AppState::test_new();
|
||
state.reveal_hidden_cursor_for_cjk_ime = true;
|
||
// Filter only Claude, but the test pane has no detected agent, so the
|
||
// reveal must not apply.
|
||
state.cjk_ime_agent_filter_configured = true;
|
||
state.cjk_ime_agents = vec![crate::detect::Agent::Claude];
|
||
let mut ws = crate::workspace::Workspace::test_new("test");
|
||
let pane_id = ws.tabs[0].root_pane;
|
||
ws.insert_test_runtime(
|
||
pane_id,
|
||
crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"left\x1b[?25l"),
|
||
);
|
||
|
||
state.workspaces = vec![ws];
|
||
state.active = Some(0);
|
||
state.selected = 0;
|
||
state.mode = crate::app::Mode::Terminal;
|
||
|
||
let area = Rect::new(0, 0, 80, 24);
|
||
let (_buffer, cursor) =
|
||
crate::server::render_stream::render_virtual(&mut state, area, true);
|
||
|
||
assert!(
|
||
cursor.as_ref().is_none_or(|cursor| !cursor.visible),
|
||
"agent filter should suppress reveal when the focused pane's detected agent is not on the list; got {cursor:?}",
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn virtual_render_skips_reveal_when_agent_filter_has_no_valid_entries() {
|
||
let mut state = AppState::test_new();
|
||
state.reveal_hidden_cursor_for_cjk_ime = true;
|
||
state.cjk_ime_agent_filter_configured = true;
|
||
state.cjk_ime_agents = Vec::new();
|
||
let mut ws = crate::workspace::Workspace::test_new("test");
|
||
let pane_id = ws.tabs[0].root_pane;
|
||
ws.insert_test_runtime(
|
||
pane_id,
|
||
crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"left\x1b[?25l"),
|
||
);
|
||
|
||
state.workspaces = vec![ws];
|
||
state.active = Some(0);
|
||
state.selected = 0;
|
||
state.mode = crate::app::Mode::Terminal;
|
||
|
||
let area = Rect::new(0, 0, 80, 24);
|
||
let (_buffer, cursor) =
|
||
crate::server::render_stream::render_virtual(&mut state, area, true);
|
||
|
||
assert!(
|
||
cursor.as_ref().is_none_or(|cursor| !cursor.visible),
|
||
"agent filter with no valid entries should suppress reveal; got {cursor:?}",
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn virtual_render_omits_focused_pane_cursor_while_mobile_switcher_open() {
|
||
let mut state = AppState::test_new();
|
||
let mut ws = crate::workspace::Workspace::test_new("test");
|
||
let pane_id = ws.tabs[0].root_pane;
|
||
ws.insert_test_runtime(
|
||
pane_id,
|
||
crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"left"),
|
||
);
|
||
|
||
state.workspaces = vec![ws];
|
||
state.active = Some(0);
|
||
state.selected = 0;
|
||
state.mode = crate::app::Mode::Navigate;
|
||
|
||
let area = Rect::new(0, 0, 44, 24);
|
||
let (_buffer, cursor) =
|
||
crate::server::render_stream::render_virtual(&mut state, area, true);
|
||
|
||
assert_eq!(cursor, None);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn virtual_render_hides_focused_pane_cursor_while_scrolled_back() {
|
||
let mut state = AppState::test_new();
|
||
let mut ws = crate::workspace::Workspace::test_new("test");
|
||
let pane_id = ws.tabs[0].root_pane;
|
||
let mut bytes = Vec::new();
|
||
for line in 0..80 {
|
||
bytes.extend_from_slice(format!("line {line:02}\r\n").as_bytes());
|
||
}
|
||
let runtime =
|
||
crate::terminal::TerminalRuntime::test_with_scrollback_bytes(20, 5, 4096, &bytes);
|
||
ws.insert_test_runtime(pane_id, runtime);
|
||
|
||
state.workspaces = vec![ws];
|
||
state.active = Some(0);
|
||
state.selected = 0;
|
||
state.mode = crate::app::Mode::Terminal;
|
||
|
||
let area = Rect::new(0, 0, 80, 24);
|
||
let _ = crate::server::render_stream::render_virtual(&mut state, area, true);
|
||
let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
|
||
let runtime = state
|
||
.runtime_for_pane(&terminal_runtimes, pane_id)
|
||
.expect("pane runtime after initial render");
|
||
runtime.scroll_up(6);
|
||
assert!(crate::ui::pane_is_scrolled_back(runtime));
|
||
|
||
let (_buffer, cursor) =
|
||
crate::server::render_stream::render_virtual(&mut state, area, true);
|
||
|
||
assert!(
|
||
cursor.as_ref().is_none_or(|cursor| !cursor.visible),
|
||
"cursor: {cursor:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn latest_active_client_drives_shared_size_theme_and_fallback() {
|
||
let mut server = test_headless_server();
|
||
|
||
server.clients.insert(
|
||
1,
|
||
ClientConnection::new(
|
||
(160, 45),
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
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,
|
||
}),
|
||
},
|
||
None,
|
||
1,
|
||
RenderEncoding::SemanticFrame,
|
||
None,
|
||
),
|
||
);
|
||
server.clients.insert(
|
||
2,
|
||
ClientConnection::new(
|
||
(80, 24),
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
crate::terminal_theme::TerminalTheme {
|
||
foreground: Some(crate::terminal_theme::RgbColor {
|
||
r: 0x10,
|
||
g: 0x20,
|
||
b: 0x30,
|
||
}),
|
||
background: Some(crate::terminal_theme::RgbColor {
|
||
r: 0xdd,
|
||
g: 0xee,
|
||
b: 0xff,
|
||
}),
|
||
},
|
||
None,
|
||
2,
|
||
RenderEncoding::SemanticFrame,
|
||
None,
|
||
),
|
||
);
|
||
|
||
assert!(server.promote_client_to_foreground(1));
|
||
assert_eq!(server.foreground_client_id, Some(1));
|
||
assert_eq!(server.effective_size, (160, 45));
|
||
assert_eq!(
|
||
server.app.state.host_terminal_theme,
|
||
server.clients[&1].host_terminal_theme
|
||
);
|
||
|
||
assert!(server.promote_client_to_foreground(2));
|
||
assert_eq!(server.foreground_client_id, Some(2));
|
||
assert_eq!(server.effective_size, (80, 24));
|
||
assert_eq!(
|
||
server.app.state.host_terminal_theme,
|
||
server.clients[&2].host_terminal_theme
|
||
);
|
||
|
||
assert!(server.remove_client(2));
|
||
assert_eq!(server.foreground_client_id, Some(1));
|
||
assert_eq!(server.effective_size, (160, 45));
|
||
assert_eq!(
|
||
server.app.state.host_terminal_theme,
|
||
server.clients[&1].host_terminal_theme
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn foreground_client_without_host_theme_clears_previous_host_theme() {
|
||
let mut server = test_headless_server();
|
||
let known_theme = crate::terminal_theme::TerminalTheme {
|
||
foreground: Some(crate::terminal_theme::RgbColor {
|
||
r: 0x10,
|
||
g: 0x20,
|
||
b: 0x30,
|
||
}),
|
||
background: Some(crate::terminal_theme::RgbColor {
|
||
r: 0x40,
|
||
g: 0x50,
|
||
b: 0x60,
|
||
}),
|
||
};
|
||
server.clients.insert(
|
||
1,
|
||
ClientConnection::new(
|
||
(120, 40),
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
known_theme,
|
||
None,
|
||
1,
|
||
RenderEncoding::SemanticFrame,
|
||
None,
|
||
),
|
||
);
|
||
server.clients.insert(
|
||
2,
|
||
ClientConnection::new(
|
||
(120, 40),
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
crate::terminal_theme::TerminalTheme::default(),
|
||
None,
|
||
2,
|
||
RenderEncoding::SemanticFrame,
|
||
None,
|
||
),
|
||
);
|
||
|
||
assert!(server.promote_client_to_foreground(1));
|
||
assert_eq!(server.app.state.host_terminal_theme, known_theme);
|
||
|
||
assert!(server.promote_client_to_foreground(2));
|
||
assert_eq!(
|
||
server.app.state.host_terminal_theme,
|
||
crate::terminal_theme::TerminalTheme::default()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn foreground_client_appearance_controls_auto_theme() {
|
||
let mut server = test_headless_server();
|
||
server.app.state.theme_runtime.auto_switch = true;
|
||
server.app.state.theme_runtime.dark_name = "catppuccin".to_string();
|
||
server.app.state.theme_runtime.light_name = "catppuccin-latte".to_string();
|
||
server.clients.insert(
|
||
1,
|
||
ClientConnection::new(
|
||
(120, 40),
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
crate::terminal_theme::TerminalTheme {
|
||
foreground: None,
|
||
background: Some(crate::terminal_theme::RgbColor { r: 0, g: 0, b: 0 }),
|
||
},
|
||
None,
|
||
1,
|
||
RenderEncoding::SemanticFrame,
|
||
None,
|
||
),
|
||
);
|
||
server.clients.insert(
|
||
2,
|
||
ClientConnection::new(
|
||
(120, 40),
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
crate::terminal_theme::TerminalTheme {
|
||
foreground: None,
|
||
background: Some(crate::terminal_theme::RgbColor {
|
||
r: 255,
|
||
g: 255,
|
||
b: 255,
|
||
}),
|
||
},
|
||
None,
|
||
2,
|
||
RenderEncoding::SemanticFrame,
|
||
None,
|
||
),
|
||
);
|
||
|
||
assert!(server.promote_client_to_foreground(1));
|
||
assert_eq!(server.app.state.theme_name, "catppuccin");
|
||
|
||
assert!(server.promote_client_to_foreground(2));
|
||
assert_eq!(server.app.state.theme_name, "catppuccin-latte");
|
||
}
|
||
|
||
#[test]
|
||
fn color_scheme_change_event_is_inert_on_server() {
|
||
let mut server = test_headless_server();
|
||
let initial_theme = crate::terminal_theme::TerminalTheme {
|
||
foreground: Some(crate::terminal_theme::RgbColor {
|
||
r: 0x10,
|
||
g: 0x20,
|
||
b: 0x30,
|
||
}),
|
||
background: Some(crate::terminal_theme::RgbColor {
|
||
r: 0x40,
|
||
g: 0x50,
|
||
b: 0x60,
|
||
}),
|
||
};
|
||
server.app.state.host_terminal_theme = initial_theme;
|
||
server.clients.insert(
|
||
1,
|
||
ClientConnection::new(
|
||
(120, 40),
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
initial_theme,
|
||
None,
|
||
1,
|
||
RenderEncoding::SemanticFrame,
|
||
None,
|
||
),
|
||
);
|
||
|
||
let changed = server.handle_server_event(ServerEvent::ClientInput {
|
||
client_id: 1,
|
||
data: crate::raw_input::GHOSTTY_COLOR_SCHEME_DARK_REPORT.to_vec(),
|
||
});
|
||
|
||
assert!(!changed);
|
||
assert_eq!(server.foreground_client_id, None);
|
||
assert_eq!(server.clients[&1].host_terminal_theme, initial_theme);
|
||
assert_eq!(server.app.state.host_terminal_theme, initial_theme);
|
||
}
|
||
|
||
#[test]
|
||
fn focus_lost_updates_client_without_promoting_foreground() {
|
||
let mut server = test_headless_server();
|
||
|
||
server.clients.insert(
|
||
1,
|
||
ClientConnection::new(
|
||
(120, 40),
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
crate::terminal_theme::TerminalTheme::default(),
|
||
None,
|
||
1,
|
||
RenderEncoding::SemanticFrame,
|
||
None,
|
||
),
|
||
);
|
||
server.clients.insert(
|
||
2,
|
||
ClientConnection::new(
|
||
(80, 24),
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
crate::terminal_theme::TerminalTheme::default(),
|
||
Some(true),
|
||
2,
|
||
RenderEncoding::SemanticFrame,
|
||
None,
|
||
),
|
||
);
|
||
server.foreground_client_id = Some(2);
|
||
server.sync_foreground_client_state();
|
||
|
||
let changed = server.handle_server_event(ServerEvent::ClientInput {
|
||
client_id: 1,
|
||
data: b"\x1b[O".to_vec(),
|
||
});
|
||
|
||
assert!(!changed);
|
||
assert_eq!(server.foreground_client_id, Some(2));
|
||
assert_eq!(server.clients[&1].outer_terminal_focus, Some(false));
|
||
assert_eq!(server.app.state.outer_terminal_focus, Some(true));
|
||
}
|
||
|
||
#[test]
|
||
fn focus_gained_promotes_client_to_foreground() {
|
||
let mut server = test_headless_server();
|
||
|
||
server.clients.insert(
|
||
1,
|
||
ClientConnection::new(
|
||
(120, 40),
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
crate::terminal_theme::TerminalTheme::default(),
|
||
None,
|
||
1,
|
||
RenderEncoding::SemanticFrame,
|
||
None,
|
||
),
|
||
);
|
||
server.clients.insert(
|
||
2,
|
||
ClientConnection::new(
|
||
(80, 24),
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
crate::terminal_theme::TerminalTheme::default(),
|
||
Some(true),
|
||
2,
|
||
RenderEncoding::SemanticFrame,
|
||
None,
|
||
),
|
||
);
|
||
server.foreground_client_id = Some(2);
|
||
server.sync_foreground_client_state();
|
||
|
||
let changed = server.handle_server_event(ServerEvent::ClientInput {
|
||
client_id: 1,
|
||
data: b"\x1b[I".to_vec(),
|
||
});
|
||
|
||
assert!(changed);
|
||
assert_eq!(server.foreground_client_id, Some(1));
|
||
assert_eq!(server.clients[&1].outer_terminal_focus, Some(true));
|
||
assert_eq!(server.app.state.outer_terminal_focus, Some(true));
|
||
}
|
||
|
||
#[test]
|
||
fn foreground_client_focus_event_updates_app_focus_state() {
|
||
let mut server = test_headless_server();
|
||
|
||
server.clients.insert(
|
||
1,
|
||
ClientConnection::new(
|
||
(120, 40),
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
crate::terminal_theme::TerminalTheme::default(),
|
||
Some(true),
|
||
1,
|
||
RenderEncoding::SemanticFrame,
|
||
None,
|
||
),
|
||
);
|
||
server.foreground_client_id = Some(1);
|
||
server.sync_foreground_client_state();
|
||
|
||
let changed = server.handle_server_event(ServerEvent::ClientInput {
|
||
client_id: 1,
|
||
data: b"\x1b[O".to_vec(),
|
||
});
|
||
|
||
assert!(!changed);
|
||
assert_eq!(server.clients[&1].outer_terminal_focus, Some(false));
|
||
assert_eq!(server.app.state.outer_terminal_focus, Some(false));
|
||
}
|
||
|
||
#[test]
|
||
fn app_client_lone_escape_closes_navigate_mode() {
|
||
let mut server = test_headless_server();
|
||
server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("test")];
|
||
server.app.state.active = Some(0);
|
||
server.app.state.selected = 0;
|
||
server.app.state.mode = crate::app::Mode::Navigate;
|
||
server.clients.insert(
|
||
1,
|
||
ClientConnection::new(
|
||
(80, 24),
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
crate::terminal_theme::TerminalTheme::default(),
|
||
Some(true),
|
||
1,
|
||
RenderEncoding::SemanticFrame,
|
||
None,
|
||
),
|
||
);
|
||
server.foreground_client_id = Some(1);
|
||
server.sync_foreground_client_state();
|
||
|
||
assert!(server.handle_server_event(ServerEvent::ClientInput {
|
||
client_id: 1,
|
||
data: b"\x1b".to_vec(),
|
||
}));
|
||
|
||
assert_eq!(server.app.state.mode, crate::app::Mode::Terminal);
|
||
}
|
||
|
||
#[test]
|
||
fn semantic_client_input_events_route_through_app_input() {
|
||
let mut server = test_headless_server();
|
||
server.app.state.mode = crate::app::Mode::Onboarding;
|
||
server.clients.insert(
|
||
1,
|
||
ClientConnection::new(
|
||
(80, 24),
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
crate::terminal_theme::TerminalTheme::default(),
|
||
Some(true),
|
||
1,
|
||
RenderEncoding::SemanticFrame,
|
||
None,
|
||
),
|
||
);
|
||
server.foreground_client_id = Some(1);
|
||
server.sync_foreground_client_state();
|
||
|
||
assert!(server.handle_server_event(ServerEvent::ClientInputEvents {
|
||
client_id: 1,
|
||
events: vec![crate::protocol::ClientInputEvent::Key {
|
||
code: crate::protocol::ClientKeyCode::Enter,
|
||
modifiers: 0,
|
||
kind: crate::protocol::ClientKeyKind::Press,
|
||
}],
|
||
}));
|
||
|
||
assert_eq!(server.app.state.mode, crate::app::Mode::Settings);
|
||
assert_eq!(
|
||
server.app.state.settings.section,
|
||
crate::app::state::SettingsSection::Integrations
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn semantic_client_escape_closes_keybind_help() {
|
||
let mut server = test_headless_server();
|
||
server.app.state.mode = crate::app::Mode::KeybindHelp;
|
||
server.clients.insert(
|
||
1,
|
||
ClientConnection::new(
|
||
(100, 30),
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
crate::terminal_theme::TerminalTheme::default(),
|
||
Some(true),
|
||
1,
|
||
RenderEncoding::SemanticFrame,
|
||
None,
|
||
),
|
||
);
|
||
server.foreground_client_id = Some(1);
|
||
server.sync_foreground_client_state();
|
||
server.resize_shared_runtime_to_effective_size();
|
||
|
||
assert!(server.handle_server_event(ServerEvent::ClientInputEvents {
|
||
client_id: 1,
|
||
events: vec![crate::protocol::ClientInputEvent::Key {
|
||
code: crate::protocol::ClientKeyCode::Esc,
|
||
modifiers: 0,
|
||
kind: crate::protocol::ClientKeyKind::Press,
|
||
}],
|
||
}));
|
||
|
||
assert_eq!(server.app.state.mode, crate::app::Mode::Navigate);
|
||
}
|
||
|
||
#[test]
|
||
fn semantic_client_down_scrolls_keybind_help() {
|
||
let mut server = test_headless_server();
|
||
server.app.state.mode = crate::app::Mode::KeybindHelp;
|
||
server.clients.insert(
|
||
1,
|
||
ClientConnection::new(
|
||
(100, 30),
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
crate::terminal_theme::TerminalTheme::default(),
|
||
Some(true),
|
||
1,
|
||
RenderEncoding::SemanticFrame,
|
||
None,
|
||
),
|
||
);
|
||
server.foreground_client_id = Some(1);
|
||
server.sync_foreground_client_state();
|
||
server.resize_shared_runtime_to_effective_size();
|
||
|
||
assert!(server.app.state.keybind_help_max_scroll() > 0);
|
||
assert!(server.handle_server_event(ServerEvent::ClientInputEvents {
|
||
client_id: 1,
|
||
events: vec![crate::protocol::ClientInputEvent::Key {
|
||
code: crate::protocol::ClientKeyCode::Down,
|
||
modifiers: 0,
|
||
kind: crate::protocol::ClientKeyKind::Press,
|
||
}],
|
||
}));
|
||
|
||
assert_eq!(server.app.state.mode, crate::app::Mode::KeybindHelp);
|
||
assert_eq!(server.app.state.keybind_help.scroll, 1);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn split_default_background_response_updates_theme_without_forwarding_tail() {
|
||
let mut server = test_headless_server();
|
||
let mut workspace = crate::workspace::Workspace::test_new("test");
|
||
let focused = workspace.focused_pane_id().unwrap();
|
||
let (runtime, mut rx) =
|
||
crate::terminal::TerminalRuntime::test_with_channel_capacity(80, 24, 1);
|
||
workspace.tabs[0].runtimes.insert(focused, runtime);
|
||
server.app.state.workspaces = vec![workspace];
|
||
server.app.state.active = Some(0);
|
||
server.app.state.selected = 0;
|
||
server.app.state.mode = crate::app::Mode::Terminal;
|
||
server.clients.insert(
|
||
1,
|
||
ClientConnection::new(
|
||
(80, 24),
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
crate::terminal_theme::TerminalTheme::default(),
|
||
Some(true),
|
||
1,
|
||
RenderEncoding::SemanticFrame,
|
||
None,
|
||
),
|
||
);
|
||
server.foreground_client_id = Some(1);
|
||
server.sync_foreground_client_state();
|
||
|
||
let _ = server.handle_server_event(ServerEvent::ClientInput {
|
||
client_id: 1,
|
||
data: b"\x1b]".to_vec(),
|
||
});
|
||
assert!(rx.try_recv().is_err());
|
||
|
||
assert!(server.handle_server_event(ServerEvent::ClientInput {
|
||
client_id: 1,
|
||
data: b"11;#123456\x07".to_vec(),
|
||
}));
|
||
|
||
assert!(rx.try_recv().is_err());
|
||
assert_eq!(
|
||
server.clients[&1].host_terminal_theme.background,
|
||
Some(crate::terminal_theme::RgbColor {
|
||
r: 0x12,
|
||
g: 0x34,
|
||
b: 0x56,
|
||
})
|
||
);
|
||
assert_eq!(
|
||
server.app.state.host_terminal_theme.background,
|
||
Some(crate::terminal_theme::RgbColor {
|
||
r: 0x12,
|
||
g: 0x34,
|
||
b: 0x56,
|
||
})
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn render_and_stream_uses_each_client_terminal_size() {
|
||
let mut server = test_headless_server();
|
||
server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("test")];
|
||
server.app.state.active = Some(0);
|
||
server.app.state.selected = 0;
|
||
server.app.state.mode = crate::app::Mode::Terminal;
|
||
|
||
let (desktop_tx, _desktop_control_rx, desktop_rx) = test_client_writer();
|
||
let (phone_tx, _phone_control_rx, phone_rx) = test_client_writer();
|
||
|
||
server.clients.insert(
|
||
1,
|
||
ClientConnection::new(
|
||
(120, 40),
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
crate::terminal_theme::TerminalTheme::default(),
|
||
None,
|
||
1,
|
||
RenderEncoding::SemanticFrame,
|
||
Some(desktop_tx),
|
||
),
|
||
);
|
||
server.clients.insert(
|
||
2,
|
||
ClientConnection::new(
|
||
(80, 24),
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
crate::terminal_theme::TerminalTheme::default(),
|
||
None,
|
||
2,
|
||
RenderEncoding::SemanticFrame,
|
||
Some(phone_tx),
|
||
),
|
||
);
|
||
server.foreground_client_id = Some(1);
|
||
server.sync_foreground_client_state();
|
||
server.resize_shared_runtime_to_effective_size();
|
||
|
||
server.render_and_stream();
|
||
|
||
let desktop_frame = read_server_frame(desktop_rx.recv().expect("desktop frame"));
|
||
let phone_frame = read_server_frame(phone_rx.recv().expect("phone frame"));
|
||
|
||
assert_eq!((desktop_frame.width, desktop_frame.height), (120, 40));
|
||
assert_eq!((phone_frame.width, phone_frame.height), (80, 24));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn resize_shared_runtime_resizes_background_tabs() {
|
||
let mut server = test_headless_server();
|
||
let mut workspace = crate::workspace::Workspace::test_new("test");
|
||
let background_tab = workspace.test_add_tab(Some("background"));
|
||
let active_pane = workspace.tabs[0].root_pane;
|
||
let background_pane = workspace.tabs[background_tab].root_pane;
|
||
workspace.tabs[0].runtimes.insert(
|
||
active_pane,
|
||
crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""),
|
||
);
|
||
workspace.tabs[background_tab].runtimes.insert(
|
||
background_pane,
|
||
crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""),
|
||
);
|
||
server.app.state.workspaces = vec![workspace];
|
||
server.app.state.active = Some(0);
|
||
server.app.state.selected = 0;
|
||
server.app.state.mode = crate::app::Mode::Terminal;
|
||
|
||
server.clients.insert(
|
||
1,
|
||
ClientConnection::new(
|
||
(120, 40),
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
crate::terminal_theme::TerminalTheme::default(),
|
||
None,
|
||
1,
|
||
RenderEncoding::SemanticFrame,
|
||
None,
|
||
),
|
||
);
|
||
server.foreground_client_id = Some(1);
|
||
server.sync_foreground_client_state();
|
||
server.resize_shared_runtime_to_effective_size();
|
||
|
||
let terminal_area = server.app.state.view.terminal_area;
|
||
let expected = (terminal_area.height, terminal_area.width.saturating_sub(1));
|
||
assert_eq!(
|
||
server
|
||
.app
|
||
.state
|
||
.runtime_for_pane(&server.app.terminal_runtimes, active_pane)
|
||
.unwrap()
|
||
.current_size(),
|
||
expected
|
||
);
|
||
assert_eq!(
|
||
server
|
||
.app
|
||
.state
|
||
.runtime_for_pane(&server.app.terminal_runtimes, background_pane)
|
||
.unwrap()
|
||
.current_size(),
|
||
expected
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn terminal_attach_disconnect_restores_app_pane_size() {
|
||
let rt = tokio::runtime::Builder::new_current_thread()
|
||
.enable_all()
|
||
.build()
|
||
.expect("test runtime");
|
||
let _runtime_guard = rt.enter();
|
||
let mut server = test_headless_server();
|
||
let workspace = crate::workspace::Workspace::test_new("test");
|
||
let pane_id = workspace.tabs[0].root_pane;
|
||
let terminal_id = workspace.terminal_id(pane_id).expect("terminal id").clone();
|
||
let terminal_id_string = terminal_id.to_string();
|
||
server.app.state.workspaces = vec![workspace];
|
||
server.app.state.ensure_test_terminals();
|
||
server.app.state.active = Some(0);
|
||
server.app.state.selected = 0;
|
||
server.app.state.mode = crate::app::Mode::Terminal;
|
||
server.app.terminal_runtimes.insert(
|
||
terminal_id.clone(),
|
||
crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""),
|
||
);
|
||
server.clients.insert(
|
||
1,
|
||
ClientConnection::new(
|
||
(120, 40),
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
crate::terminal_theme::TerminalTheme::default(),
|
||
None,
|
||
1,
|
||
RenderEncoding::SemanticFrame,
|
||
None,
|
||
),
|
||
);
|
||
server.foreground_client_id = Some(1);
|
||
server.sync_foreground_client_state();
|
||
server.resize_shared_runtime_to_effective_size();
|
||
let expected_app_size = server
|
||
.app
|
||
.terminal_runtimes
|
||
.get(&terminal_id)
|
||
.expect("runtime")
|
||
.current_size();
|
||
assert_ne!(expected_app_size, (24, 80));
|
||
|
||
let (writer, _control_rx, _render_rx) = test_client_writer();
|
||
assert!(server.handle_server_event(ServerEvent::ClientConnected {
|
||
client_id: 2,
|
||
cols: 80,
|
||
rows: 24,
|
||
cell_width_px: 0,
|
||
cell_height_px: 0,
|
||
render_encoding: RenderEncoding::TerminalAnsi,
|
||
keybindings: None,
|
||
direct_attach_requested: true,
|
||
writer,
|
||
}));
|
||
assert!(
|
||
server.handle_server_event(ServerEvent::ClientAttachTerminal {
|
||
client_id: 2,
|
||
terminal_id: terminal_id_string.clone(),
|
||
takeover: false,
|
||
})
|
||
);
|
||
assert_eq!(server.foreground_client_id, Some(1));
|
||
assert!(server
|
||
.app
|
||
.state
|
||
.direct_attach_resize_locks
|
||
.contains(&terminal_id));
|
||
assert_eq!(
|
||
server
|
||
.app
|
||
.terminal_runtimes
|
||
.get(&terminal_id)
|
||
.expect("runtime")
|
||
.current_size(),
|
||
(24, 80)
|
||
);
|
||
|
||
assert!(server.handle_server_event(ServerEvent::ClientDisconnected { client_id: 2 }));
|
||
|
||
assert!(!server
|
||
.app
|
||
.state
|
||
.direct_attach_resize_locks
|
||
.contains(&terminal_id));
|
||
assert_eq!(
|
||
server
|
||
.app
|
||
.terminal_runtimes
|
||
.get(&terminal_id)
|
||
.expect("runtime")
|
||
.current_size(),
|
||
expected_app_size
|
||
);
|
||
drop(server);
|
||
drop(_runtime_guard);
|
||
rt.shutdown_timeout(Duration::from_millis(100));
|
||
}
|
||
|
||
#[test]
|
||
fn render_and_stream_sends_terminal_frame_for_terminal_ansi_client() {
|
||
let mut server = test_headless_server();
|
||
let (client_tx, _client_control_rx, client_rx) = test_client_writer();
|
||
|
||
server.clients.insert(
|
||
1,
|
||
ClientConnection::new(
|
||
(80, 24),
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
crate::terminal_theme::TerminalTheme::default(),
|
||
None,
|
||
1,
|
||
RenderEncoding::TerminalAnsi,
|
||
Some(client_tx),
|
||
),
|
||
);
|
||
server.foreground_client_id = Some(1);
|
||
|
||
server.render_and_stream();
|
||
|
||
match read_server_message(
|
||
client_rx
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.expect("terminal frame"),
|
||
) {
|
||
ServerMessage::Terminal(frame) => {
|
||
assert_eq!(frame.seq, 1);
|
||
assert_eq!((frame.width, frame.height), (80, 24));
|
||
assert!(frame.full);
|
||
assert!(!frame.bytes.is_empty());
|
||
}
|
||
other => panic!("expected terminal frame, got {other:?}"),
|
||
}
|
||
assert_eq!(
|
||
server
|
||
.clients
|
||
.get(&1)
|
||
.unwrap()
|
||
.render_state
|
||
.terminal_seq()
|
||
.unwrap(),
|
||
1
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn terminal_ansi_input_does_not_reset_blit_baseline() {
|
||
let mut server = test_headless_server();
|
||
let (client_tx, _client_control_rx, client_rx) = test_client_writer();
|
||
|
||
server.clients.insert(
|
||
1,
|
||
ClientConnection::new(
|
||
(80, 24),
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
crate::terminal_theme::TerminalTheme::default(),
|
||
None,
|
||
1,
|
||
RenderEncoding::TerminalAnsi,
|
||
Some(client_tx),
|
||
),
|
||
);
|
||
server.foreground_client_id = Some(1);
|
||
|
||
server.render_and_stream();
|
||
let _ = client_rx
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.expect("initial terminal frame");
|
||
assert_eq!(
|
||
server
|
||
.clients
|
||
.get(&1)
|
||
.unwrap()
|
||
.render_state
|
||
.terminal_seq()
|
||
.unwrap(),
|
||
1
|
||
);
|
||
|
||
assert!(!server.handle_server_event(ServerEvent::ClientInput {
|
||
client_id: 1,
|
||
data: Vec::new(),
|
||
}));
|
||
server.render_and_stream();
|
||
|
||
assert_eq!(
|
||
server
|
||
.clients
|
||
.get(&1)
|
||
.unwrap()
|
||
.render_state
|
||
.terminal_seq()
|
||
.unwrap(),
|
||
1
|
||
);
|
||
assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn outer_focus_gained_forces_terminal_ansi_full_redraw() {
|
||
let mut server = test_headless_server();
|
||
let (client_tx, _client_control_rx, client_rx) = test_client_writer();
|
||
|
||
server.clients.insert(
|
||
1,
|
||
ClientConnection::new(
|
||
(80, 24),
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
crate::terminal_theme::TerminalTheme::default(),
|
||
None,
|
||
1,
|
||
RenderEncoding::TerminalAnsi,
|
||
Some(client_tx),
|
||
),
|
||
);
|
||
server.foreground_client_id = Some(1);
|
||
|
||
server.render_and_stream();
|
||
let _ = client_rx
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.expect("initial terminal frame");
|
||
|
||
assert!(server.handle_server_event(ServerEvent::ClientInput {
|
||
client_id: 1,
|
||
data: b"\x1b[I".to_vec(),
|
||
}));
|
||
server.render_and_stream();
|
||
|
||
match read_server_message(client_rx.recv_timeout(Duration::from_millis(100)).unwrap()) {
|
||
ServerMessage::Terminal(frame) => {
|
||
assert_eq!(frame.seq, 2);
|
||
assert!(frame.full);
|
||
}
|
||
other => panic!("expected terminal frame, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn outer_focus_gained_client_render_pending_survives_semantic_render_queue_full() {
|
||
let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa");
|
||
|
||
server.render_and_stream();
|
||
let _ = client_rx
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.expect("initial semantic frame");
|
||
|
||
let queued = HeadlessServer::frame_server_message(&ServerMessage::ReloadSoundConfig)
|
||
.expect("serialize dummy message");
|
||
server
|
||
.clients
|
||
.get(&1)
|
||
.unwrap()
|
||
.writer
|
||
.as_ref()
|
||
.unwrap()
|
||
.render
|
||
.try_send(queued)
|
||
.expect("pre-fill render queue");
|
||
|
||
assert!(server.handle_server_event(ServerEvent::ClientInput {
|
||
client_id: 1,
|
||
data: b"\x1b[I".to_vec(),
|
||
}));
|
||
assert!(server.clients.get(&1).unwrap().render_pending);
|
||
|
||
server.render_and_stream();
|
||
|
||
assert!(server.clients.get(&1).unwrap().render_pending);
|
||
assert!(matches!(
|
||
read_server_message(client_rx.recv_timeout(Duration::from_millis(100)).unwrap()),
|
||
ServerMessage::ReloadSoundConfig
|
||
));
|
||
|
||
let runtime = server
|
||
.app
|
||
.state
|
||
.runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id)
|
||
.expect("runtime");
|
||
runtime.test_process_pty_bytes(b"\rZ");
|
||
|
||
assert!(!server.render_retained_pty_update_and_stream());
|
||
assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err());
|
||
|
||
assert!(server.handle_server_event(ServerEvent::ClientWriterDrained { client_id: 1 }));
|
||
server.render_and_stream();
|
||
|
||
assert!(!server.clients.get(&1).unwrap().render_pending);
|
||
assert!(matches!(
|
||
read_server_message(client_rx.recv_timeout(Duration::from_millis(100)).unwrap()),
|
||
ServerMessage::Frame(_)
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn outer_focus_gained_does_not_force_terminal_ansi_full_redraw_when_disabled() {
|
||
let mut server = test_headless_server();
|
||
server.app.state.redraw_on_focus_gained = false;
|
||
let (client_tx, _client_control_rx, client_rx) = test_client_writer();
|
||
|
||
server.clients.insert(
|
||
1,
|
||
ClientConnection::new(
|
||
(80, 24),
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
crate::terminal_theme::TerminalTheme::default(),
|
||
None,
|
||
1,
|
||
RenderEncoding::TerminalAnsi,
|
||
Some(client_tx),
|
||
),
|
||
);
|
||
server.foreground_client_id = Some(1);
|
||
|
||
server.render_and_stream();
|
||
let _ = client_rx
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.expect("initial terminal frame");
|
||
|
||
server.handle_server_event(ServerEvent::ClientInput {
|
||
client_id: 1,
|
||
data: b"\x1b[I".to_vec(),
|
||
});
|
||
server.render_and_stream();
|
||
|
||
assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err());
|
||
assert_eq!(server.clients[&1].outer_terminal_focus, Some(true));
|
||
assert_eq!(server.app.state.outer_terminal_focus, Some(true));
|
||
assert_eq!(
|
||
server
|
||
.clients
|
||
.get(&1)
|
||
.unwrap()
|
||
.render_state
|
||
.terminal_seq()
|
||
.unwrap(),
|
||
1
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn outer_focus_gained_does_not_mark_semantic_render_pending_when_disabled() {
|
||
let mut server = test_headless_server();
|
||
server.app.state.redraw_on_focus_gained = false;
|
||
let (client_tx, _client_control_rx, _client_rx) = test_client_writer();
|
||
|
||
server.clients.insert(
|
||
1,
|
||
ClientConnection::new(
|
||
(80, 24),
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
crate::terminal_theme::TerminalTheme::default(),
|
||
None,
|
||
1,
|
||
RenderEncoding::SemanticFrame,
|
||
Some(client_tx),
|
||
),
|
||
);
|
||
server.foreground_client_id = Some(1);
|
||
|
||
assert!(server.handle_server_event(ServerEvent::ClientInput {
|
||
client_id: 1,
|
||
data: b"\x1b[I".to_vec(),
|
||
}));
|
||
|
||
assert!(!server.clients.get(&1).unwrap().render_pending);
|
||
assert!(!server.app.full_redraw_pending);
|
||
assert_eq!(server.clients[&1].outer_terminal_focus, Some(true));
|
||
assert_eq!(server.app.state.outer_terminal_focus, Some(true));
|
||
}
|
||
|
||
#[test]
|
||
fn full_render_queue_does_not_advance_terminal_ansi_baseline() {
|
||
let mut server = test_headless_server();
|
||
let (client_tx, _client_control_rx, client_rx) = test_client_writer();
|
||
let queued = HeadlessServer::frame_server_message(&ServerMessage::ReloadSoundConfig)
|
||
.expect("serialize dummy message");
|
||
client_tx
|
||
.render
|
||
.try_send(queued)
|
||
.expect("pre-fill render queue");
|
||
|
||
server.clients.insert(
|
||
1,
|
||
ClientConnection::new(
|
||
(80, 24),
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
crate::terminal_theme::TerminalTheme::default(),
|
||
None,
|
||
1,
|
||
RenderEncoding::TerminalAnsi,
|
||
Some(client_tx),
|
||
),
|
||
);
|
||
server.foreground_client_id = Some(1);
|
||
|
||
server.render_and_stream();
|
||
|
||
assert_eq!(
|
||
server
|
||
.clients
|
||
.get(&1)
|
||
.unwrap()
|
||
.render_state
|
||
.terminal_seq()
|
||
.unwrap(),
|
||
0
|
||
);
|
||
assert!(matches!(
|
||
read_server_message(client_rx.recv_timeout(Duration::from_millis(100)).unwrap()),
|
||
ServerMessage::ReloadSoundConfig
|
||
));
|
||
assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn writer_drained_retries_pending_terminal_ansi_render() {
|
||
let mut server = test_headless_server();
|
||
let (client_tx, _client_control_rx, client_rx) = test_client_writer();
|
||
let queued = HeadlessServer::frame_server_message(&ServerMessage::ReloadSoundConfig)
|
||
.expect("serialize dummy message");
|
||
client_tx
|
||
.render
|
||
.try_send(queued)
|
||
.expect("pre-fill render queue");
|
||
|
||
server.clients.insert(
|
||
1,
|
||
ClientConnection::new(
|
||
(80, 24),
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
crate::terminal_theme::TerminalTheme::default(),
|
||
None,
|
||
1,
|
||
RenderEncoding::TerminalAnsi,
|
||
Some(client_tx),
|
||
),
|
||
);
|
||
server.foreground_client_id = Some(1);
|
||
|
||
server.render_and_stream();
|
||
assert!(server.clients.get(&1).unwrap().render_pending);
|
||
assert!(matches!(
|
||
read_server_message(client_rx.recv_timeout(Duration::from_millis(100)).unwrap()),
|
||
ServerMessage::ReloadSoundConfig
|
||
));
|
||
|
||
assert!(server.handle_server_event(ServerEvent::ClientWriterDrained { client_id: 1 }));
|
||
server.render_and_stream();
|
||
|
||
match read_server_message(client_rx.recv_timeout(Duration::from_millis(100)).unwrap()) {
|
||
ServerMessage::Terminal(frame) => assert_eq!(frame.seq, 1),
|
||
other => panic!("expected terminal frame, got {other:?}"),
|
||
}
|
||
assert_eq!(
|
||
server
|
||
.clients
|
||
.get(&1)
|
||
.unwrap()
|
||
.render_state
|
||
.terminal_seq()
|
||
.unwrap(),
|
||
1
|
||
);
|
||
assert!(!server.clients.get(&1).unwrap().render_pending);
|
||
}
|
||
|
||
#[test]
|
||
fn render_and_stream_skips_identical_frame_sends() {
|
||
let mut server = test_headless_server();
|
||
server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("test")];
|
||
server.app.state.active = Some(0);
|
||
server.app.state.selected = 0;
|
||
server.app.state.mode = crate::app::Mode::Terminal;
|
||
|
||
let (client_tx, _client_control_rx, client_rx) = test_client_writer();
|
||
|
||
server.clients.insert(
|
||
1,
|
||
ClientConnection::new(
|
||
(80, 24),
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
crate::terminal_theme::TerminalTheme::default(),
|
||
None,
|
||
1,
|
||
RenderEncoding::SemanticFrame,
|
||
Some(client_tx),
|
||
),
|
||
);
|
||
server.foreground_client_id = Some(1);
|
||
server.sync_foreground_client_state();
|
||
server.resize_shared_runtime_to_effective_size();
|
||
|
||
server.render_and_stream();
|
||
let first = client_rx.recv_timeout(Duration::from_millis(100));
|
||
assert!(first.is_ok(), "expected first frame to be sent");
|
||
|
||
server.render_and_stream();
|
||
assert!(
|
||
client_rx.recv_timeout(Duration::from_millis(50)).is_err(),
|
||
"identical frame should not be sent twice"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn retained_pty_update_streams_dirty_row_from_last_frame() {
|
||
let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa");
|
||
server.render_and_stream();
|
||
let first = read_server_frame(
|
||
client_rx
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.expect("initial frame"),
|
||
);
|
||
assert!(first.cells.iter().any(|cell| cell.symbol == "a"));
|
||
|
||
let runtime = server
|
||
.app
|
||
.state
|
||
.runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id)
|
||
.expect("runtime");
|
||
runtime.test_process_pty_bytes(b"\rZ");
|
||
|
||
assert!(server.render_retained_pty_update_and_stream());
|
||
let patched = read_server_frame(
|
||
client_rx
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.expect("retained frame"),
|
||
);
|
||
assert!(patched.cells.iter().any(|cell| cell.symbol == "Z"));
|
||
assert_eq!((patched.width, patched.height), (80, 24));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn retained_pty_update_declines_while_toast_is_visible() {
|
||
let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa");
|
||
server.app.state.toast = Some(crate::app::state::ToastNotification {
|
||
kind: crate::app::state::ToastKind::NeedsAttention,
|
||
title: "pi needs attention".to_owned(),
|
||
context: "background · 2".to_owned(),
|
||
position: None,
|
||
target: None,
|
||
});
|
||
server.render_and_stream();
|
||
let initial = read_server_frame(
|
||
client_rx
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.expect("initial frame"),
|
||
);
|
||
assert!(
|
||
frame_text(&initial).contains("pi needs attention"),
|
||
"expected initial full frame to include toast text"
|
||
);
|
||
|
||
let toast_row = server.app.state.view.toast_hit_area.y;
|
||
let inner_rect = server.app.state.view.pane_infos[0].inner_rect;
|
||
let pane_row = toast_row
|
||
.checked_sub(inner_rect.y)
|
||
.expect("toast should overlap the pane")
|
||
+ 1;
|
||
assert!(pane_row <= inner_rect.height);
|
||
let runtime = server
|
||
.app
|
||
.state
|
||
.runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id)
|
||
.expect("runtime");
|
||
runtime.test_process_pty_bytes(format!("\x1b[{pane_row};1Hzzzz").as_bytes());
|
||
|
||
assert!(!server.render_retained_pty_update_and_stream());
|
||
assert!(
|
||
client_rx.recv_timeout(Duration::from_millis(50)).is_err(),
|
||
"retained path should not stream a frame that can overwrite toast cells"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn retained_pty_update_matches_full_render_frame() {
|
||
let initial = b"\x1b[6 qleft \xe4\xb8\xad";
|
||
let update = b"\r\x1b[44mZ\x1b[0m";
|
||
let (mut retained_server, retained_rx, retained_pane_id) = retained_test_server(initial);
|
||
let (mut full_server, full_rx, full_pane_id) = retained_test_server(initial);
|
||
|
||
retained_server.render_and_stream();
|
||
let _ = retained_rx
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.expect("initial retained baseline");
|
||
full_server.render_and_stream();
|
||
let _ = full_rx
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.expect("initial full baseline");
|
||
|
||
retained_server
|
||
.app
|
||
.state
|
||
.runtime_for_pane_in_workspace(
|
||
&retained_server.app.terminal_runtimes,
|
||
0,
|
||
retained_pane_id,
|
||
)
|
||
.expect("retained runtime")
|
||
.test_process_pty_bytes(update);
|
||
full_server
|
||
.app
|
||
.state
|
||
.runtime_for_pane_in_workspace(&full_server.app.terminal_runtimes, 0, full_pane_id)
|
||
.expect("full runtime")
|
||
.test_process_pty_bytes(update);
|
||
|
||
assert!(retained_server.render_retained_pty_update_and_stream());
|
||
full_server.render_and_stream();
|
||
|
||
let retained_frame = read_server_frame(
|
||
retained_rx
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.expect("retained frame"),
|
||
);
|
||
let full_frame = read_server_frame(
|
||
full_rx
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.expect("full frame"),
|
||
);
|
||
assert_frame_data_eq(&retained_frame, &full_frame);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn retained_pty_update_streams_cursor_only_change() {
|
||
let initial = b"abcd";
|
||
let update = b"\x1b[D";
|
||
let (mut retained_server, retained_rx, retained_pane_id) = retained_test_server(initial);
|
||
let (mut full_server, full_rx, full_pane_id) = retained_test_server(initial);
|
||
|
||
retained_server.render_and_stream();
|
||
let _ = retained_rx
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.expect("initial retained baseline");
|
||
full_server.render_and_stream();
|
||
let _ = full_rx
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.expect("initial full baseline");
|
||
|
||
retained_server
|
||
.app
|
||
.state
|
||
.runtime_for_pane_in_workspace(
|
||
&retained_server.app.terminal_runtimes,
|
||
0,
|
||
retained_pane_id,
|
||
)
|
||
.expect("retained runtime")
|
||
.test_process_pty_bytes(update);
|
||
full_server
|
||
.app
|
||
.state
|
||
.runtime_for_pane_in_workspace(&full_server.app.terminal_runtimes, 0, full_pane_id)
|
||
.expect("full runtime")
|
||
.test_process_pty_bytes(update);
|
||
|
||
assert!(retained_server.render_retained_pty_update_and_stream());
|
||
full_server.render_and_stream();
|
||
|
||
let retained_frame = read_server_frame(
|
||
retained_rx
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.expect("retained cursor frame"),
|
||
);
|
||
let full_frame = read_server_frame(
|
||
full_rx
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.expect("full cursor frame"),
|
||
);
|
||
assert_frame_data_eq(&retained_frame, &full_frame);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn retained_pty_update_declines_unsafe_mode_without_consuming_dirty_rows() {
|
||
let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa");
|
||
server.render_and_stream();
|
||
let _ = client_rx
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.expect("initial frame");
|
||
|
||
let runtime = server
|
||
.app
|
||
.state
|
||
.runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id)
|
||
.expect("runtime");
|
||
runtime.test_process_pty_bytes(b"\rZ");
|
||
|
||
server.app.state.mode = crate::app::Mode::Navigate;
|
||
assert!(!server.render_retained_pty_update_and_stream());
|
||
assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err());
|
||
|
||
server.app.state.mode = crate::app::Mode::Terminal;
|
||
assert!(server.render_retained_pty_update_and_stream());
|
||
let patched = read_server_frame(
|
||
client_rx
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.expect("retained frame after safe mode"),
|
||
);
|
||
assert!(patched.cells.iter().any(|cell| cell.symbol == "Z"));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn headless_full_render_clears_full_redraw_pending_for_future_retained_updates() {
|
||
let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa");
|
||
server.app.full_redraw_pending = true;
|
||
|
||
server.render_and_stream();
|
||
let _ = client_rx
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.expect("full redraw frame");
|
||
assert!(!server.app.full_redraw_pending);
|
||
|
||
let runtime = server
|
||
.app
|
||
.state
|
||
.runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id)
|
||
.expect("runtime");
|
||
runtime.test_process_pty_bytes(b"\rZ");
|
||
|
||
assert!(server.render_retained_pty_update_and_stream());
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn retained_pty_update_declines_when_patch_would_stale_hyperlinks() {
|
||
let (mut server, client_rx, pane_id) = retained_test_server(b"link");
|
||
server.render_and_stream();
|
||
let _ = client_rx
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.expect("initial frame");
|
||
let inner_rect = server.app.state.view.pane_infos[0].inner_rect;
|
||
let client = server.clients.get_mut(&1).unwrap();
|
||
let mut frame = client.render_state.last_frame().unwrap().clone();
|
||
frame.hyperlinks = vec!["https://example.com".to_owned()];
|
||
let hyperlink_idx =
|
||
usize::from(inner_rect.y) * usize::from(frame.width) + usize::from(inner_rect.x);
|
||
frame.cells[hyperlink_idx].hyperlink = Some(0);
|
||
let prepared = client
|
||
.render_state
|
||
.prepare_frame(frame)
|
||
.expect("hyperlink frame differs");
|
||
client.render_state.commit_sent_frame(prepared);
|
||
|
||
let runtime = server
|
||
.app
|
||
.state
|
||
.runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id)
|
||
.expect("runtime");
|
||
runtime.test_process_pty_bytes(b"\rplain");
|
||
|
||
assert!(!server.render_retained_pty_update_and_stream());
|
||
assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err());
|
||
|
||
server.render_and_stream();
|
||
let full = read_server_frame(
|
||
client_rx
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.expect("full frame after hyperlink overwrite"),
|
||
);
|
||
assert!(
|
||
full.cells.iter().all(|cell| cell.hyperlink.is_none()),
|
||
"full render should clear overwritten hyperlink cells"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn retained_pty_update_allows_kitty_enabled_empty_graphics_cache() {
|
||
let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa");
|
||
server.app.state.kitty_graphics_enabled = true;
|
||
server.clients.get_mut(&1).unwrap().cell_size = crate::kitty_graphics::HostCellSize {
|
||
width_px: 10,
|
||
height_px: 20,
|
||
};
|
||
|
||
server.render_and_stream();
|
||
let _ = client_rx
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.expect("initial frame");
|
||
|
||
let runtime = server
|
||
.app
|
||
.state
|
||
.runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id)
|
||
.expect("runtime");
|
||
runtime.test_process_pty_bytes(b"\rZ");
|
||
|
||
assert!(server.render_retained_pty_update_and_stream());
|
||
let retained = read_server_frame(
|
||
client_rx
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.expect("retained frame with kitty enabled"),
|
||
);
|
||
assert!(retained.cells.iter().any(|cell| cell.symbol == "Z"));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn retained_pty_update_declines_when_graphics_cache_has_content() {
|
||
let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa");
|
||
server.app.state.kitty_graphics_enabled = true;
|
||
let client = server.clients.get_mut(&1).unwrap();
|
||
client.cell_size = crate::kitty_graphics::HostCellSize {
|
||
width_px: 10,
|
||
height_px: 20,
|
||
};
|
||
|
||
server.render_and_stream();
|
||
let _ = client_rx
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.expect("initial frame");
|
||
server
|
||
.clients
|
||
.get_mut(&1)
|
||
.unwrap()
|
||
.graphics_cache
|
||
.test_mark_non_empty();
|
||
|
||
let runtime = server
|
||
.app
|
||
.state
|
||
.runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id)
|
||
.expect("runtime");
|
||
runtime.test_process_pty_bytes(b"\rZ");
|
||
|
||
assert!(!server.render_retained_pty_update_and_stream());
|
||
assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err());
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn full_redraw_pending_survives_full_render_queue_full() {
|
||
let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa");
|
||
let queued = HeadlessServer::frame_server_message(&ServerMessage::ReloadSoundConfig)
|
||
.expect("serialize dummy message");
|
||
server
|
||
.clients
|
||
.get(&1)
|
||
.unwrap()
|
||
.writer
|
||
.as_ref()
|
||
.unwrap()
|
||
.render
|
||
.try_send(queued)
|
||
.expect("pre-fill render queue");
|
||
server.app.full_redraw_pending = true;
|
||
|
||
server.render_and_stream();
|
||
|
||
assert!(server.app.full_redraw_pending);
|
||
assert!(server.clients.get(&1).unwrap().render_pending);
|
||
assert!(matches!(
|
||
read_server_message(client_rx.recv_timeout(Duration::from_millis(100)).unwrap()),
|
||
ServerMessage::ReloadSoundConfig
|
||
));
|
||
|
||
let runtime = server
|
||
.app
|
||
.state
|
||
.runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id)
|
||
.expect("runtime");
|
||
runtime.test_process_pty_bytes(b"\rZ");
|
||
|
||
assert!(!server.render_retained_pty_update_and_stream());
|
||
assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn client_config_reload_request_refreshes_attached_clients() {
|
||
let mut server = test_headless_server();
|
||
let (client_tx, client_control_rx, _client_rx) = test_client_writer();
|
||
|
||
server.clients.insert(
|
||
1,
|
||
ClientConnection::new(
|
||
(80, 24),
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
crate::terminal_theme::TerminalTheme::default(),
|
||
None,
|
||
1,
|
||
RenderEncoding::SemanticFrame,
|
||
Some(client_tx),
|
||
),
|
||
);
|
||
server.app.state.request_client_config_reload = true;
|
||
|
||
server.drain_client_config_reload_request();
|
||
|
||
match read_server_message(
|
||
client_control_rx
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.expect("client config reload message"),
|
||
) {
|
||
ServerMessage::ReloadSoundConfig => {}
|
||
other => panic!("expected ReloadSoundConfig, got {other:?}"),
|
||
}
|
||
assert!(!server.app.state.request_client_config_reload);
|
||
}
|
||
|
||
#[test]
|
||
fn clipboard_write_targets_foreground_client_only() {
|
||
let mut server = test_headless_server();
|
||
let (background_tx, background_control_rx, _background_rx) = test_client_writer();
|
||
let (foreground_tx, foreground_control_rx, _foreground_rx) = test_client_writer();
|
||
|
||
server.clients.insert(
|
||
1,
|
||
ClientConnection::new(
|
||
(120, 40),
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
crate::terminal_theme::TerminalTheme::default(),
|
||
None,
|
||
1,
|
||
RenderEncoding::SemanticFrame,
|
||
Some(background_tx),
|
||
),
|
||
);
|
||
server.clients.insert(
|
||
2,
|
||
ClientConnection::new(
|
||
(80, 24),
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
crate::terminal_theme::TerminalTheme::default(),
|
||
None,
|
||
2,
|
||
RenderEncoding::SemanticFrame,
|
||
Some(foreground_tx),
|
||
),
|
||
);
|
||
server.foreground_client_id = Some(2);
|
||
server.sync_foreground_client_state();
|
||
|
||
let changed = server.handle_internal_event_with_forwarding(AppEvent::ClipboardWrite {
|
||
content: b"test".to_vec(),
|
||
});
|
||
|
||
assert!(changed);
|
||
assert_eq!(
|
||
server
|
||
.app
|
||
.state
|
||
.copy_feedback
|
||
.as_ref()
|
||
.map(|feedback| feedback.message.as_str()),
|
||
Some("copied to clipboard")
|
||
);
|
||
match read_server_message(
|
||
foreground_control_rx
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.expect("foreground clipboard message"),
|
||
) {
|
||
ServerMessage::Clipboard { data } => assert_eq!(data, "dGVzdA=="),
|
||
other => panic!("expected clipboard message, got {other:?}"),
|
||
}
|
||
assert!(
|
||
background_control_rx
|
||
.recv_timeout(Duration::from_millis(50))
|
||
.is_err(),
|
||
"background client should not receive clipboard writes"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn clipboard_write_without_foreground_client_does_not_show_feedback() {
|
||
let mut server = test_headless_server();
|
||
server.foreground_client_id = None;
|
||
|
||
let changed = server.handle_internal_event_with_forwarding(AppEvent::ClipboardWrite {
|
||
content: b"test".to_vec(),
|
||
});
|
||
|
||
assert!(changed);
|
||
assert!(
|
||
server.app.state.copy_feedback.is_none(),
|
||
"clipboard feedback should only show when a foreground client can receive the write"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn clipboard_write_failed_foreground_send_does_not_show_feedback() {
|
||
let mut server = test_headless_server();
|
||
let (foreground_tx, foreground_control_rx, _foreground_rx) = test_client_writer();
|
||
drop(foreground_control_rx);
|
||
|
||
server.clients.insert(
|
||
1,
|
||
ClientConnection::new(
|
||
(80, 24),
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
crate::terminal_theme::TerminalTheme::default(),
|
||
None,
|
||
1,
|
||
RenderEncoding::SemanticFrame,
|
||
Some(foreground_tx),
|
||
),
|
||
);
|
||
server.foreground_client_id = Some(1);
|
||
|
||
let changed = server.handle_internal_event_with_forwarding(AppEvent::ClipboardWrite {
|
||
content: b"test".to_vec(),
|
||
});
|
||
|
||
assert!(changed);
|
||
assert!(
|
||
server.app.state.copy_feedback.is_none(),
|
||
"clipboard feedback should only show after the foreground client receives the write"
|
||
);
|
||
assert!(
|
||
!server.clients.contains_key(&1),
|
||
"failed targeted send should remove the broken foreground client"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn client_local_notifications_target_foreground_client_only() {
|
||
let mut server = test_headless_server();
|
||
let (background_tx, background_control_rx, _background_rx) = test_client_writer();
|
||
let (foreground_tx, foreground_control_rx, _foreground_rx) = test_client_writer();
|
||
|
||
server.clients.insert(
|
||
1,
|
||
ClientConnection::new(
|
||
(120, 40),
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
crate::terminal_theme::TerminalTheme::default(),
|
||
None,
|
||
1,
|
||
RenderEncoding::SemanticFrame,
|
||
Some(background_tx),
|
||
),
|
||
);
|
||
server.clients.insert(
|
||
2,
|
||
ClientConnection::new(
|
||
(80, 24),
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
crate::terminal_theme::TerminalTheme::default(),
|
||
None,
|
||
2,
|
||
RenderEncoding::SemanticFrame,
|
||
Some(foreground_tx),
|
||
),
|
||
);
|
||
server.foreground_client_id = Some(2);
|
||
server.sync_foreground_client_state();
|
||
|
||
assert!(server.send_to_foreground_client(ServerMessage::Notify {
|
||
kind: protocol::NotifyKind::Toast,
|
||
message: "pi finished".to_string(),
|
||
body: Some("workspace 1".to_string()),
|
||
}));
|
||
|
||
match read_server_message(
|
||
foreground_control_rx
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.expect("foreground toast message"),
|
||
) {
|
||
ServerMessage::Notify {
|
||
kind,
|
||
message,
|
||
body,
|
||
} => {
|
||
assert_eq!(kind, protocol::NotifyKind::Toast);
|
||
assert_eq!(message, "pi finished");
|
||
assert_eq!(body.as_deref(), Some("workspace 1"));
|
||
}
|
||
other => panic!("expected toast notify, got {other:?}"),
|
||
}
|
||
assert!(
|
||
background_control_rx
|
||
.recv_timeout(Duration::from_millis(50))
|
||
.is_err(),
|
||
"background client should not receive client-local notifications"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn herdr_toast_delivery_keeps_toast_in_frame_without_client_notify() {
|
||
let mut server = test_headless_server();
|
||
let (client_tx, client_control_rx, _client_rx) = test_client_writer();
|
||
|
||
server.clients.insert(
|
||
1,
|
||
ClientConnection::new(
|
||
(80, 24),
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
crate::terminal_theme::TerminalTheme::default(),
|
||
None,
|
||
1,
|
||
RenderEncoding::SemanticFrame,
|
||
Some(client_tx),
|
||
),
|
||
);
|
||
server.foreground_client_id = Some(1);
|
||
server.app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr;
|
||
|
||
let changed = server.handle_internal_event_with_forwarding(AppEvent::UpdateReady {
|
||
version: "9.9.9".to_string(),
|
||
install_command: "herdr update".into(),
|
||
});
|
||
|
||
assert!(changed);
|
||
assert!(server.app.state.toast.is_some());
|
||
assert!(
|
||
client_control_rx
|
||
.recv_timeout(Duration::from_millis(50))
|
||
.is_err(),
|
||
"herdr delivery should render in-frame instead of forwarding a client-local notification"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn system_toast_delivery_forwards_system_notify_kind() {
|
||
let mut server = test_headless_server();
|
||
let (client_tx, client_control_rx, _client_rx) = test_client_writer();
|
||
|
||
server.clients.insert(
|
||
1,
|
||
ClientConnection::new(
|
||
(80, 24),
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
crate::terminal_theme::TerminalTheme::default(),
|
||
None,
|
||
1,
|
||
RenderEncoding::SemanticFrame,
|
||
Some(client_tx),
|
||
),
|
||
);
|
||
server.foreground_client_id = Some(1);
|
||
server.app.state.toast_config.delivery = crate::config::ToastDelivery::System;
|
||
|
||
let changed = server.handle_internal_event_with_forwarding(AppEvent::UpdateReady {
|
||
version: "9.9.9".to_string(),
|
||
install_command: "herdr update".into(),
|
||
});
|
||
|
||
assert!(changed);
|
||
match read_server_message(
|
||
client_control_rx
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.expect("system toast message"),
|
||
) {
|
||
ServerMessage::Notify {
|
||
kind,
|
||
message,
|
||
body,
|
||
} => {
|
||
assert_eq!(kind, protocol::NotifyKind::SystemToast);
|
||
assert_eq!(message, "v9.9.9 available");
|
||
assert_eq!(
|
||
body.as_deref(),
|
||
Some("detach, run `herdr update`, then follow its restart guidance")
|
||
);
|
||
}
|
||
other => panic!("expected system toast notify, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn notification_show_api_forwards_system_notification_to_foreground_client() {
|
||
let mut server = test_headless_server();
|
||
let (client_tx, client_control_rx, _client_rx) = test_client_writer();
|
||
|
||
server.clients.insert(
|
||
1,
|
||
ClientConnection::new(
|
||
(80, 24),
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
crate::terminal_theme::TerminalTheme::default(),
|
||
None,
|
||
1,
|
||
RenderEncoding::SemanticFrame,
|
||
Some(client_tx),
|
||
),
|
||
);
|
||
server.foreground_client_id = Some(1);
|
||
server.app.state.toast_config.delivery = crate::config::ToastDelivery::System;
|
||
|
||
let (respond_to, response_rx) = std::sync::mpsc::channel();
|
||
let changed = server.handle_api_request_with_shutdown_check(api::ApiRequestMessage {
|
||
request: api::schema::Request {
|
||
id: "notify".into(),
|
||
method: api::schema::Method::NotificationShow(
|
||
api::schema::NotificationShowParams {
|
||
title: "build failed".into(),
|
||
body: Some("api workspace".into()),
|
||
position: Some(crate::config::ToastHerdrPosition::TopLeft),
|
||
sound: api::schema::NotificationShowSound::Request,
|
||
},
|
||
),
|
||
},
|
||
respond_to,
|
||
});
|
||
|
||
assert!(changed);
|
||
let response = response_rx
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.unwrap();
|
||
let parsed: api::schema::SuccessResponse = serde_json::from_str(&response).unwrap();
|
||
assert_eq!(
|
||
parsed.result,
|
||
api::schema::ResponseResult::NotificationShow {
|
||
shown: true,
|
||
reason: api::schema::NotificationShowReason::Shown,
|
||
}
|
||
);
|
||
let first = read_server_message(
|
||
client_control_rx
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.expect("api notification message"),
|
||
);
|
||
let second = read_server_message(
|
||
client_control_rx
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.expect("api sound message"),
|
||
);
|
||
|
||
match first {
|
||
ServerMessage::Notify {
|
||
kind,
|
||
message,
|
||
body,
|
||
} => {
|
||
assert_eq!(kind, protocol::NotifyKind::SystemToast);
|
||
assert_eq!(message, "build failed");
|
||
assert_eq!(body.as_deref(), Some("api workspace"));
|
||
}
|
||
other => panic!("expected api notification, got {other:?}"),
|
||
}
|
||
match second {
|
||
ServerMessage::Notify {
|
||
kind,
|
||
message,
|
||
body,
|
||
} => {
|
||
assert_eq!(kind, protocol::NotifyKind::Sound);
|
||
assert_eq!(message, "agent attention");
|
||
assert!(body.is_none());
|
||
}
|
||
other => panic!("expected api sound, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn notification_show_api_preserves_colon_in_forwarded_title() {
|
||
let mut server = test_headless_server();
|
||
let (client_tx, client_control_rx, _client_rx) = test_client_writer();
|
||
|
||
server.clients.insert(
|
||
1,
|
||
ClientConnection::new(
|
||
(80, 24),
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
crate::terminal_theme::TerminalTheme::default(),
|
||
None,
|
||
1,
|
||
RenderEncoding::SemanticFrame,
|
||
Some(client_tx),
|
||
),
|
||
);
|
||
server.foreground_client_id = Some(1);
|
||
server.app.state.toast_config.delivery = crate::config::ToastDelivery::System;
|
||
|
||
let (respond_to, response_rx) = std::sync::mpsc::channel();
|
||
let changed = server.handle_api_request_with_shutdown_check(api::ApiRequestMessage {
|
||
request: api::schema::Request {
|
||
id: "notify".into(),
|
||
method: api::schema::Method::NotificationShow(
|
||
api::schema::NotificationShowParams {
|
||
title: "build: failed".into(),
|
||
body: Some("api workspace".into()),
|
||
position: None,
|
||
sound: api::schema::NotificationShowSound::None,
|
||
},
|
||
),
|
||
},
|
||
respond_to,
|
||
});
|
||
|
||
assert!(changed);
|
||
let response = response_rx
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.unwrap();
|
||
let parsed: api::schema::SuccessResponse = serde_json::from_str(&response).unwrap();
|
||
assert_eq!(
|
||
parsed.result,
|
||
api::schema::ResponseResult::NotificationShow {
|
||
shown: true,
|
||
reason: api::schema::NotificationShowReason::Shown,
|
||
}
|
||
);
|
||
match read_server_message(
|
||
client_control_rx
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.expect("api notification message"),
|
||
) {
|
||
ServerMessage::Notify {
|
||
kind,
|
||
message,
|
||
body,
|
||
} => {
|
||
assert_eq!(kind, protocol::NotifyKind::SystemToast);
|
||
assert_eq!(message, "build: failed");
|
||
assert_eq!(body.as_deref(), Some("api workspace"));
|
||
}
|
||
other => panic!("expected api notification, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn notification_show_api_validates_empty_title_before_disabled_delivery() {
|
||
let mut server = test_headless_server();
|
||
server.app.state.toast_config.delivery = crate::config::ToastDelivery::Off;
|
||
|
||
let (respond_to, response_rx) = std::sync::mpsc::channel();
|
||
let changed = server.handle_api_request_with_shutdown_check(api::ApiRequestMessage {
|
||
request: api::schema::Request {
|
||
id: "notify".into(),
|
||
method: api::schema::Method::NotificationShow(
|
||
api::schema::NotificationShowParams {
|
||
title: "\n\t".into(),
|
||
body: None,
|
||
position: None,
|
||
sound: api::schema::NotificationShowSound::None,
|
||
},
|
||
),
|
||
},
|
||
respond_to,
|
||
});
|
||
|
||
assert!(changed);
|
||
let response = response_rx
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.unwrap();
|
||
let parsed: api::schema::ErrorResponse = serde_json::from_str(&response).unwrap();
|
||
assert_eq!(parsed.error.code, "invalid_params");
|
||
assert_eq!(parsed.error.message, "notification title is empty");
|
||
}
|
||
|
||
#[test]
|
||
fn notification_show_api_reports_no_foreground_client() {
|
||
let mut server = test_headless_server();
|
||
server.foreground_client_id = None;
|
||
server.app.state.toast_config.delivery = crate::config::ToastDelivery::System;
|
||
|
||
let (respond_to, response_rx) = std::sync::mpsc::channel();
|
||
let changed = server.handle_api_request_with_shutdown_check(api::ApiRequestMessage {
|
||
request: api::schema::Request {
|
||
id: "notify".into(),
|
||
method: api::schema::Method::NotificationShow(
|
||
api::schema::NotificationShowParams {
|
||
title: "build failed".into(),
|
||
body: None,
|
||
position: None,
|
||
sound: api::schema::NotificationShowSound::Request,
|
||
},
|
||
),
|
||
},
|
||
respond_to,
|
||
});
|
||
|
||
assert!(changed);
|
||
let response = response_rx
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.unwrap();
|
||
let parsed: api::schema::SuccessResponse = serde_json::from_str(&response).unwrap();
|
||
assert_eq!(
|
||
parsed.result,
|
||
api::schema::ResponseResult::NotificationShow {
|
||
shown: false,
|
||
reason: api::schema::NotificationShowReason::NoForegroundClient,
|
||
}
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn notification_show_api_herdr_toast_expires_headless() {
|
||
let mut server = test_headless_server();
|
||
server.app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr;
|
||
|
||
let (respond_to, response_rx) = std::sync::mpsc::channel();
|
||
assert!(
|
||
server.handle_api_request_with_shutdown_check(api::ApiRequestMessage {
|
||
request: api::schema::Request {
|
||
id: "notify".into(),
|
||
method: api::schema::Method::NotificationShow(
|
||
api::schema::NotificationShowParams {
|
||
title: "build failed".into(),
|
||
body: None,
|
||
position: None,
|
||
sound: api::schema::NotificationShowSound::None,
|
||
},
|
||
),
|
||
},
|
||
respond_to,
|
||
})
|
||
);
|
||
|
||
let response = response_rx
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.unwrap();
|
||
let parsed: api::schema::SuccessResponse = serde_json::from_str(&response).unwrap();
|
||
assert_eq!(
|
||
parsed.result,
|
||
api::schema::ResponseResult::NotificationShow {
|
||
shown: true,
|
||
reason: api::schema::NotificationShowReason::Shown,
|
||
}
|
||
);
|
||
let deadline = server.app.toast_deadline.expect("api toast deadline");
|
||
assert!(server.handle_scheduled_tasks_headless(deadline, false));
|
||
assert!(server.app.state.toast.is_none());
|
||
assert!(server.app.toast_deadline.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn notification_show_api_forwards_sound_for_herdr_delivery() {
|
||
let mut server = test_headless_server();
|
||
let (client_tx, client_control_rx, _client_rx) = test_client_writer();
|
||
|
||
server.clients.insert(
|
||
1,
|
||
ClientConnection::new(
|
||
(80, 24),
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
crate::terminal_theme::TerminalTheme::default(),
|
||
None,
|
||
1,
|
||
RenderEncoding::SemanticFrame,
|
||
Some(client_tx),
|
||
),
|
||
);
|
||
server.foreground_client_id = Some(1);
|
||
server.app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr;
|
||
|
||
let (respond_to, response_rx) = std::sync::mpsc::channel();
|
||
assert!(
|
||
server.handle_api_request_with_shutdown_check(api::ApiRequestMessage {
|
||
request: api::schema::Request {
|
||
id: "notify".into(),
|
||
method: api::schema::Method::NotificationShow(
|
||
api::schema::NotificationShowParams {
|
||
title: "build failed".into(),
|
||
body: None,
|
||
position: None,
|
||
sound: api::schema::NotificationShowSound::Done,
|
||
},
|
||
),
|
||
},
|
||
respond_to,
|
||
})
|
||
);
|
||
|
||
let response = response_rx
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.unwrap();
|
||
let parsed: api::schema::SuccessResponse = serde_json::from_str(&response).unwrap();
|
||
assert_eq!(
|
||
parsed.result,
|
||
api::schema::ResponseResult::NotificationShow {
|
||
shown: true,
|
||
reason: api::schema::NotificationShowReason::Shown,
|
||
}
|
||
);
|
||
match read_server_message(
|
||
client_control_rx
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.expect("api sound message"),
|
||
) {
|
||
ServerMessage::Notify {
|
||
kind,
|
||
message,
|
||
body,
|
||
} => {
|
||
assert_eq!(kind, protocol::NotifyKind::Sound);
|
||
assert_eq!(message, "agent done");
|
||
assert!(body.is_none());
|
||
}
|
||
other => panic!("expected api sound, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn delayed_agent_notification_forwards_after_deadline() {
|
||
let mut server = test_headless_server();
|
||
let background = crate::workspace::Workspace::test_new("background");
|
||
let pane_id = background.tabs[0].root_pane;
|
||
let foreground = crate::workspace::Workspace::test_new("foreground");
|
||
server.app.state.workspaces = vec![background, foreground];
|
||
server.app.state.ensure_test_terminals();
|
||
server.app.state.active = Some(1);
|
||
server.app.state.selected = 1;
|
||
server.app.state.mode = crate::app::Mode::Terminal;
|
||
server.app.state.toast_config.delivery = crate::config::ToastDelivery::System;
|
||
server.app.state.toast_config.delay_seconds = 1;
|
||
|
||
let (client_tx, client_control_rx, _client_rx) = test_client_writer();
|
||
server.clients.insert(
|
||
1,
|
||
ClientConnection::new(
|
||
(80, 24),
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
crate::terminal_theme::TerminalTheme::default(),
|
||
None,
|
||
1,
|
||
RenderEncoding::SemanticFrame,
|
||
Some(client_tx),
|
||
),
|
||
);
|
||
server.foreground_client_id = Some(1);
|
||
server.sync_foreground_client_state();
|
||
|
||
let changed = server.handle_internal_event_with_forwarding(AppEvent::StateChanged {
|
||
pane_id,
|
||
agent: Some(crate::detect::Agent::Pi),
|
||
state: crate::detect::AgentState::Blocked,
|
||
visible_blocker: false,
|
||
visible_working: false,
|
||
process_exited: false,
|
||
observed_at: Instant::now(),
|
||
});
|
||
|
||
assert!(changed);
|
||
assert!(server.app.state.toast.is_none());
|
||
assert!(
|
||
client_control_rx
|
||
.recv_timeout(Duration::from_millis(50))
|
||
.is_err(),
|
||
"delayed transition should not notify immediately"
|
||
);
|
||
|
||
let deadline = server
|
||
.app
|
||
.state
|
||
.next_pending_agent_notification_deadline()
|
||
.expect("pending notification deadline");
|
||
assert!(server.handle_scheduled_tasks_headless(deadline, false));
|
||
|
||
let first = read_server_message(
|
||
client_control_rx
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.expect("delayed sound message"),
|
||
);
|
||
let second = read_server_message(
|
||
client_control_rx
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.expect("delayed toast message"),
|
||
);
|
||
|
||
assert!(matches!(
|
||
first,
|
||
ServerMessage::Notify {
|
||
kind: protocol::NotifyKind::Sound,
|
||
..
|
||
}
|
||
));
|
||
match second {
|
||
ServerMessage::Notify {
|
||
kind,
|
||
message,
|
||
body,
|
||
} => {
|
||
assert_eq!(kind, protocol::NotifyKind::SystemToast);
|
||
assert_eq!(message, "pi needs attention");
|
||
assert_eq!(body.as_deref(), Some("background · 1"));
|
||
}
|
||
other => panic!("expected delayed system toast, got {other:?}"),
|
||
}
|
||
assert!(server.app.state.pending_agent_notifications.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn delayed_active_tab_unfocused_agent_notification_forwards_after_deadline() {
|
||
let mut server = test_headless_server();
|
||
let workspace = crate::workspace::Workspace::test_new("active");
|
||
let pane_id = workspace.tabs[0].root_pane;
|
||
server.app.state.workspaces = vec![workspace];
|
||
server.app.state.ensure_test_terminals();
|
||
server.app.state.active = Some(0);
|
||
server.app.state.selected = 0;
|
||
server.app.state.mode = crate::app::Mode::Terminal;
|
||
server.app.state.toast_config.delivery = crate::config::ToastDelivery::System;
|
||
server.app.state.toast_config.delay_seconds = 1;
|
||
|
||
let (client_tx, client_control_rx, _client_rx) = test_client_writer();
|
||
server.clients.insert(
|
||
1,
|
||
ClientConnection::new(
|
||
(80, 24),
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
crate::terminal_theme::TerminalTheme::default(),
|
||
Some(false),
|
||
1,
|
||
RenderEncoding::SemanticFrame,
|
||
Some(client_tx),
|
||
),
|
||
);
|
||
server.foreground_client_id = Some(1);
|
||
server.sync_foreground_client_state();
|
||
|
||
assert!(
|
||
server.handle_internal_event_with_forwarding(AppEvent::StateChanged {
|
||
pane_id,
|
||
agent: Some(crate::detect::Agent::Pi),
|
||
state: crate::detect::AgentState::Blocked,
|
||
visible_blocker: false,
|
||
visible_working: false,
|
||
process_exited: false,
|
||
observed_at: Instant::now(),
|
||
})
|
||
);
|
||
assert!(server.app.state.toast.is_none());
|
||
assert!(
|
||
client_control_rx
|
||
.recv_timeout(Duration::from_millis(50))
|
||
.is_err(),
|
||
"delayed transition should not notify immediately"
|
||
);
|
||
|
||
let deadline = server
|
||
.app
|
||
.state
|
||
.next_pending_agent_notification_deadline()
|
||
.expect("pending notification deadline");
|
||
assert!(server.handle_scheduled_tasks_headless(deadline, false));
|
||
|
||
let first = read_server_message(
|
||
client_control_rx
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.expect("delayed sound message"),
|
||
);
|
||
let second = read_server_message(
|
||
client_control_rx
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.expect("delayed toast message"),
|
||
);
|
||
|
||
assert!(matches!(
|
||
first,
|
||
ServerMessage::Notify {
|
||
kind: protocol::NotifyKind::Sound,
|
||
..
|
||
}
|
||
));
|
||
match second {
|
||
ServerMessage::Notify {
|
||
kind,
|
||
message,
|
||
body,
|
||
} => {
|
||
assert_eq!(kind, protocol::NotifyKind::SystemToast);
|
||
assert_eq!(message, "pi needs attention");
|
||
assert_eq!(body.as_deref(), Some("active · 1"));
|
||
}
|
||
other => panic!("expected delayed system toast, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn stale_api_agent_report_does_not_forward_done_sound() {
|
||
let mut server = test_headless_server();
|
||
let background = crate::workspace::Workspace::test_new("background");
|
||
let pane_id = background.tabs[0].root_pane;
|
||
let public_pane_id = format!("{}:p1", background.id);
|
||
let foreground = crate::workspace::Workspace::test_new("foreground");
|
||
server.app.state.workspaces = vec![background, foreground];
|
||
server.app.state.ensure_test_terminals();
|
||
let terminal_id = server.app.state.workspaces[0]
|
||
.pane_state(pane_id)
|
||
.unwrap()
|
||
.attached_terminal_id
|
||
.clone();
|
||
server
|
||
.app
|
||
.state
|
||
.terminals
|
||
.get_mut(&terminal_id)
|
||
.unwrap()
|
||
.set_hook_authority(
|
||
"herdr:pi".into(),
|
||
"pi".into(),
|
||
crate::detect::AgentState::Working,
|
||
None,
|
||
Some(20),
|
||
);
|
||
server.app.state.active = Some(1);
|
||
server.app.state.selected = 1;
|
||
server.app.state.mode = crate::app::Mode::Terminal;
|
||
|
||
let (client_tx, client_control_rx, _client_rx) = test_client_writer();
|
||
server.clients.insert(
|
||
1,
|
||
ClientConnection::new(
|
||
(80, 24),
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
crate::terminal_theme::TerminalTheme::default(),
|
||
None,
|
||
1,
|
||
RenderEncoding::SemanticFrame,
|
||
Some(client_tx),
|
||
),
|
||
);
|
||
server.foreground_client_id = Some(1);
|
||
server.sync_foreground_client_state();
|
||
|
||
let (respond_to, response_rx) = std::sync::mpsc::channel();
|
||
let changed = server.handle_api_request_with_shutdown_check(api::ApiRequestMessage {
|
||
request: api::schema::Request {
|
||
id: "stale".into(),
|
||
method: api::schema::Method::PaneReportAgent(api::schema::PaneReportAgentParams {
|
||
pane_id: public_pane_id,
|
||
source: "herdr:pi".into(),
|
||
agent: "pi".into(),
|
||
state: api::schema::PaneAgentState::Idle,
|
||
message: None,
|
||
custom_status: None,
|
||
seq: Some(19),
|
||
agent_session_id: None,
|
||
agent_session_path: None,
|
||
}),
|
||
},
|
||
respond_to,
|
||
});
|
||
|
||
assert!(changed);
|
||
assert!(response_rx.recv_timeout(Duration::from_millis(100)).is_ok());
|
||
assert_eq!(
|
||
server.app.state.terminals.get(&terminal_id).unwrap().state,
|
||
crate::detect::AgentState::Working
|
||
);
|
||
assert!(
|
||
client_control_rx
|
||
.recv_timeout(Duration::from_millis(50))
|
||
.is_err(),
|
||
"stale idle report must not forward a done sound"
|
||
);
|
||
}
|
||
|
||
/// Verify that no direct calls to `self.app.handle_internal_event`
|
||
/// exist outside of `handle_internal_event_with_forwarding` in this
|
||
/// module. This ensures the forwarding bypass cannot be reintroduced.
|
||
///
|
||
/// The search pattern looks for `handle_internal_event` calls that
|
||
/// are NOT inside the `handle_internal_event_with_forwarding` method.
|
||
#[test]
|
||
fn no_handle_internal_event_bypass_in_module() {
|
||
let source = include_str!("headless.rs");
|
||
|
||
// Find all lines containing handle_internal_event
|
||
let mut bypass_lines: Vec<String> = Vec::new();
|
||
let mut inside_forwarding_method = false;
|
||
let mut forwarding_method_brace_depth = 0u32;
|
||
|
||
for (i, line) in source.lines().enumerate() {
|
||
let line_num = i + 1;
|
||
|
||
// Track when we're inside handle_internal_event_with_forwarding
|
||
if line.contains("fn handle_internal_event_with_forwarding") {
|
||
inside_forwarding_method = true;
|
||
forwarding_method_brace_depth = 0;
|
||
}
|
||
|
||
if inside_forwarding_method {
|
||
// Count braces to track when we exit the method
|
||
for ch in line.chars() {
|
||
match ch {
|
||
'{' => forwarding_method_brace_depth += 1,
|
||
'}' => {
|
||
forwarding_method_brace_depth =
|
||
forwarding_method_brace_depth.saturating_sub(1);
|
||
if forwarding_method_brace_depth == 0 {
|
||
inside_forwarding_method = false;
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
} else if line.contains("self.app.handle_internal_event(")
|
||
&& !line.trim().starts_with("///")
|
||
&& !line.contains("contains(")
|
||
{
|
||
// Direct call to handle_internal_event outside the forwarding method
|
||
bypass_lines.push(format!("line {}: {}", line_num, line.trim()));
|
||
}
|
||
}
|
||
|
||
assert!(
|
||
bypass_lines.is_empty(),
|
||
"Found direct calls to self.app.handle_internal_event outside \
|
||
handle_internal_event_with_forwarding (bypass risk):\n {}",
|
||
bypass_lines.join("\n ")
|
||
);
|
||
}
|
||
}
|