10619 lines
392 KiB
Rust
10619 lines
392 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, HashSet};
|
||
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, DeferredRender,
|
||
};
|
||
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;
|
||
|
||
mod pane_graphics;
|
||
|
||
#[cfg(test)]
|
||
use pane_graphics::frame_pane_graphics_for_client;
|
||
use pane_graphics::RetainedGraphicsOutcome;
|
||
|
||
#[cfg(test)]
|
||
use crate::protocol::RenderEncoding;
|
||
#[cfg(test)]
|
||
use crate::server::client_transport::ClientWriter;
|
||
#[cfg(test)]
|
||
use std::fs;
|
||
|
||
const LIVE_HANDOFF_RESPONSE_WRITE_TIMEOUT: Duration = Duration::from_secs(6);
|
||
|
||
fn wait_for_live_handoff_response_write(
|
||
response_write_complete: Option<std::sync::mpsc::Receiver<()>>,
|
||
) {
|
||
let Some(response_write_complete) = response_write_complete else {
|
||
return;
|
||
};
|
||
|
||
match response_write_complete.recv_timeout(LIVE_HANDOFF_RESPONSE_WRITE_TIMEOUT) {
|
||
Ok(()) => {}
|
||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
|
||
warn!("timed out waiting for live handoff response write; old server exiting");
|
||
}
|
||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
|
||
warn!("live handoff response writer disconnected; old server exiting");
|
||
}
|
||
}
|
||
}
|
||
|
||
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,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
|
||
enum RenderImpact {
|
||
#[default]
|
||
None,
|
||
Graphics,
|
||
Full,
|
||
}
|
||
|
||
impl RenderImpact {
|
||
fn merge(&mut self, other: Self) {
|
||
*self = (*self).max(other);
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
enum PtyRenderState {
|
||
Clean,
|
||
Hidden,
|
||
Visible,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
struct RetainedRenderInput {
|
||
needs_full_render: bool,
|
||
needs_graphics_render: bool,
|
||
pty: PtyRenderState,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
enum RetainedRenderPlan {
|
||
Full,
|
||
Graphics,
|
||
Pty,
|
||
HiddenPty,
|
||
}
|
||
|
||
fn retained_render_plan(input: RetainedRenderInput) -> RetainedRenderPlan {
|
||
if input.needs_full_render {
|
||
RetainedRenderPlan::Full
|
||
} else if input.needs_graphics_render && input.pty != PtyRenderState::Visible {
|
||
RetainedRenderPlan::Graphics
|
||
} else {
|
||
match input.pty {
|
||
PtyRenderState::Visible => RetainedRenderPlan::Pty,
|
||
PtyRenderState::Hidden => RetainedRenderPlan::HiddenPty,
|
||
PtyRenderState::Clean => RetainedRenderPlan::Full,
|
||
}
|
||
}
|
||
}
|
||
|
||
fn record_render_impact(source: &'static str, impact: RenderImpact) {
|
||
let event = match (source, impact) {
|
||
("api_requests", RenderImpact::Graphics) => "graphics_render_cause.api_requests",
|
||
("api_requests", RenderImpact::Full) => "full_render_cause.api_requests",
|
||
("server_events", RenderImpact::Graphics) => "graphics_render_cause.server_events",
|
||
("server_events", RenderImpact::Full) => "full_render_cause.server_events",
|
||
_ => return,
|
||
};
|
||
crate::render_prof::event(event);
|
||
}
|
||
|
||
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
|
||
// ---------------------------------------------------------------------------
|
||
|
||
struct AltScreenReadSpec {
|
||
terminal_id: crate::terminal::TerminalId,
|
||
lines: usize,
|
||
unwrap: bool,
|
||
initial: crate::terminal::ScreenSnapshot,
|
||
}
|
||
|
||
enum AltScreenReadConflict {
|
||
None,
|
||
Frozen(crate::pane::TerminalReadSnapshot),
|
||
Defer,
|
||
}
|
||
|
||
/// 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>,
|
||
// Kept on every platform so dropping HeadlessServer owns API server shutdown.
|
||
#[cfg_attr(windows, allow(dead_code))]
|
||
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>,
|
||
/// Deferred application-history reads currently driving alternate-screen viewports.
|
||
pending_alt_screen_reads: Vec<crate::server::alt_screen_read::PendingAltScreenRead>,
|
||
/// Reads waiting for an alternate-screen traversal of the same terminal to finish.
|
||
deferred_alt_screen_reads: Vec<api::ApiRequestMessage>,
|
||
/// 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(crate::pane::InputState::plain_page_keys_use_host_scrollback);
|
||
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;
|
||
Ok(Self {
|
||
app,
|
||
#[cfg(unix)]
|
||
api_tx,
|
||
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(),
|
||
pending_alt_screen_reads: Vec::new(),
|
||
deferred_alt_screen_reads: Vec::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 (session save, metadata expiry, 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;
|
||
let mut needs_graphics_render = false;
|
||
|
||
loop {
|
||
crate::render_prof::event("loop.tick");
|
||
crate::render_prof::flush_if_due();
|
||
self.app.reap_finished_custom_commands();
|
||
|
||
// 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 the coalesced render signal from PTY readers and generic runtime work.
|
||
if self.app.render_dirty.is_pending() {
|
||
needs_render = true;
|
||
crate::render_prof::event("render.request.signal");
|
||
}
|
||
let terminal_title_changed = self.app.sync_terminal_titles();
|
||
if terminal_title_changed && self.app.terminal_title_sidebar_configured() {
|
||
needs_render = true;
|
||
needs_full_render = true;
|
||
crate::render_prof::event("full_render_cause.terminal_title");
|
||
}
|
||
|
||
// 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;
|
||
needs_graphics_render = false;
|
||
crate::render_prof::event("full_render_cause.internal_events");
|
||
}
|
||
if self.app.expire_due_metadata(Instant::now()) {
|
||
needs_render = true;
|
||
needs_full_render = true;
|
||
crate::render_prof::event("full_render_cause.metadata_expiry");
|
||
}
|
||
|
||
// 3. Drain API requests.
|
||
if self.pane_graphics_runtime_active() {
|
||
let api_impact = self.drain_api_requests_with_render_impact();
|
||
record_render_impact("api_requests", api_impact);
|
||
match api_impact {
|
||
RenderImpact::None => {}
|
||
RenderImpact::Graphics => {
|
||
needs_render = true;
|
||
needs_graphics_render = true;
|
||
}
|
||
RenderImpact::Full => {
|
||
needs_render = true;
|
||
needs_full_render = true;
|
||
needs_graphics_render = false;
|
||
}
|
||
}
|
||
} else 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.pane_graphics_runtime_active() {
|
||
let server_impact = self.drain_server_events_with_render_impact();
|
||
record_render_impact("server_events", server_impact);
|
||
match server_impact {
|
||
RenderImpact::None => {}
|
||
RenderImpact::Graphics => {
|
||
needs_render = true;
|
||
needs_graphics_render = true;
|
||
}
|
||
RenderImpact::Full => {
|
||
needs_render = true;
|
||
needs_full_render = true;
|
||
needs_graphics_render = false;
|
||
}
|
||
}
|
||
} else 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;
|
||
needs_graphics_render = false;
|
||
crate::render_prof::event("full_render_cause.scheduled_tasks");
|
||
}
|
||
|
||
if self.handle_deferred_requests_headless() {
|
||
needs_render = true;
|
||
needs_full_render = true;
|
||
needs_graphics_render = false;
|
||
}
|
||
|
||
self.poll_pending_alt_screen_reads(now);
|
||
if self.process_deferred_alt_screen_reads() {
|
||
needs_render = true;
|
||
needs_full_render = true;
|
||
needs_graphics_render = false;
|
||
}
|
||
|
||
if latest_app_client(&self.clients).is_some() && self.app.ensure_default_workspace() {
|
||
needs_render = true;
|
||
needs_full_render = true;
|
||
needs_graphics_render = false;
|
||
crate::render_prof::event("full_render_cause.default_workspace");
|
||
}
|
||
|
||
self.cancel_inactive_pane_graphics_streams();
|
||
|
||
self.drain_client_config_reload_request();
|
||
self.stream_host_mouse_capture_mode();
|
||
self.stream_host_keyboard_enhancement_flags();
|
||
|
||
// 7. Render virtually and stream frames.
|
||
if needs_render && self.app.can_render_now(now) {
|
||
crate::render_prof::event("render.attempt");
|
||
let render_request = self.app.render_dirty.take();
|
||
let pty_dirty = !render_request.pty_sources.is_empty();
|
||
if pty_dirty {
|
||
crate::render_prof::event("render.attempt.pty_dirty");
|
||
crate::render_prof::counter(
|
||
"render.attempt.pty_sources",
|
||
render_request.pty_sources.len() as u64,
|
||
);
|
||
}
|
||
if render_request.generic {
|
||
needs_full_render = true;
|
||
crate::render_prof::event("full_render_cause.generic_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 pty = if !pty_dirty {
|
||
PtyRenderState::Clean
|
||
} else if self.pty_sources_visible_to_any_render_target(&render_request.pty_sources)
|
||
{
|
||
PtyRenderState::Visible
|
||
} else {
|
||
PtyRenderState::Hidden
|
||
};
|
||
let mut deferred_graphics = false;
|
||
let rendered_retained = match retained_render_plan(RetainedRenderInput {
|
||
needs_full_render,
|
||
needs_graphics_render,
|
||
pty,
|
||
}) {
|
||
RetainedRenderPlan::Full => false,
|
||
RetainedRenderPlan::Graphics => {
|
||
match self.render_retained_graphics_update_and_stream() {
|
||
RetainedGraphicsOutcome::Sent => true,
|
||
RetainedGraphicsOutcome::Deferred => {
|
||
deferred_graphics = true;
|
||
false
|
||
}
|
||
RetainedGraphicsOutcome::Fallback => false,
|
||
}
|
||
}
|
||
RetainedRenderPlan::Pty => self.render_retained_pty_update_and_stream(),
|
||
RetainedRenderPlan::HiddenPty => {
|
||
crate::render_prof::event("render.skipped.hidden_sources");
|
||
true
|
||
}
|
||
};
|
||
if deferred_graphics {
|
||
needs_render = false;
|
||
continue;
|
||
}
|
||
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;
|
||
needs_graphics_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 next_deadline = self
|
||
.pending_alt_screen_reads
|
||
.iter()
|
||
.map(|pending| pending.next_deadline())
|
||
.fold(next_deadline, |deadline, pending| {
|
||
Some(deadline.map_or(pending, |current| current.min(pending)))
|
||
});
|
||
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;
|
||
needs_graphics_render = false;
|
||
}
|
||
}
|
||
LoopEvent::Api(msg) => {
|
||
if self.pane_graphics_runtime_active() {
|
||
let impact = self.handle_api_request_with_render_impact(*msg);
|
||
record_render_impact("api_requests", impact);
|
||
match impact {
|
||
RenderImpact::None => {}
|
||
RenderImpact::Graphics => {
|
||
needs_render = true;
|
||
needs_graphics_render = true;
|
||
}
|
||
RenderImpact::Full => {
|
||
needs_render = true;
|
||
needs_full_render = true;
|
||
needs_graphics_render = false;
|
||
}
|
||
}
|
||
} else if self.handle_api_request_with_shutdown_check(*msg) {
|
||
needs_render = true;
|
||
needs_full_render = true;
|
||
}
|
||
}
|
||
LoopEvent::ServerEvent(ev) => {
|
||
if self.pane_graphics_runtime_active() {
|
||
let impact = self.handle_server_event_with_render_impact(ev);
|
||
record_render_impact("server_events", impact);
|
||
match impact {
|
||
RenderImpact::None => {}
|
||
RenderImpact::Graphics => {
|
||
needs_render = true;
|
||
needs_graphics_render = true;
|
||
}
|
||
RenderImpact::Full => {
|
||
needs_render = true;
|
||
needs_full_render = true;
|
||
needs_graphics_render = false;
|
||
}
|
||
}
|
||
} else if self.handle_server_event(ev) {
|
||
needs_render = true;
|
||
needs_full_render = true;
|
||
}
|
||
}
|
||
LoopEvent::RenderRequested => {
|
||
if self.app.render_dirty.is_pending() {
|
||
needs_render = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Save session on exit.
|
||
if !self.app.no_session {
|
||
self.app.save_session_now();
|
||
}
|
||
|
||
info!("headless server exiting");
|
||
Ok(())
|
||
}
|
||
|
||
fn handle_deferred_requests_headless(&mut self) -> bool {
|
||
let mut needs_render = false;
|
||
|
||
if self.app.state.request_complete_onboarding {
|
||
self.app.state.request_complete_onboarding = false;
|
||
self.app.open_settings_from_onboarding();
|
||
needs_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;
|
||
let response = self.headless_workspace_create("headless.workspace.create", None, None);
|
||
if let Err(error) = response {
|
||
error!(
|
||
code = %error.code,
|
||
message = %error.message,
|
||
"failed to create workspace"
|
||
);
|
||
}
|
||
needs_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;
|
||
let label = self.app.state.requested_new_tab_name.take();
|
||
let response = self.headless_tab_create("headless.tab.create", label);
|
||
if let Err(error) = response {
|
||
error!(
|
||
code = %error.code,
|
||
message = %error.message,
|
||
"failed to create tab"
|
||
);
|
||
}
|
||
needs_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;
|
||
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;
|
||
crate::render_prof::event("full_render_cause.deferred_worktree_dialog");
|
||
}
|
||
|
||
if let Some(cwd) = self.app.state.request_new_workspace_cwd.take() {
|
||
let response = self.headless_workspace_create(
|
||
"headless.workspace.create_cwd",
|
||
Some(cwd.display().to_string()),
|
||
None,
|
||
);
|
||
if let Err(error) = response {
|
||
error!(
|
||
code = %error.code,
|
||
message = %error.message,
|
||
"failed to create workspace at requested cwd"
|
||
);
|
||
self.app.state.mode = app::Mode::Navigate;
|
||
}
|
||
needs_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;
|
||
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;
|
||
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;
|
||
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;
|
||
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;
|
||
crate::render_prof::event("full_render_cause.config_reload");
|
||
}
|
||
|
||
needs_render
|
||
}
|
||
|
||
fn headless_workspace_create(
|
||
&mut self,
|
||
id: &'static str,
|
||
cwd: Option<String>,
|
||
label: Option<String>,
|
||
) -> Result<(), api::schema::ErrorBody> {
|
||
self.dispatch_headless_runtime_mutation(
|
||
id,
|
||
api::schema::Method::WorkspaceCreate(api::schema::WorkspaceCreateParams {
|
||
cwd,
|
||
focus: true,
|
||
label,
|
||
env: Default::default(),
|
||
}),
|
||
)
|
||
}
|
||
|
||
fn headless_tab_create(
|
||
&mut self,
|
||
id: &'static str,
|
||
label: Option<String>,
|
||
) -> Result<(), api::schema::ErrorBody> {
|
||
self.dispatch_headless_runtime_mutation(
|
||
id,
|
||
api::schema::Method::TabCreate(api::schema::TabCreateParams {
|
||
workspace_id: None,
|
||
cwd: None,
|
||
focus: true,
|
||
label,
|
||
env: Default::default(),
|
||
}),
|
||
)
|
||
}
|
||
|
||
fn dispatch_headless_runtime_mutation(
|
||
&mut self,
|
||
id: &'static str,
|
||
method: api::schema::Method,
|
||
) -> Result<(), api::schema::ErrorBody> {
|
||
let (respond_to, response_rx) = std::sync::mpsc::channel();
|
||
self.handle_api_request_with_shutdown_check_inner(
|
||
api::ApiRequestMessage {
|
||
request: api::schema::Request {
|
||
id: id.to_string(),
|
||
method,
|
||
},
|
||
respond_to,
|
||
response_write_complete: None,
|
||
},
|
||
true,
|
||
);
|
||
match response_rx.recv_timeout(Duration::from_secs(5)) {
|
||
Ok(response) => serde_json::from_str::<api::schema::ErrorResponse>(&response)
|
||
.map(|response| Err(response.error))
|
||
.unwrap_or(Ok(())),
|
||
Err(err) => Err(api::schema::ErrorBody {
|
||
code: "internal_error".into(),
|
||
message: format!("headless runtime mutation response failed: {err}"),
|
||
}),
|
||
}
|
||
}
|
||
|
||
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_repaint();
|
||
}
|
||
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_repaint();
|
||
}
|
||
}
|
||
}
|
||
|
||
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;
|
||
self.app.state.host_cell_size = crate::kitty_graphics::HostCellSize::default();
|
||
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;
|
||
self.app.state.host_cell_size = crate::kitty_graphics::HostCellSize::default();
|
||
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_cell_size = if self.app.state.kitty_graphics_enabled && client.cell_size.is_known()
|
||
{
|
||
client.cell_size
|
||
} else {
|
||
crate::kitty_graphics::HostCellSize::default()
|
||
};
|
||
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;
|
||
self.app.state.host_cell_size = host_cell_size;
|
||
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);
|
||
|
||
Ok(())
|
||
}
|
||
|
||
fn finish_live_handoff_shutdown(&mut self) {
|
||
self.shutting_down = true;
|
||
self.app.state.should_quit = true;
|
||
self.app.no_session = true;
|
||
info!("live handoff completed; old server exiting");
|
||
}
|
||
|
||
#[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.app.clear_input_source(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.
|
||
///
|
||
/// Uses the original full-render semantics when pane graphics are dormant.
|
||
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
|
||
}
|
||
|
||
/// Returns the strongest render impact from the drained event batch.
|
||
fn drain_server_events_with_render_impact(&mut self) -> RenderImpact {
|
||
let mut impact = RenderImpact::None;
|
||
while let Ok(ev) = self.server_event_rx.try_recv() {
|
||
impact.merge(self.handle_server_event_with_render_impact(ev));
|
||
}
|
||
impact
|
||
}
|
||
|
||
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::PrefixInputSource { active } => {
|
||
// Input-source switching is a client-local host side effect; forward it to the
|
||
// foreground client (which owns the real TIS switch + run-loop pump), like clipboard.
|
||
self.send_to_foreground_client(ServerMessage::PrefixInputSource {
|
||
active: *active,
|
||
});
|
||
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_with_render_impact(ev),
|
||
}
|
||
}
|
||
|
||
/// 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 self
|
||
.pending_alt_screen_reads
|
||
.iter()
|
||
.any(|pending| pending.terminal_id == real_terminal_id)
|
||
{
|
||
self.send_to_client(
|
||
client_id,
|
||
ServerMessage::ServerShutdown {
|
||
reason: Some(format!(
|
||
"terminal attach failed: terminal {terminal_id} has a read in progress; retry"
|
||
)),
|
||
},
|
||
);
|
||
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 source_was_foreground = self.foreground_client_id == Some(client_id);
|
||
let source_is_full_app = self
|
||
.clients
|
||
.get(&client_id)
|
||
.is_some_and(ClientConnection::is_full_app_client);
|
||
let host_surface_redraw = crate::raw_input::events_require_host_surface_redraw(
|
||
&events,
|
||
self.app.state.redraw_on_focus_gained,
|
||
);
|
||
let render_neutral_mouse_motion =
|
||
events_are_render_neutral_mouse_motion(&events, self.app.state.mode);
|
||
if let Some(client) = self.clients.get_mut(&client_id) {
|
||
if host_surface_redraw {
|
||
client.request_repaint();
|
||
client.defer_full_render();
|
||
} else if !render_neutral_mouse_motion {
|
||
// 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();
|
||
}
|
||
}
|
||
if source_is_full_app {
|
||
self.update_client_outer_focus_from_events(client_id, &events);
|
||
if events
|
||
.iter()
|
||
.any(|event| matches!(event, crate::raw_input::RawInputEvent::OuterFocusLost))
|
||
{
|
||
self.app.clear_input_source(client_id);
|
||
}
|
||
}
|
||
let events = events_for_app_routing(events, source_was_foreground, source_is_full_app);
|
||
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);
|
||
// Client-local theme reports were applied above; routing them again would update every
|
||
// pane once per palette entry instead of once per captured batch.
|
||
self.app.route_client_events_from(client_id, events, false);
|
||
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 && !render_neutral_mouse_motion)
|
||
}
|
||
}
|
||
|
||
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::ClientPasteRejected {
|
||
client_id,
|
||
size,
|
||
max,
|
||
} => {
|
||
self.send_to_client(
|
||
client_id,
|
||
ServerMessage::Notify {
|
||
kind: protocol::NotifyKind::Toast,
|
||
message: "Paste rejected".to_owned(),
|
||
body: Some(format!(
|
||
"Input message is {size} bytes; Herdr's limit is {max} bytes"
|
||
)),
|
||
},
|
||
);
|
||
false
|
||
}
|
||
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.request_repaint();
|
||
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.request_repaint();
|
||
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;
|
||
};
|
||
client.take_deferred_render() != DeferredRender::None
|
||
}
|
||
ServerEvent::QuitSignal => {
|
||
// The quit check at the top of the loop handles this.
|
||
// No render needed — the next iteration will initiate shutdown.
|
||
false
|
||
}
|
||
}
|
||
}
|
||
|
||
fn handle_server_event_with_render_impact(&mut self, ev: ServerEvent) -> RenderImpact {
|
||
let deferred_render = match &ev {
|
||
ServerEvent::ClientWriterDrained { client_id } => self
|
||
.clients
|
||
.get(client_id)
|
||
.map_or(DeferredRender::None, ClientConnection::deferred_render),
|
||
_ => DeferredRender::None,
|
||
};
|
||
if !self.handle_server_event(ev) {
|
||
return RenderImpact::None;
|
||
}
|
||
match deferred_render {
|
||
DeferredRender::Graphics => RenderImpact::Graphics,
|
||
DeferredRender::None | DeferredRender::Full => RenderImpact::Full,
|
||
}
|
||
}
|
||
|
||
fn ignore_client_event_during_handoff(ev: &ServerEvent) -> bool {
|
||
!matches!(
|
||
ev,
|
||
ServerEvent::ClientConnected { .. }
|
||
| ServerEvent::ClientDisconnected { .. }
|
||
| ServerEvent::ClientWriterDrained { .. }
|
||
| ServerEvent::QuitSignal
|
||
)
|
||
}
|
||
|
||
fn agent_read_not_idle_error(
|
||
&self,
|
||
request: &api::schema::Request,
|
||
) -> Option<api::schema::ErrorBody> {
|
||
use api::schema::{Method, ReadFormat, ReadSource};
|
||
|
||
let Method::AgentRead(params) = &request.method else {
|
||
return None;
|
||
};
|
||
let requested = params.lines?;
|
||
if params.format != ReadFormat::Text
|
||
|| !matches!(
|
||
params.source,
|
||
ReadSource::Recent | ReadSource::RecentUnwrapped
|
||
)
|
||
{
|
||
return None;
|
||
}
|
||
let target = self.app.resolve_agent_target(¶ms.target).ok()?;
|
||
let terminal = self
|
||
.app
|
||
.state
|
||
.terminals
|
||
.values()
|
||
.find(|terminal| terminal.id.as_str() == target.terminal_id)?;
|
||
if terminal.effective_known_agent().is_none()
|
||
|| terminal.state == crate::detect::AgentState::Idle
|
||
{
|
||
return None;
|
||
}
|
||
let runtime = self.app.terminal_runtimes.get(&terminal.id)?;
|
||
let (screen, snapshot) = runtime.screen_text_snapshot()?;
|
||
if screen != crate::ghostty::ActiveScreen::Alternate
|
||
|| snapshot.rows.len() >= requested.min(1000) as usize
|
||
{
|
||
return None;
|
||
}
|
||
let status = crate::detect::manifest::agent_state_label(terminal.state);
|
||
Some(api::schema::ErrorBody {
|
||
code: "agent_not_idle".into(),
|
||
message: format!(
|
||
"cannot read {requested} lines while {} is {status}: its alternate-screen history can only be captured by scrolling while idle. Wait and retry, or use --source visible",
|
||
params.target
|
||
),
|
||
})
|
||
}
|
||
|
||
fn alt_screen_read_spec(&self, request: &api::schema::Request) -> Option<AltScreenReadSpec> {
|
||
use api::schema::{Method, ReadFormat, ReadIntent, ReadSource};
|
||
|
||
let (target, source, lines, format) = match &request.method {
|
||
Method::AgentRead(params) => (
|
||
self.app.resolve_agent_target(¶ms.target).ok()?,
|
||
params.source,
|
||
params.lines,
|
||
params.format,
|
||
),
|
||
Method::PaneRead(params) if params.intent == ReadIntent::Interactive => (
|
||
self.app.resolve_terminal_target(¶ms.pane_id).ok()?,
|
||
params.source,
|
||
params.lines,
|
||
params.format,
|
||
),
|
||
_ => return None,
|
||
};
|
||
if format != ReadFormat::Text
|
||
|| !matches!(source, ReadSource::Recent | ReadSource::RecentUnwrapped)
|
||
{
|
||
return None;
|
||
}
|
||
let lines = lines.unwrap_or(80).min(1000) as usize;
|
||
if lines == 0
|
||
|| self
|
||
.terminal_attach_owners
|
||
.contains_key(target.terminal_id.as_str())
|
||
|| self
|
||
.pending_alt_screen_reads
|
||
.iter()
|
||
.any(|pending| pending.terminal_id.as_str() == target.terminal_id)
|
||
{
|
||
return None;
|
||
}
|
||
let terminal = self
|
||
.app
|
||
.state
|
||
.terminals
|
||
.values()
|
||
.find(|terminal| terminal.id.as_str() == target.terminal_id)?;
|
||
if terminal.effective_known_agent().is_none()
|
||
|| terminal.state != crate::detect::AgentState::Idle
|
||
{
|
||
return None;
|
||
}
|
||
let runtime = self.app.terminal_runtimes.get(&terminal.id)?;
|
||
if runtime.wheel_routing() != Some(crate::pane::WheelRouting::MouseReport) {
|
||
return None;
|
||
}
|
||
let (screen, initial) = runtime.screen_text_snapshot()?;
|
||
if screen != crate::ghostty::ActiveScreen::Alternate || initial.rows.len() >= lines {
|
||
return None;
|
||
}
|
||
Some(AltScreenReadSpec {
|
||
terminal_id: terminal.id.clone(),
|
||
lines,
|
||
unwrap: source == ReadSource::RecentUnwrapped,
|
||
initial,
|
||
})
|
||
}
|
||
|
||
fn poll_pending_alt_screen_reads(&mut self, now: Instant) {
|
||
let pending = std::mem::take(&mut self.pending_alt_screen_reads);
|
||
for read in pending {
|
||
let runtime = self.app.terminal_runtimes.get(&read.terminal_id);
|
||
let remains_idle = self
|
||
.app
|
||
.state
|
||
.terminals
|
||
.get(&read.terminal_id)
|
||
.is_some_and(|terminal| terminal.state == crate::detect::AgentState::Idle);
|
||
let attached = self
|
||
.terminal_attach_owners
|
||
.contains_key(read.terminal_id.as_str());
|
||
let outcome = if remains_idle && !attached {
|
||
read.poll(runtime, now)
|
||
} else {
|
||
read.abort(runtime, now)
|
||
};
|
||
if let Some(read) = outcome {
|
||
self.pending_alt_screen_reads.push(read);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn alt_screen_read_conflict(&self, request: &api::schema::Request) -> AltScreenReadConflict {
|
||
let (target, source, lines, format) = match &request.method {
|
||
api::schema::Method::AgentRead(params) => (
|
||
self.app.resolve_agent_target(¶ms.target).ok(),
|
||
params.source,
|
||
params.lines,
|
||
params.format,
|
||
),
|
||
api::schema::Method::PaneRead(params) => (
|
||
self.app.resolve_terminal_target(¶ms.pane_id).ok(),
|
||
params.source,
|
||
params.lines,
|
||
params.format,
|
||
),
|
||
_ => return AltScreenReadConflict::None,
|
||
};
|
||
let Some(target) = target else {
|
||
return AltScreenReadConflict::None;
|
||
};
|
||
let Some(pending) = self
|
||
.pending_alt_screen_reads
|
||
.iter()
|
||
.find(|pending| pending.terminal_id.as_str() == target.terminal_id)
|
||
else {
|
||
return AltScreenReadConflict::None;
|
||
};
|
||
if format == api::schema::ReadFormat::Text {
|
||
AltScreenReadConflict::Frozen(pending.frozen_snapshot(source, lines))
|
||
} else {
|
||
AltScreenReadConflict::Defer
|
||
}
|
||
}
|
||
|
||
fn process_deferred_alt_screen_reads(&mut self) -> bool {
|
||
let deferred = std::mem::take(&mut self.deferred_alt_screen_reads);
|
||
let mut changed = false;
|
||
for msg in deferred {
|
||
match self.alt_screen_read_conflict(&msg.request) {
|
||
AltScreenReadConflict::None => {
|
||
changed |= self.handle_api_request_with_shutdown_check(msg);
|
||
}
|
||
AltScreenReadConflict::Frozen(_) | AltScreenReadConflict::Defer => {
|
||
self.deferred_alt_screen_reads.push(msg);
|
||
}
|
||
}
|
||
}
|
||
changed
|
||
}
|
||
|
||
/// 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
|
||
}
|
||
|
||
fn drain_api_requests_with_render_impact(&mut self) -> RenderImpact {
|
||
let mut impact = RenderImpact::None;
|
||
while let Ok(msg) = self.app.api_rx.try_recv() {
|
||
impact.merge(self.handle_api_request_with_render_impact(msg));
|
||
}
|
||
impact
|
||
}
|
||
|
||
/// 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 {
|
||
self.handle_api_request_with_shutdown_check_inner(msg, false)
|
||
}
|
||
|
||
fn handle_api_request_with_render_impact(
|
||
&mut self,
|
||
msg: api::ApiRequestMessage,
|
||
) -> RenderImpact {
|
||
if matches!(
|
||
&msg.request.method,
|
||
api::schema::Method::PaneGraphicsStreamSet(_)
|
||
) {
|
||
return self.handle_pane_graphics_stream_frame(msg);
|
||
}
|
||
if self.handle_api_request_with_shutdown_check_inner(msg, false) {
|
||
RenderImpact::Full
|
||
} else {
|
||
RenderImpact::None
|
||
}
|
||
}
|
||
|
||
fn handle_api_request_with_shutdown_check_inner(
|
||
&mut self,
|
||
msg: api::ApiRequestMessage,
|
||
skip_default_workspace_for_request: bool,
|
||
) -> 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;
|
||
}
|
||
|
||
let frozen_alt_screen_read = match self.alt_screen_read_conflict(&msg.request) {
|
||
AltScreenReadConflict::None => None,
|
||
AltScreenReadConflict::Frozen(snapshot) => Some(snapshot),
|
||
AltScreenReadConflict::Defer => {
|
||
self.deferred_alt_screen_reads.push(msg);
|
||
return false;
|
||
}
|
||
};
|
||
|
||
let metadata_expired = self.app.expire_due_metadata(Instant::now());
|
||
|
||
if let api::schema::Method::ServerLiveHandoff(params) = &msg.request.method {
|
||
let handoff_result = self.perform_live_handoff(params.clone());
|
||
let handoff_succeeded = handoff_result.is_ok();
|
||
let response = match handoff_result {
|
||
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);
|
||
if handoff_succeeded {
|
||
wait_for_live_handoff_response_write(msg.response_write_complete);
|
||
self.finish_live_handoff_shutdown();
|
||
}
|
||
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 pane_graphics_revision_before = matches!(
|
||
&msg.request.method,
|
||
api::schema::Method::PaneGraphicsSet(_)
|
||
| api::schema::Method::PaneGraphicsClear(_)
|
||
| api::schema::Method::PaneGraphicsStreamOpen(_)
|
||
| api::schema::Method::PaneGraphicsStreamClose(_)
|
||
)
|
||
.then_some(self.app.state.pane_graphics_revision);
|
||
let mut changed = metadata_expired
|
||
| (pane_graphics_revision_before.is_none() && api::request_changes_ui(&msg.request));
|
||
let skip_default_workspace = skip_default_workspace_for_request
|
||
|| 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 let Some(error) = self.agent_read_not_idle_error(&msg.request) {
|
||
let response = serde_json::to_string(&api::schema::ErrorResponse {
|
||
id: msg.request.id.clone(),
|
||
error,
|
||
})
|
||
.unwrap_or_else(|_| "{}".to_owned());
|
||
let _ = msg.respond_to.send(response);
|
||
return changed;
|
||
}
|
||
let alt_screen_read_spec = self.alt_screen_read_spec(&msg.request);
|
||
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 mut 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)
|
||
};
|
||
if let Some(snapshot) = frozen_alt_screen_read {
|
||
if let Ok(mut success) = serde_json::from_str::<api::schema::SuccessResponse>(&response)
|
||
{
|
||
if let api::schema::ResponseResult::PaneRead { read } = &mut success.result {
|
||
read.text = snapshot.text;
|
||
read.truncated = snapshot.truncated;
|
||
if let Ok(serialized) = serde_json::to_string(&success) {
|
||
response = serialized;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if let Some(spec) = alt_screen_read_spec {
|
||
if let Ok(success) = serde_json::from_str::<api::schema::SuccessResponse>(&response) {
|
||
if let api::schema::ResponseResult::PaneRead { read } = success.result {
|
||
let pending = crate::server::alt_screen_read::PendingAltScreenRead::start(
|
||
spec.terminal_id,
|
||
success.id,
|
||
msg.respond_to,
|
||
response,
|
||
read,
|
||
spec.lines,
|
||
spec.unwrap,
|
||
spec.initial,
|
||
Instant::now(),
|
||
);
|
||
self.pending_alt_screen_reads.push(pending);
|
||
return changed;
|
||
}
|
||
}
|
||
}
|
||
let _ = msg.respond_to.send(response);
|
||
|
||
if let Some(revision_before) = pane_graphics_revision_before {
|
||
changed |= revision_before != self.app.state.pane_graphics_revision;
|
||
}
|
||
|
||
// 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);
|
||
}
|
||
}
|
||
|
||
fn stream_host_keyboard_enhancement_flags(&mut self) {
|
||
let report_all_keys = self.app.host_keyboard_report_all_requested();
|
||
let serialized = match Self::frame_server_message(&ServerMessage::KittyKeyboardReportAll {
|
||
enabled: report_all_keys,
|
||
}) {
|
||
Ok(framed) => framed,
|
||
Err(err) => {
|
||
warn!(err = %err, "failed to serialize keyboard enhancement flags for clients");
|
||
return;
|
||
}
|
||
};
|
||
|
||
let mut broken_clients = Vec::new();
|
||
for (&client_id, client) in &mut self.clients {
|
||
if !client.is_full_app_client()
|
||
|| client.host_keyboard_report_all_active == Some(report_all_keys)
|
||
{
|
||
continue;
|
||
}
|
||
let Some(writer) = &client.writer else {
|
||
continue;
|
||
};
|
||
if writer.control.send(serialized.clone()).is_err() {
|
||
debug!(
|
||
client_id,
|
||
"client writer channel closed during keyboard enhancement update"
|
||
);
|
||
broken_clients.push(client_id);
|
||
continue;
|
||
}
|
||
client.host_keyboard_report_all_active = Some(report_all_keys);
|
||
}
|
||
|
||
for client_id in broken_clients {
|
||
self.remove_client_and_resize_if_needed(client_id);
|
||
}
|
||
}
|
||
|
||
fn pty_sources_visible_to_any_render_target(
|
||
&self,
|
||
sources: &HashSet<crate::layout::PaneId>,
|
||
) -> bool {
|
||
let mut has_app_target = false;
|
||
let mut direct_terminal_targets = HashSet::new();
|
||
for client in self
|
||
.clients
|
||
.values()
|
||
.filter(|client| client.writer.is_some())
|
||
{
|
||
match &client.mode {
|
||
ClientConnectionMode::App if client.is_full_app_client() => {
|
||
has_app_target = true;
|
||
}
|
||
ClientConnectionMode::TerminalAttach { terminal_id }
|
||
| ClientConnectionMode::TerminalObserve { terminal_id } => {
|
||
direct_terminal_targets.insert(terminal_id.as_str());
|
||
}
|
||
ClientConnectionMode::App => {}
|
||
}
|
||
}
|
||
if !has_app_target && direct_terminal_targets.is_empty() {
|
||
return false;
|
||
}
|
||
|
||
sources.iter().any(|&pane_id| {
|
||
let terminal_id = self.terminal_id_for_pane(pane_id);
|
||
(has_app_target && (terminal_id.is_none() || self.app_surface_contains_pane(pane_id)))
|
||
|| terminal_id
|
||
.is_none_or(|source| direct_terminal_targets.contains(source.as_str()))
|
||
})
|
||
}
|
||
|
||
fn terminal_id_for_pane(
|
||
&self,
|
||
pane_id: crate::layout::PaneId,
|
||
) -> Option<&crate::terminal::TerminalId> {
|
||
if let Some(popup) = self
|
||
.app
|
||
.state
|
||
.popup_pane
|
||
.as_ref()
|
||
.filter(|popup| popup.pane_id == pane_id)
|
||
{
|
||
return Some(&popup.terminal_id);
|
||
}
|
||
self.app
|
||
.find_pane(pane_id)
|
||
.map(|(_, pane)| &pane.attached_terminal_id)
|
||
}
|
||
|
||
fn app_surface_contains_pane(&self, pane_id: crate::layout::PaneId) -> bool {
|
||
if self
|
||
.app
|
||
.state
|
||
.popup_pane
|
||
.as_ref()
|
||
.is_some_and(|popup| popup.pane_id == pane_id)
|
||
{
|
||
return true;
|
||
}
|
||
let Some(workspace) = self
|
||
.app
|
||
.state
|
||
.active
|
||
.and_then(|ws_idx| self.app.state.workspaces.get(ws_idx))
|
||
else {
|
||
return false;
|
||
};
|
||
let Some(tab) = workspace.active_tab() else {
|
||
return false;
|
||
};
|
||
if !tab.panes.contains_key(&pane_id) {
|
||
return false;
|
||
}
|
||
!tab.zoomed || tab.layout.focused() == pane_id
|
||
}
|
||
|
||
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.deferred_render() != DeferredRender::None {
|
||
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,
|
||
self.app.state.view.tab_surface(),
|
||
*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.popup_pane.is_none()
|
||
&& 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.state.copy_feedback.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.clear_deferred_render();
|
||
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.clear_deferred_render();
|
||
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.defer_full_render();
|
||
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 render_cell_size =
|
||
if self.app.state.kitty_graphics_enabled && cell_size.is_known() {
|
||
cell_size
|
||
} else {
|
||
crate::kitty_graphics::HostCellSize::default()
|
||
};
|
||
let preserved_scroll = (!is_foreground).then_some((
|
||
self.app.state.workspace_scroll,
|
||
self.app.state.agent_panel_scroll,
|
||
self.app.state.tab_scroll,
|
||
self.app.state.mobile_switcher_scroll,
|
||
));
|
||
let (buffer, cursor) =
|
||
crate::server::render_stream::render_virtual_with_runtime_registry(
|
||
&mut self.app.state,
|
||
&self.app.terminal_runtimes,
|
||
area,
|
||
is_foreground,
|
||
render_cell_size,
|
||
);
|
||
if let Some((workspace, agent_panel, tab, mobile_switcher)) = preserved_scroll {
|
||
self.app.state.workspace_scroll = workspace;
|
||
self.app.state.agent_panel_scroll = agent_panel;
|
||
self.app.state.tab_scroll = tab;
|
||
self.app.state.mobile_switcher_scroll = mobile_switcher;
|
||
}
|
||
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,
|
||
self.app.state.view.tab_surface(),
|
||
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.clear_deferred_render();
|
||
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.clear_deferred_render();
|
||
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.clear_deferred_render();
|
||
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.defer_full_render();
|
||
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;
|
||
|
||
// 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
|
||
.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.start_background_session_save();
|
||
}
|
||
|
||
if let Some(deadline) = self
|
||
.app
|
||
.agent_metadata_deadline
|
||
.filter(|deadline| now >= *deadline)
|
||
{
|
||
self.app.expire_metadata_at(deadline, now);
|
||
changed = true;
|
||
}
|
||
|
||
if geometry_dirty {
|
||
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));
|
||
}
|
||
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(())
|
||
}
|
||
}
|
||
|
||
// Pane applications render their own motion responses through PTY output. Only Herdr modes with
|
||
// hover selection mutate the current frame directly from a plain mouse-move event.
|
||
fn events_are_render_neutral_mouse_motion(
|
||
events: &[crate::raw_input::RawInputEvent],
|
||
mode: crate::app::Mode,
|
||
) -> bool {
|
||
!events.is_empty()
|
||
&& !mode.mouse_motion_changes_view()
|
||
&& events.iter().all(|event| {
|
||
matches!(
|
||
event,
|
||
crate::raw_input::RawInputEvent::Mouse(crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Moved,
|
||
..
|
||
})
|
||
)
|
||
})
|
||
}
|
||
|
||
fn events_for_app_routing(
|
||
events: Vec<crate::raw_input::RawInputEvent>,
|
||
mut source_is_foreground: bool,
|
||
source_is_full_app: bool,
|
||
) -> Vec<crate::raw_input::RawInputEvent> {
|
||
events
|
||
.into_iter()
|
||
.filter_map(|event| match event {
|
||
crate::raw_input::RawInputEvent::OuterFocusGained
|
||
| crate::raw_input::RawInputEvent::OuterFocusLost
|
||
if !source_is_full_app =>
|
||
{
|
||
None
|
||
}
|
||
crate::raw_input::RawInputEvent::OuterFocusGained => {
|
||
source_is_foreground = true;
|
||
Some(event)
|
||
}
|
||
crate::raw_input::RawInputEvent::OuterFocusLost if !source_is_foreground => None,
|
||
crate::raw_input::RawInputEvent::Key(_)
|
||
| crate::raw_input::RawInputEvent::Text(_)
|
||
| crate::raw_input::RawInputEvent::Mouse(_)
|
||
| crate::raw_input::RawInputEvent::Paste(_) => {
|
||
source_is_foreground = true;
|
||
Some(event)
|
||
}
|
||
_ => Some(event),
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
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.
|
||
// The prefix input-source switch is likewise forwarded to the foreground
|
||
// client (ServerMessage::PrefixInputSource), never applied in-process.
|
||
app.state.local_sound_playback = false;
|
||
app.local_terminal_notifications = false;
|
||
app.local_input_source_switch = 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.app.run_plugin_startup_hooks();
|
||
|
||
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;
|
||
app.local_input_source_switch = 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.app.run_plugin_startup_hooks();
|
||
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;
|
||
|
||
#[path = "pane_graphics.rs"]
|
||
mod pane_graphics_tests;
|
||
|
||
#[test]
|
||
fn retained_render_plan_covers_each_render_path() {
|
||
assert_eq!(
|
||
retained_render_plan(RetainedRenderInput {
|
||
needs_full_render: true,
|
||
needs_graphics_render: true,
|
||
pty: PtyRenderState::Hidden,
|
||
}),
|
||
RetainedRenderPlan::Full
|
||
);
|
||
assert_eq!(
|
||
retained_render_plan(RetainedRenderInput {
|
||
needs_full_render: false,
|
||
needs_graphics_render: true,
|
||
pty: PtyRenderState::Hidden,
|
||
}),
|
||
RetainedRenderPlan::Graphics
|
||
);
|
||
assert_eq!(
|
||
retained_render_plan(RetainedRenderInput {
|
||
needs_full_render: false,
|
||
needs_graphics_render: false,
|
||
pty: PtyRenderState::Visible,
|
||
}),
|
||
RetainedRenderPlan::Pty
|
||
);
|
||
assert_eq!(
|
||
retained_render_plan(RetainedRenderInput {
|
||
needs_full_render: false,
|
||
needs_graphics_render: false,
|
||
pty: PtyRenderState::Hidden,
|
||
}),
|
||
RetainedRenderPlan::HiddenPty
|
||
);
|
||
}
|
||
|
||
fn test_headless_server() -> HeadlessServer {
|
||
test_headless_server_with_event_hub(api::EventHub::default())
|
||
}
|
||
|
||
fn test_headless_server_with_event_hub(event_hub: api::EventHub) -> 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, event_hub);
|
||
app.state.local_sound_playback = false;
|
||
app.local_terminal_notifications = false;
|
||
app.local_input_source_switch = 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,
|
||
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(),
|
||
pending_alt_screen_reads: Vec::new(),
|
||
deferred_alt_screen_reads: Vec::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 shutdown_test_runtimes(server: &mut HeadlessServer) {
|
||
for (_, runtime) in server.app.terminal_runtimes.drain() {
|
||
runtime.shutdown();
|
||
}
|
||
}
|
||
|
||
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:?}"),
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn headless_api_reads_latest_title_without_spinner_event_flooding() {
|
||
let event_hub = api::EventHub::default();
|
||
let mut server = test_headless_server_with_event_hub(event_hub.clone());
|
||
server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("one")];
|
||
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.sidebar_agents.rows = vec![vec![
|
||
crate::config::AgentSidebarToken::TerminalTitleStripped,
|
||
]];
|
||
let pane_id = server.app.state.workspaces[0].tabs[0].root_pane;
|
||
let terminal_id = server.app.state.workspaces[0].tabs[0].panes[&pane_id]
|
||
.attached_terminal_id
|
||
.clone();
|
||
server
|
||
.app
|
||
.state
|
||
.terminals
|
||
.get_mut(&terminal_id)
|
||
.unwrap()
|
||
.detected_agent = Some(crate::detect::Agent::Claude);
|
||
let runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b"");
|
||
runtime.test_process_pty_bytes(b"\x1b]0;\xe2\xa0\x8b task\x07");
|
||
server
|
||
.app
|
||
.terminal_runtimes
|
||
.insert(terminal_id.clone(), runtime);
|
||
|
||
let first = headless_pane_list(&mut server).pop().unwrap();
|
||
assert_eq!(first.terminal_title.as_deref(), Some("⠋ task"));
|
||
assert_eq!(first.terminal_title_stripped.as_deref(), Some("task"));
|
||
assert_eq!(pane_updated_events(&event_hub), 1);
|
||
let (buffer, _) = crate::server::render_stream::render_virtual_with_runtime_registry(
|
||
&mut server.app.state,
|
||
&server.app.terminal_runtimes,
|
||
Rect::new(0, 0, 100, 30),
|
||
true,
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
);
|
||
let rendered = buffer
|
||
.content
|
||
.iter()
|
||
.map(|cell| cell.symbol())
|
||
.collect::<String>();
|
||
assert!(rendered.contains("task"), "rendered frame: {rendered:?}");
|
||
|
||
server
|
||
.app
|
||
.terminal_runtimes
|
||
.get(&terminal_id)
|
||
.unwrap()
|
||
.test_process_pty_bytes(b"\x1b]2;\xe2\xa0\x99 task\x1b\\");
|
||
let second = headless_pane_list(&mut server).pop().unwrap();
|
||
assert_eq!(second.terminal_title.as_deref(), Some("⠙ task"));
|
||
assert_eq!(second.terminal_title_stripped.as_deref(), Some("task"));
|
||
assert_eq!(pane_updated_events(&event_hub), 1);
|
||
}
|
||
|
||
fn headless_pane_list(server: &mut HeadlessServer) -> Vec<api::schema::PaneInfo> {
|
||
let (respond_to, response_rx) = std::sync::mpsc::channel();
|
||
server.handle_api_request_with_shutdown_check(api::ApiRequestMessage {
|
||
request: api::schema::Request {
|
||
id: "list-titles".into(),
|
||
method: api::schema::Method::PaneList(api::schema::PaneListParams::default()),
|
||
},
|
||
respond_to,
|
||
response_write_complete: None,
|
||
});
|
||
let response: api::schema::SuccessResponse =
|
||
serde_json::from_str(&response_rx.recv().unwrap()).unwrap();
|
||
let api::schema::ResponseResult::PaneList { panes } = response.result else {
|
||
panic!("expected pane list");
|
||
};
|
||
panes
|
||
}
|
||
|
||
fn pane_updated_events(event_hub: &api::EventHub) -> usize {
|
||
event_hub
|
||
.events_after(0)
|
||
.iter()
|
||
.filter(|(_, event)| event.event == api::schema::EventKind::PaneUpdated)
|
||
.count()
|
||
}
|
||
|
||
#[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,
|
||
response_write_complete: None,
|
||
})
|
||
);
|
||
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());
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn headless_deferred_workspace_create_uses_runtime_events() {
|
||
let event_hub = api::EventHub::default();
|
||
let mut server = test_headless_server_with_event_hub(event_hub.clone());
|
||
|
||
server.app.state.request_new_workspace = true;
|
||
|
||
assert!(server.handle_deferred_requests_headless());
|
||
assert!(!server.app.state.request_new_workspace);
|
||
assert_eq!(
|
||
event_hub
|
||
.events_after(0)
|
||
.into_iter()
|
||
.map(|(_, event)| event.event)
|
||
.collect::<Vec<_>>(),
|
||
vec![
|
||
api::schema::EventKind::WorkspaceCreated,
|
||
api::schema::EventKind::TabCreated,
|
||
api::schema::EventKind::PaneCreated,
|
||
api::schema::EventKind::LayoutUpdated,
|
||
]
|
||
);
|
||
shutdown_test_runtimes(&mut server);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn headless_deferred_named_tab_create_uses_runtime_events() {
|
||
let event_hub = api::EventHub::default();
|
||
let mut server = test_headless_server_with_event_hub(event_hub.clone());
|
||
server
|
||
.app
|
||
.create_workspace_with_options(std::env::temp_dir(), true)
|
||
.unwrap();
|
||
let after_setup = event_hub.current_sequence();
|
||
|
||
server.app.state.request_new_tab = true;
|
||
server.app.state.requested_new_tab_name = Some("ops".into());
|
||
|
||
assert!(server.handle_deferred_requests_headless());
|
||
assert!(!server.app.state.request_new_tab);
|
||
assert_eq!(server.app.state.requested_new_tab_name, None);
|
||
let events = event_hub.events_after(after_setup);
|
||
assert_eq!(
|
||
events
|
||
.iter()
|
||
.map(|(_, event)| event.event)
|
||
.collect::<Vec<_>>(),
|
||
vec![
|
||
api::schema::EventKind::TabCreated,
|
||
api::schema::EventKind::PaneCreated,
|
||
api::schema::EventKind::LayoutUpdated,
|
||
]
|
||
);
|
||
let tab_created = events
|
||
.iter()
|
||
.find_map(|(_, event)| match &event.data {
|
||
api::schema::EventData::TabCreated { tab } => Some(tab),
|
||
_ => None,
|
||
})
|
||
.expect("tab created event");
|
||
assert_eq!(tab_created.label, "ops");
|
||
shutdown_test_runtimes(&mut server);
|
||
}
|
||
|
||
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 hidden_pty_visibility_test_server(
|
||
client_sizes: &[(u16, u16)],
|
||
) -> (HeadlessServer, crate::layout::PaneId) {
|
||
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 background_pane = workspace.tabs[background_tab].root_pane;
|
||
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;
|
||
|
||
for (index, &terminal_size) in client_sizes.iter().enumerate() {
|
||
let client_id = index as u64 + 1;
|
||
let (client_tx, _client_control_rx, _client_rx) = test_client_writer();
|
||
server.clients.insert(
|
||
client_id,
|
||
ClientConnection::new(
|
||
terminal_size,
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
crate::terminal_theme::TerminalTheme::default(),
|
||
None,
|
||
client_id,
|
||
RenderEncoding::SemanticFrame,
|
||
Some(client_tx),
|
||
),
|
||
);
|
||
}
|
||
|
||
(server, background_pane)
|
||
}
|
||
|
||
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 explicit_agent_history_read_requires_idle_on_alternate_screen() {
|
||
with_terminal_session_test_server(
|
||
|server, terminal_id, _terminal_id_string, public_pane_id| {
|
||
let terminal = server
|
||
.app
|
||
.state
|
||
.terminals
|
||
.get_mut(&terminal_id)
|
||
.expect("terminal");
|
||
terminal.detected_agent = Some(crate::detect::Agent::Claude);
|
||
terminal.state = crate::detect::AgentState::Working;
|
||
server.app.terminal_runtimes.insert(
|
||
terminal_id,
|
||
crate::terminal::TerminalRuntime::test_with_screen_bytes(
|
||
80,
|
||
24,
|
||
b"\x1b[?1049hworking",
|
||
),
|
||
);
|
||
let request = api::schema::Request {
|
||
id: "read".into(),
|
||
method: api::schema::Method::AgentRead(api::schema::AgentReadParams {
|
||
target: public_pane_id.clone(),
|
||
source: api::schema::ReadSource::Recent,
|
||
lines: Some(200),
|
||
format: api::schema::ReadFormat::Text,
|
||
strip_ansi: true,
|
||
}),
|
||
};
|
||
|
||
assert_eq!(
|
||
server.agent_read_not_idle_error(&request),
|
||
Some(api::schema::ErrorBody {
|
||
code: "agent_not_idle".into(),
|
||
message: format!(
|
||
"cannot read 200 lines while {public_pane_id} is working: its alternate-screen history can only be captured by scrolling while idle. Wait and retry, or use --source visible"
|
||
),
|
||
})
|
||
);
|
||
|
||
let mut default_request = request.clone();
|
||
let api::schema::Method::AgentRead(params) = &mut default_request.method else {
|
||
unreachable!();
|
||
};
|
||
params.lines = None;
|
||
assert_eq!(server.agent_read_not_idle_error(&default_request), None);
|
||
|
||
let mut visible_request = request;
|
||
let api::schema::Method::AgentRead(params) = &mut visible_request.method else {
|
||
unreachable!();
|
||
};
|
||
params.source = api::schema::ReadSource::Visible;
|
||
assert_eq!(server.agent_read_not_idle_error(&visible_request), None);
|
||
},
|
||
);
|
||
}
|
||
|
||
#[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_attach_during_alt_screen_read() {
|
||
with_terminal_session_test_server(|server, terminal_id, terminal_id_string, _| {
|
||
let (respond_to, _response_rx) = std::sync::mpsc::channel();
|
||
server.pending_alt_screen_reads.push(
|
||
crate::server::alt_screen_read::PendingAltScreenRead::start(
|
||
terminal_id,
|
||
"read".into(),
|
||
respond_to,
|
||
"fallback".into(),
|
||
api::schema::PaneReadResult {
|
||
pane_id: "w1:p1".into(),
|
||
workspace_id: "w1".into(),
|
||
tab_id: "w1:t1".into(),
|
||
source: api::schema::ReadSource::Recent,
|
||
format: api::schema::ReadFormat::Text,
|
||
text: String::new(),
|
||
revision: 0,
|
||
truncated: false,
|
||
},
|
||
120,
|
||
false,
|
||
crate::terminal::ScreenSnapshot {
|
||
cols: 80,
|
||
rows: Vec::new(),
|
||
},
|
||
Instant::now(),
|
||
),
|
||
);
|
||
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.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(format!(
|
||
"terminal attach failed: terminal {terminal_id_string} has a read in progress; retry"
|
||
))
|
||
);
|
||
});
|
||
}
|
||
|
||
#[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 unchanged_git_refresh_does_not_request_headless_render() {
|
||
let mut server = test_headless_server();
|
||
server.app.git_refresh_in_flight = true;
|
||
let mut workspace = crate::workspace::Workspace::test_new("one");
|
||
let workspace_id = workspace.id.clone();
|
||
let cwd = workspace.identity_cwd.clone();
|
||
workspace.cached_auto_label = "cached".into();
|
||
workspace.cached_git_status_key = cwd.clone();
|
||
workspace.cached_git_branch = None;
|
||
server.app.state.workspaces.push(workspace);
|
||
|
||
let changed = server.handle_internal_event_with_forwarding(AppEvent::GitStatusRefreshed {
|
||
results: vec![crate::workspace::WorkspaceGitStatus {
|
||
workspace_id,
|
||
resolved_identity_cwd: cwd.clone(),
|
||
status_cache_key: cwd,
|
||
demand: crate::workspace::GitStatusRefreshDemand::ALL,
|
||
auto_label: "cached".into(),
|
||
branch: None,
|
||
ahead_behind: None,
|
||
space: None,
|
||
}],
|
||
cache_updates: Vec::new(),
|
||
});
|
||
|
||
assert!(!changed);
|
||
assert!(!server.app.git_refresh_in_flight);
|
||
}
|
||
|
||
#[test]
|
||
fn changed_git_refresh_requests_headless_render() {
|
||
let mut server = test_headless_server();
|
||
let workspace = crate::workspace::Workspace::test_new("one");
|
||
let workspace_id = workspace.id.clone();
|
||
let cwd = workspace.identity_cwd.clone();
|
||
server.app.state.workspaces.push(workspace);
|
||
|
||
let changed = server.handle_internal_event_with_forwarding(AppEvent::GitStatusRefreshed {
|
||
results: vec![crate::workspace::WorkspaceGitStatus {
|
||
workspace_id,
|
||
resolved_identity_cwd: cwd.clone(),
|
||
status_cache_key: cwd,
|
||
demand: crate::workspace::GitStatusRefreshDemand::ALL,
|
||
auto_label: "one".into(),
|
||
branch: Some("changed".into()),
|
||
ahead_behind: None,
|
||
space: None,
|
||
}],
|
||
cache_updates: Vec::new(),
|
||
});
|
||
|
||
assert!(changed);
|
||
}
|
||
|
||
#[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));
|
||
}
|
||
|
||
fn with_terminal_attach_page_key_runtime(
|
||
initial_bytes: &[u8],
|
||
initial_scroll: usize,
|
||
test: impl FnOnce(&crate::terminal::TerminalRuntime, &mut mpsc::Receiver<Bytes>),
|
||
) {
|
||
let rt = tokio::runtime::Builder::new_current_thread()
|
||
.enable_all()
|
||
.build()
|
||
.expect("test runtime");
|
||
let _runtime_guard = rt.enter();
|
||
let mut bytes = initial_bytes.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,
|
||
);
|
||
if initial_scroll > 0 {
|
||
runtime.scroll_up(initial_scroll);
|
||
}
|
||
|
||
test(&runtime, &mut input_rx);
|
||
|
||
drop(runtime);
|
||
drop(_runtime_guard);
|
||
rt.shutdown_timeout(Duration::from_millis(100));
|
||
}
|
||
|
||
fn apply_terminal_attach_page_up(runtime: &crate::terminal::TerminalRuntime) {
|
||
apply_terminal_attach_scroll(
|
||
runtime,
|
||
AttachScrollSource::PageKey {
|
||
input: b"\x1b[5~".to_vec(),
|
||
},
|
||
AttachScrollDirection::Up,
|
||
4,
|
||
None,
|
||
None,
|
||
0,
|
||
)
|
||
.expect("page key");
|
||
}
|
||
|
||
#[test]
|
||
fn terminal_attach_page_key_host_scrolls_plain_terminal() {
|
||
with_terminal_attach_page_key_runtime(b"", 0, |runtime, input_rx| {
|
||
apply_terminal_attach_page_up(runtime);
|
||
|
||
assert_eq!(
|
||
runtime
|
||
.scroll_metrics()
|
||
.expect("scroll metrics")
|
||
.offset_from_bottom,
|
||
4
|
||
);
|
||
assert!(input_rx.try_recv().is_err());
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
fn terminal_attach_page_key_forwards_when_mouse_reporting() {
|
||
with_terminal_attach_page_key_runtime(b"\x1b[?1000h", 3, |runtime, input_rx| {
|
||
apply_terminal_attach_page_up(runtime);
|
||
|
||
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~")
|
||
);
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
fn terminal_attach_page_key_forwards_when_application_cursor() {
|
||
with_terminal_attach_page_key_runtime(b"\x1b[?1h", 3, |runtime, input_rx| {
|
||
apply_terminal_attach_page_up(runtime);
|
||
|
||
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~")
|
||
);
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
fn terminal_attach_page_key_host_scrolls_shell_like_decckm_with_bracketed_paste() {
|
||
with_terminal_attach_page_key_runtime(b"\x1b[?1h\x1b[?2004h", 0, |runtime, input_rx| {
|
||
apply_terminal_attach_page_up(runtime);
|
||
|
||
assert_eq!(
|
||
runtime
|
||
.scroll_metrics()
|
||
.expect("scroll metrics")
|
||
.offset_from_bottom,
|
||
4
|
||
);
|
||
assert!(input_rx.try_recv().is_err());
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
fn terminal_attach_page_key_forwards_in_alternate_screen_without_mouse_reporting() {
|
||
with_terminal_attach_page_key_runtime(b"\x1b[?1049h", 3, |runtime, input_rx| {
|
||
apply_terminal_attach_page_up(runtime);
|
||
|
||
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~")
|
||
);
|
||
});
|
||
}
|
||
|
||
#[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: "custom:pi".into(),
|
||
agent_label: "pi".into(),
|
||
state: crate::detect::AgentState::Working,
|
||
message: 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("custom:pi".into()),
|
||
title: Some("short lived".into()),
|
||
display_agent: None,
|
||
state_labels: HashMap::new(),
|
||
clear_title: false,
|
||
clear_display_agent: 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_title()
|
||
.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_title(),
|
||
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 {
|
||
title,
|
||
..
|
||
} if title.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,
|
||
}),
|
||
..Default::default()
|
||
};
|
||
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());
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
#[tokio::test]
|
||
async fn headless_scheduled_tasks_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.workspaces = vec![workspace];
|
||
server.app.state.active = Some(0);
|
||
server.app.state.ensure_test_terminals();
|
||
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.render_and_stream();
|
||
assert_ne!(server.app.state.view.terminal_area, Rect::default());
|
||
|
||
let now = Instant::now();
|
||
assert!(!server.handle_scheduled_tasks_headless(now, false));
|
||
assert!(server.app.terminal_runtimes.get(&terminal_id).is_none());
|
||
let deadline = server
|
||
.app
|
||
.pending_agent_resume_deadline
|
||
.expect("clientless resume should wait briefly for a host theme");
|
||
|
||
assert!(server.handle_scheduled_tasks_headless(deadline, false));
|
||
assert!(server.app.terminal_runtimes.get(&terminal_id).is_some());
|
||
assert!(server
|
||
.app
|
||
.state
|
||
.terminals
|
||
.get(&terminal_id)
|
||
.expect("test terminal should still exist")
|
||
.pending_agent_resume_plan
|
||
.is_none());
|
||
shutdown_test_runtimes(&mut server);
|
||
}
|
||
|
||
#[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,
|
||
}),
|
||
..Default::default()
|
||
};
|
||
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,
|
||
}),
|
||
..Default::default()
|
||
},
|
||
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,
|
||
}),
|
||
..Default::default()
|
||
},
|
||
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,
|
||
}),
|
||
..Default::default()
|
||
};
|
||
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 }),
|
||
..Default::default()
|
||
},
|
||
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,
|
||
}),
|
||
..Default::default()
|
||
},
|
||
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,
|
||
}),
|
||
..Default::default()
|
||
};
|
||
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));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn foreground_focus_gained_reaches_pane_with_focus_reporting() {
|
||
let mut server = test_headless_server();
|
||
let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[?1004h");
|
||
|
||
server.clients.insert(1, test_app_client(Some(false), 1));
|
||
server.foreground_client_id = Some(1);
|
||
server.sync_foreground_client_state();
|
||
|
||
assert!(server.handle_server_event(ServerEvent::ClientInput {
|
||
client_id: 1,
|
||
data: b"\x1b[I".to_vec(),
|
||
}));
|
||
assert_eq!(
|
||
input_rx.try_recv().expect("forwarded focus gained report"),
|
||
Bytes::from_static(b"\x1b[I")
|
||
);
|
||
|
||
assert!(!server.handle_server_event(ServerEvent::ClientInput {
|
||
client_id: 1,
|
||
data: b"\x1b[O".to_vec(),
|
||
}));
|
||
assert_eq!(
|
||
input_rx.try_recv().expect("forwarded focus lost report"),
|
||
Bytes::from_static(b"\x1b[O")
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn outer_focus_events_do_not_reach_pane_without_focus_reporting() {
|
||
let mut server = test_headless_server();
|
||
let mut input_rx = install_focused_test_runtime(&mut server, b"");
|
||
server.clients.insert(1, test_app_client(Some(false), 1));
|
||
server.foreground_client_id = Some(1);
|
||
server.sync_foreground_client_state();
|
||
|
||
assert!(server.handle_server_event(ServerEvent::ClientInput {
|
||
client_id: 1,
|
||
data: b"\x1b[I".to_vec(),
|
||
}));
|
||
assert!(matches!(
|
||
input_rx.try_recv(),
|
||
Err(tokio::sync::mpsc::error::TryRecvError::Empty)
|
||
));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn background_focus_batch_only_forwards_events_after_promotion() {
|
||
let mut server = test_headless_server();
|
||
let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[?1004h");
|
||
server.clients.insert(1, test_app_client(Some(true), 1));
|
||
server.clients.insert(2, test_app_client(Some(false), 2));
|
||
server.foreground_client_id = Some(1);
|
||
server.sync_foreground_client_state();
|
||
|
||
assert!(server.handle_server_event(ServerEvent::ClientInput {
|
||
client_id: 2,
|
||
data: b"\x1b[O\x1b[I".to_vec(),
|
||
}));
|
||
assert_eq!(server.foreground_client_id, Some(2));
|
||
assert_eq!(server.app.state.outer_terminal_focus, Some(true));
|
||
assert_eq!(
|
||
input_rx
|
||
.try_recv()
|
||
.expect("focus gained after client promotion"),
|
||
Bytes::from_static(b"\x1b[I")
|
||
);
|
||
assert!(matches!(
|
||
input_rx.try_recv(),
|
||
Err(tokio::sync::mpsc::error::TryRecvError::Empty)
|
||
));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn background_client_focus_loss_releases_its_owned_keys() {
|
||
let mut server = test_headless_server();
|
||
let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[>15u");
|
||
server.clients.insert(1, test_app_client(Some(true), 1));
|
||
server.clients.insert(2, test_app_client(Some(true), 2));
|
||
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::Char('j'),
|
||
modifiers: 0,
|
||
kind: crate::protocol::ClientKeyKind::Press,
|
||
|
||
repeat_count: 1,
|
||
generated_text: None,
|
||
source: crate::protocol::ClientKeySource::Synthesized,
|
||
}],
|
||
}));
|
||
server.foreground_client_id = Some(2);
|
||
server.sync_foreground_client_state();
|
||
|
||
assert!(!server.handle_server_event(ServerEvent::ClientInputEvents {
|
||
client_id: 1,
|
||
events: vec![crate::protocol::ClientInputEvent::FocusLost],
|
||
}));
|
||
assert_eq!(
|
||
input_rx.try_recv().expect("forwarded press"),
|
||
Bytes::from_static(b"\x1b[106;1:1u")
|
||
);
|
||
assert_eq!(
|
||
input_rx
|
||
.try_recv()
|
||
.expect("synthetic release from background client"),
|
||
Bytes::from_static(b"\x1b[106;1:3u")
|
||
);
|
||
assert!(server.app.input_leases.is_empty());
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn structured_outer_focus_events_reach_reporting_pane() {
|
||
let mut server = test_headless_server();
|
||
let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[?1004h");
|
||
server.clients.insert(1, test_app_client(Some(true), 1));
|
||
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::FocusGained,
|
||
crate::protocol::ClientInputEvent::FocusLost,
|
||
],
|
||
}));
|
||
assert_eq!(
|
||
input_rx.try_recv().expect("structured focus gained report"),
|
||
Bytes::from_static(b"\x1b[I")
|
||
);
|
||
assert_eq!(
|
||
input_rx.try_recv().expect("structured focus lost report"),
|
||
Bytes::from_static(b"\x1b[O")
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn background_key_makes_later_focus_lost_eligible() {
|
||
let mut server = test_headless_server();
|
||
let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[?1004h");
|
||
server.clients.insert(1, test_app_client(Some(true), 1));
|
||
server.clients.insert(2, test_app_client(Some(true), 2));
|
||
server.foreground_client_id = Some(1);
|
||
server.sync_foreground_client_state();
|
||
|
||
assert!(server.handle_server_event(ServerEvent::ClientInputEvents {
|
||
client_id: 2,
|
||
events: vec![
|
||
crate::protocol::ClientInputEvent::Key {
|
||
code: crate::protocol::ClientKeyCode::Char('x'),
|
||
modifiers: 0,
|
||
kind: crate::protocol::ClientKeyKind::Release,
|
||
|
||
repeat_count: 1,
|
||
generated_text: None,
|
||
source: crate::protocol::ClientKeySource::Synthesized,
|
||
},
|
||
crate::protocol::ClientInputEvent::FocusLost,
|
||
],
|
||
}));
|
||
assert_eq!(server.foreground_client_id, Some(2));
|
||
assert_eq!(
|
||
input_rx.try_recv().expect("focus lost after promotion"),
|
||
Bytes::from_static(b"\x1b[O")
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn structured_non_app_focus_is_ignored_without_suppressing_keys() {
|
||
let mut server = test_headless_server();
|
||
let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[?1004h");
|
||
server.clients.insert(1, test_app_client(Some(true), 1));
|
||
|
||
let mut attached = test_app_client(Some(false), 2);
|
||
attached.mode = ClientConnectionMode::TerminalAttach {
|
||
terminal_id: "attached".to_owned(),
|
||
};
|
||
server.clients.insert(2, attached);
|
||
|
||
let mut pending = test_app_client(Some(false), 3);
|
||
pending.pending_terminal_attach = true;
|
||
server.clients.insert(3, pending);
|
||
server.foreground_client_id = Some(1);
|
||
server.sync_foreground_client_state();
|
||
|
||
for client_id in [2, 3] {
|
||
assert!(!server.handle_server_event(ServerEvent::ClientInputEvents {
|
||
client_id,
|
||
events: vec![crate::protocol::ClientInputEvent::FocusGained],
|
||
}));
|
||
assert_eq!(server.foreground_client_id, Some(1));
|
||
assert_eq!(server.app.state.outer_terminal_focus, Some(true));
|
||
assert_eq!(server.clients[&client_id].outer_terminal_focus, Some(false));
|
||
}
|
||
|
||
assert!(matches!(
|
||
input_rx.try_recv(),
|
||
Err(tokio::sync::mpsc::error::TryRecvError::Empty)
|
||
));
|
||
|
||
assert!(server.handle_server_event(ServerEvent::ClientInputEvents {
|
||
client_id: 3,
|
||
events: vec![crate::protocol::ClientInputEvent::Key {
|
||
code: crate::protocol::ClientKeyCode::Char('x'),
|
||
modifiers: 0,
|
||
kind: crate::protocol::ClientKeyKind::Release,
|
||
|
||
repeat_count: 1,
|
||
generated_text: None,
|
||
source: crate::protocol::ClientKeySource::Synthesized,
|
||
}],
|
||
}));
|
||
assert_eq!(server.foreground_client_id, Some(3));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn passive_mouse_motion_forwards_without_requesting_render() {
|
||
let mut server = test_headless_server();
|
||
let mut input_rx = install_focused_test_runtime(&mut server, b"\x1b[?1003h\x1b[?1006h");
|
||
server.clients.insert(1, test_app_client(Some(true), 1));
|
||
server.foreground_client_id = Some(1);
|
||
server.sync_foreground_client_state();
|
||
server.resize_shared_runtime_to_effective_size();
|
||
let baseline = FrameData {
|
||
cells: Vec::new(),
|
||
width: 0,
|
||
height: 0,
|
||
cursor: None,
|
||
hyperlinks: Vec::new(),
|
||
graphics: Vec::new(),
|
||
};
|
||
let client = server.clients.get_mut(&1).unwrap();
|
||
let prepared = client
|
||
.render_state
|
||
.prepare_frame(baseline.clone())
|
||
.expect("new semantic baseline");
|
||
client.render_state.commit_sent_frame(prepared);
|
||
let pane = server.app.state.view.pane_infos[0].clone();
|
||
let column = pane.inner_rect.x + 2;
|
||
let row = pane.inner_rect.y + 3;
|
||
let input = format!("\x1b[<35;{};{}M", column + 1, row + 1).into_bytes();
|
||
|
||
assert!(!server.handle_server_event(ServerEvent::ClientInput {
|
||
client_id: 1,
|
||
data: input,
|
||
}));
|
||
assert_eq!(
|
||
input_rx.try_recv().expect("forwarded mouse motion"),
|
||
Bytes::from_static(b"\x1b[<35;3;4M")
|
||
);
|
||
assert_eq!(
|
||
server.clients[&1].render_state.last_frame(),
|
||
Some(&baseline)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn background_mouse_motion_promotes_once_then_becomes_render_neutral() {
|
||
let mut server = test_headless_server();
|
||
server.app.state.mode = crate::app::Mode::Terminal;
|
||
server.clients.insert(1, test_app_client(Some(true), 1));
|
||
server.clients.insert(2, test_app_client(Some(true), 2));
|
||
server.foreground_client_id = Some(1);
|
||
server.sync_foreground_client_state();
|
||
let motion = || ServerEvent::ClientInputEvents {
|
||
client_id: 2,
|
||
events: vec![crate::protocol::ClientInputEvent::Mouse {
|
||
kind: crate::protocol::ClientMouseKind::Moved,
|
||
column: 10,
|
||
row: 5,
|
||
modifiers: 0,
|
||
}],
|
||
};
|
||
|
||
assert!(server.handle_server_event(motion()));
|
||
assert_eq!(server.foreground_client_id, Some(2));
|
||
assert!(!server.handle_server_event(motion()));
|
||
}
|
||
|
||
#[test]
|
||
fn mouse_motion_in_hover_modes_requires_render() {
|
||
let events = [crate::raw_input::RawInputEvent::Mouse(
|
||
crossterm::event::MouseEvent {
|
||
kind: MouseEventKind::Moved,
|
||
column: 10,
|
||
row: 5,
|
||
modifiers: KeyModifiers::empty(),
|
||
},
|
||
)];
|
||
|
||
assert!(events_are_render_neutral_mouse_motion(
|
||
&events,
|
||
crate::app::Mode::Terminal
|
||
));
|
||
for mode in [
|
||
crate::app::Mode::GlobalMenu,
|
||
crate::app::Mode::ContextMenu,
|
||
crate::app::Mode::Navigator,
|
||
] {
|
||
assert!(!events_are_render_neutral_mouse_motion(&events, mode));
|
||
}
|
||
}
|
||
|
||
fn install_focused_test_runtime(
|
||
server: &mut HeadlessServer,
|
||
terminal_bytes: &[u8],
|
||
) -> tokio::sync::mpsc::Receiver<Bytes> {
|
||
let mut workspace = crate::workspace::Workspace::test_new("focus-reporting");
|
||
let pane_id = workspace.tabs[0].root_pane;
|
||
let (runtime, input_rx) =
|
||
crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
|
||
80,
|
||
24,
|
||
0,
|
||
terminal_bytes,
|
||
4,
|
||
);
|
||
workspace.insert_test_runtime(pane_id, 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;
|
||
input_rx
|
||
}
|
||
|
||
fn test_app_client(outer_terminal_focus: Option<bool>, last_activity: u64) -> ClientConnection {
|
||
ClientConnection::new(
|
||
(80, 24),
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
crate::terminal_theme::TerminalTheme::default(),
|
||
outer_terminal_focus,
|
||
last_activity,
|
||
RenderEncoding::SemanticFrame,
|
||
None,
|
||
)
|
||
}
|
||
|
||
#[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,
|
||
|
||
repeat_count: 1,
|
||
generated_text: None,
|
||
source: crate::protocol::ClientKeySource::Synthesized,
|
||
}],
|
||
}));
|
||
|
||
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,
|
||
|
||
repeat_count: 1,
|
||
generated_text: None,
|
||
source: crate::protocol::ClientKeySource::Synthesized,
|
||
}],
|
||
}));
|
||
|
||
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,
|
||
|
||
repeat_count: 1,
|
||
generated_text: None,
|
||
source: crate::protocol::ClientKeySource::Synthesized,
|
||
}],
|
||
}));
|
||
|
||
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,
|
||
})
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn render_and_stream_uses_each_client_terminal_size() {
|
||
let mut server = test_headless_server();
|
||
let mut workspace = crate::workspace::Workspace::test_new("test");
|
||
let active_pane = workspace.tabs[0].root_pane;
|
||
let background_tab = workspace.test_add_tab(Some("background"));
|
||
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"active"),
|
||
);
|
||
workspace.tabs[background_tab].runtimes.insert(
|
||
background_pane,
|
||
crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b"background"),
|
||
);
|
||
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 (desktop_tx, _desktop_control_rx, desktop_rx) = test_client_writer();
|
||
let (mobile_tx, _mobile_control_rx, mobile_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(
|
||
(44, 20),
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
crate::terminal_theme::TerminalTheme::default(),
|
||
None,
|
||
2,
|
||
RenderEncoding::SemanticFrame,
|
||
Some(mobile_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 mobile_frame = read_server_frame(mobile_rx.recv().expect("mobile frame"));
|
||
|
||
assert_eq!((desktop_frame.width, desktop_frame.height), (120, 40));
|
||
assert_eq!((mobile_frame.width, mobile_frame.height), (44, 20));
|
||
let mobile_text = frame_text(&mobile_frame);
|
||
let mut mobile_rows = mobile_text.lines();
|
||
let mobile_header = mobile_rows.by_ref().take(2).collect::<String>();
|
||
let mobile_surface = mobile_rows.collect::<String>();
|
||
assert!(mobile_header.contains("test"), "header: {mobile_header:?}");
|
||
assert!(
|
||
mobile_surface.contains("active"),
|
||
"surface: {mobile_surface:?}"
|
||
);
|
||
assert!(!mobile_surface.contains("background"));
|
||
|
||
let foreground_terminal_area = Rect::new(26, 1, 94, 39);
|
||
let expected_pane_size = (
|
||
foreground_terminal_area.height,
|
||
foreground_terminal_area.width.saturating_sub(1),
|
||
);
|
||
assert_eq!(
|
||
server.app.state.view.layout,
|
||
crate::app::state::ViewLayout::Desktop
|
||
);
|
||
assert_eq!(server.app.state.view.mobile_header_rect, Rect::default());
|
||
assert_eq!(
|
||
server.app.state.view.terminal_area,
|
||
foreground_terminal_area
|
||
);
|
||
assert_eq!(
|
||
server.app.state.workspaces[0].tabs[0].runtimes[&active_pane].current_size(),
|
||
expected_pane_size
|
||
);
|
||
assert_eq!(
|
||
server.app.state.workspaces[0].tabs[background_tab].runtimes[&background_pane]
|
||
.current_size(),
|
||
expected_pane_size
|
||
);
|
||
}
|
||
|
||
#[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_repaints_terminal_ansi_without_clearing() {
|
||
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);
|
||
assert!(!frame.bytes.windows(4).any(|bytes| bytes == b"\x1b[2J"));
|
||
}
|
||
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_eq!(
|
||
server.clients.get(&1).unwrap().deferred_render(),
|
||
DeferredRender::Full
|
||
);
|
||
|
||
server.render_and_stream();
|
||
|
||
assert_eq!(
|
||
server.clients.get(&1).unwrap().deferred_render(),
|
||
DeferredRender::Full
|
||
);
|
||
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_eq!(
|
||
server.clients.get(&1).unwrap().deferred_render(),
|
||
DeferredRender::None
|
||
);
|
||
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_eq!(
|
||
server.clients.get(&1).unwrap().deferred_render(),
|
||
DeferredRender::None
|
||
);
|
||
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_eq!(
|
||
server.clients.get(&1).unwrap().deferred_render(),
|
||
DeferredRender::Full
|
||
);
|
||
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_eq!(
|
||
server.clients.get(&1).unwrap().deferred_render(),
|
||
DeferredRender::None
|
||
);
|
||
}
|
||
|
||
#[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"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn inactive_tab_pty_source_is_hidden_until_tab_focus() {
|
||
let (server, background_pane) = hidden_pty_visibility_test_server(&[]);
|
||
let sources = HashSet::from([background_pane]);
|
||
assert!(!server.pty_sources_visible_to_any_render_target(&sources));
|
||
|
||
let (mut server, background_pane) =
|
||
hidden_pty_visibility_test_server(&[(120, 40), (44, 20)]);
|
||
let sources = HashSet::from([background_pane]);
|
||
assert!(!server.pty_sources_visible_to_any_render_target(&sources));
|
||
|
||
server.app.state.workspaces[0].switch_tab(1);
|
||
assert!(server.pty_sources_visible_to_any_render_target(&sources));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn hidden_pty_output_appears_after_switching_to_its_tab() {
|
||
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 background_pane = workspace.tabs[background_tab].root_pane;
|
||
workspace.insert_test_runtime(
|
||
background_pane,
|
||
crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b"before"),
|
||
);
|
||
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.render_and_stream();
|
||
let _initial_frame = 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, background_pane)
|
||
.expect("background runtime");
|
||
runtime.test_process_pty_bytes(b"\rhidden-update");
|
||
assert!(server.app.render_dirty.request_pty(background_pane));
|
||
let request = server.app.render_dirty.take();
|
||
let pty = if server.pty_sources_visible_to_any_render_target(&request.pty_sources) {
|
||
PtyRenderState::Visible
|
||
} else {
|
||
PtyRenderState::Hidden
|
||
};
|
||
assert_eq!(
|
||
retained_render_plan(RetainedRenderInput {
|
||
needs_full_render: false,
|
||
needs_graphics_render: false,
|
||
pty,
|
||
}),
|
||
RetainedRenderPlan::HiddenPty
|
||
);
|
||
assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err());
|
||
|
||
server.app.state.workspaces[0].switch_tab(background_tab);
|
||
server.render_and_stream();
|
||
let visible_frame = read_server_frame(
|
||
client_rx
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.expect("frame after tab switch"),
|
||
);
|
||
assert!(frame_text(&visible_frame).contains("hidden-update"));
|
||
}
|
||
|
||
#[test]
|
||
fn direct_terminal_observer_keeps_hidden_pty_source_renderable() {
|
||
let (mut server, background_pane) = hidden_pty_visibility_test_server(&[]);
|
||
let terminal_id = server.app.state.workspaces[0]
|
||
.terminal_id(background_pane)
|
||
.expect("background terminal id")
|
||
.to_string();
|
||
let (client_tx, _client_control_rx, _client_rx) = test_client_writer();
|
||
server.clients.insert(
|
||
1,
|
||
ClientConnection::new_with_mode(
|
||
ClientConnectionMode::TerminalObserve { terminal_id },
|
||
None,
|
||
(80, 24),
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
crate::terminal_theme::TerminalTheme::default(),
|
||
None,
|
||
1,
|
||
RenderEncoding::SemanticFrame,
|
||
false,
|
||
Some(client_tx),
|
||
),
|
||
);
|
||
|
||
assert!(server.pty_sources_visible_to_any_render_target(&HashSet::from([background_pane])));
|
||
}
|
||
|
||
#[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_popup_is_visible() {
|
||
let (mut server, client_rx, _) = retained_test_server(b"tiled");
|
||
let popup_runtime =
|
||
crate::terminal::TerminalRuntime::test_with_screen_bytes(40, 12, b"popup-aaaa");
|
||
let (_, terminal_id) = server.app.install_test_popup_runtime(popup_runtime);
|
||
|
||
server.render_and_stream();
|
||
let initial = read_server_frame(
|
||
client_rx
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.expect("initial popup frame"),
|
||
);
|
||
assert!(frame_text(&initial).contains("popup-aaaa"));
|
||
server
|
||
.app
|
||
.terminal_runtimes
|
||
.get(&terminal_id)
|
||
.unwrap()
|
||
.test_process_pty_bytes(b"\rZ");
|
||
|
||
assert!(!server.render_retained_pty_update_and_stream());
|
||
server.render_and_stream();
|
||
let updated = read_server_frame(
|
||
client_rx
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.expect("full popup fallback frame"),
|
||
);
|
||
assert!(frame_text(&updated).contains("Zopup-aaaa"));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn popup_forces_host_mouse_capture_for_headless_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.app.state.mouse_capture = false;
|
||
let popup_runtime =
|
||
crate::terminal::TerminalRuntime::test_with_screen_bytes(40, 12, b"popup");
|
||
server.app.install_test_popup_runtime(popup_runtime);
|
||
|
||
server.stream_host_mouse_capture_mode();
|
||
|
||
assert!(matches!(
|
||
read_server_message(
|
||
client_control_rx
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.expect("mouse capture message")
|
||
),
|
||
ServerMessage::MouseCapture { enabled: true }
|
||
));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn command_mode_updates_headless_client_keyboard_flags() {
|
||
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.mode = crate::app::Mode::Prefix;
|
||
server.stream_host_keyboard_enhancement_flags();
|
||
assert!(matches!(
|
||
read_server_message(
|
||
client_control_rx
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.expect("command-mode keyboard enhancement message")
|
||
),
|
||
ServerMessage::KittyKeyboardReportAll { enabled: true }
|
||
));
|
||
|
||
server.app.state.mode = crate::app::Mode::Terminal;
|
||
server.stream_host_keyboard_enhancement_flags();
|
||
assert!(matches!(
|
||
read_server_message(
|
||
client_control_rx
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.expect("IME-compatible keyboard enhancement message")
|
||
),
|
||
ServerMessage::KittyKeyboardReportAll { enabled: false }
|
||
));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn focused_report_all_pane_updates_headless_client_keyboard_flags() {
|
||
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),
|
||
),
|
||
);
|
||
let popup_runtime =
|
||
crate::terminal::TerminalRuntime::test_with_screen_bytes(40, 12, b"\x1b[>15u");
|
||
server.app.install_test_popup_runtime(popup_runtime);
|
||
|
||
server.stream_host_keyboard_enhancement_flags();
|
||
|
||
assert!(matches!(
|
||
read_server_message(
|
||
client_control_rx
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.expect("keyboard enhancement message")
|
||
),
|
||
ServerMessage::KittyKeyboardReportAll { enabled: true }
|
||
));
|
||
|
||
assert!(server.app.close_popup_pane());
|
||
server.app.state.mode = crate::app::Mode::Terminal;
|
||
server.stream_host_keyboard_enhancement_flags();
|
||
assert!(matches!(
|
||
read_server_message(
|
||
client_control_rx
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.expect("IME-compatible keyboard enhancement message")
|
||
),
|
||
ServerMessage::KittyKeyboardReportAll { enabled: false }
|
||
));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn virtual_render_uses_popup_cursor() {
|
||
let (mut server, _client_rx, _) = retained_test_server(b"\x1b[2;2H");
|
||
let popup_runtime =
|
||
crate::terminal::TerminalRuntime::test_with_screen_bytes(40, 12, b"\x1b[4;5H");
|
||
let (_, terminal_id) = server.app.install_test_popup_runtime(popup_runtime);
|
||
|
||
let (_, cursor) = crate::server::render_stream::render_virtual_with_runtime_registry(
|
||
&mut server.app.state,
|
||
&server.app.terminal_runtimes,
|
||
ratatui::layout::Rect::new(0, 0, 80, 24),
|
||
true,
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
);
|
||
let (_, inner) =
|
||
crate::ui::popup_pane_rects(&server.app.state, server.app.state.view.terminal_area)
|
||
.unwrap();
|
||
let expected = server
|
||
.app
|
||
.terminal_runtimes
|
||
.get(&terminal_id)
|
||
.unwrap()
|
||
.cursor_state(inner, true)
|
||
.unwrap();
|
||
|
||
assert_eq!(
|
||
cursor,
|
||
Some(crate::protocol::CursorState {
|
||
x: expected.x,
|
||
y: expected.y,
|
||
visible: expected.visible,
|
||
shape: expected.shape,
|
||
})
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn virtual_render_does_not_resize_directly_attached_popup() {
|
||
let (mut server, _client_rx, _) = retained_test_server(b"tiled");
|
||
let popup_runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(50, 13, b"");
|
||
let (_, terminal_id) = server.app.install_test_popup_runtime(popup_runtime);
|
||
server
|
||
.app
|
||
.state
|
||
.direct_attach_resize_locks
|
||
.insert(terminal_id.clone());
|
||
|
||
let _ = crate::server::render_stream::render_virtual_with_runtime_registry(
|
||
&mut server.app.state,
|
||
&server.app.terminal_runtimes,
|
||
ratatui::layout::Rect::new(0, 0, 80, 24),
|
||
true,
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
);
|
||
|
||
assert_eq!(
|
||
server
|
||
.app
|
||
.terminal_runtimes
|
||
.get(&terminal_id)
|
||
.unwrap()
|
||
.current_size(),
|
||
(13, 50)
|
||
);
|
||
}
|
||
|
||
#[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_declines_while_copy_feedback_is_visible() {
|
||
let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa");
|
||
server.app.state.copy_feedback = Some(crate::app::state::CopyFeedback {
|
||
message: "copied to clipboard".to_owned(),
|
||
});
|
||
server.render_and_stream();
|
||
let initial = read_server_frame(
|
||
client_rx
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.expect("initial frame"),
|
||
);
|
||
let initial_text = frame_text(&initial);
|
||
assert!(
|
||
initial_text.contains("copied to clipboard"),
|
||
"expected initial full frame to include copy feedback"
|
||
);
|
||
|
||
let feedback_row = initial_text
|
||
.lines()
|
||
.position(|line| line.contains("copied to clipboard"))
|
||
.expect("copy feedback row") as u16;
|
||
let inner_rect = server.app.state.view.pane_infos[0].inner_rect;
|
||
let pane_row = feedback_row
|
||
.checked_sub(inner_rect.y)
|
||
.expect("copy feedback 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 copy feedback 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_dirty_row_that_creates_plain_url() {
|
||
let (mut server, client_rx, pane_id) = retained_test_server(b"plain");
|
||
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"\rhttps://example.com/new");
|
||
|
||
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 plain URL"),
|
||
);
|
||
assert!(
|
||
patched.hyperlinks.is_empty(),
|
||
"retained render should not synthesize plain URL hyperlink metadata"
|
||
);
|
||
}
|
||
|
||
#[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_eq!(
|
||
server.clients.get(&1).unwrap().deferred_render(),
|
||
DeferredRender::Full
|
||
);
|
||
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 prefix_input_source_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();
|
||
// Drain any setup messages (e.g. mouse-capture sync) before exercising the event.
|
||
while foreground_control_rx
|
||
.recv_timeout(Duration::from_millis(20))
|
||
.is_ok()
|
||
{}
|
||
|
||
let changed = server
|
||
.handle_internal_event_with_forwarding(AppEvent::PrefixInputSource { active: true });
|
||
|
||
assert!(changed);
|
||
match read_server_message(
|
||
foreground_control_rx
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.expect("foreground prefix input-source message"),
|
||
) {
|
||
ServerMessage::PrefixInputSource { active } => assert!(active),
|
||
other => panic!("expected prefix input-source message, got {other:?}"),
|
||
}
|
||
assert!(
|
||
background_control_rx
|
||
.recv_timeout(Duration::from_millis(50))
|
||
.is_err(),
|
||
"background client should not receive prefix input-source changes"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn headless_app_keeps_prefix_input_source_switch_off_process() {
|
||
// An App-internal drain (e.g. the exhaustive drain at the top of
|
||
// handle_api_request) can consume a queued PrefixInputSource intent
|
||
// before the forwarding drain sees it. The headless App must treat the
|
||
// event as inert instead of switching the host input source from the
|
||
// server process.
|
||
struct CountingPrefixInputSource(std::rc::Rc<std::cell::Cell<usize>>);
|
||
impl crate::platform::PrefixInputSource for CountingPrefixInputSource {
|
||
fn switch_to_ascii(&mut self) {
|
||
self.0.set(self.0.get() + 1);
|
||
}
|
||
fn restore(&mut self) {
|
||
self.0.set(self.0.get() + 1);
|
||
}
|
||
}
|
||
|
||
let mut server = test_headless_server();
|
||
let calls = std::rc::Rc::new(std::cell::Cell::new(0));
|
||
server
|
||
.app
|
||
.set_prefix_input_source(Box::new(CountingPrefixInputSource(calls.clone())));
|
||
|
||
server
|
||
.app
|
||
.handle_internal_event(AppEvent::PrefixInputSource { active: true });
|
||
server
|
||
.app
|
||
.handle_internal_event(AppEvent::PrefixInputSource { active: false });
|
||
assert_eq!(
|
||
calls.get(),
|
||
0,
|
||
"headless server must not apply the host input-source switch"
|
||
);
|
||
|
||
// Sanity: the same event does apply once the flag is on (monolithic semantics).
|
||
server.app.local_input_source_switch = true;
|
||
server
|
||
.app
|
||
.handle_internal_event(AppEvent::PrefixInputSource { active: true });
|
||
assert_eq!(calls.get(), 1);
|
||
}
|
||
|
||
#[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 oversized_paste_rejection_notifies_only_the_sending_client() {
|
||
let mut server = test_headless_server();
|
||
let (sender_writer, sender_control_rx, _sender_render_rx) = test_client_writer();
|
||
let (foreground_writer, foreground_control_rx, _foreground_render_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(sender_writer),
|
||
),
|
||
);
|
||
server.clients.insert(
|
||
2,
|
||
ClientConnection::new(
|
||
(80, 24),
|
||
crate::kitty_graphics::HostCellSize::default(),
|
||
crate::terminal_theme::TerminalTheme::default(),
|
||
None,
|
||
2,
|
||
RenderEncoding::SemanticFrame,
|
||
Some(foreground_writer),
|
||
),
|
||
);
|
||
server.foreground_client_id = Some(2);
|
||
server.sync_foreground_client_state();
|
||
|
||
assert!(
|
||
!server.handle_server_event(ServerEvent::ClientPasteRejected {
|
||
client_id: 1,
|
||
size: 5_000_012,
|
||
max: 1_048_576,
|
||
})
|
||
);
|
||
|
||
match read_server_message(
|
||
sender_control_rx
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.expect("sending client rejection notification"),
|
||
) {
|
||
ServerMessage::Notify {
|
||
kind,
|
||
message,
|
||
body,
|
||
} => {
|
||
assert_eq!(kind, protocol::NotifyKind::Toast);
|
||
assert_eq!(message, "Paste rejected");
|
||
assert_eq!(
|
||
body.as_deref(),
|
||
Some("Input message is 5000012 bytes; Herdr's limit is 1048576 bytes")
|
||
);
|
||
}
|
||
other => panic!("expected paste rejection notification, got {other:?}"),
|
||
}
|
||
assert!(
|
||
foreground_control_rx
|
||
.recv_timeout(Duration::from_millis(50))
|
||
.is_err(),
|
||
"foreground client must not receive another client's rejection"
|
||
);
|
||
assert_eq!(server.foreground_client_id, Some(2));
|
||
assert_eq!(server.clients.len(), 2);
|
||
assert!(server.app.state.toast.is_none());
|
||
}
|
||
|
||
#[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,
|
||
response_write_complete: None,
|
||
});
|
||
|
||
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,
|
||
response_write_complete: None,
|
||
});
|
||
|
||
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,
|
||
response_write_complete: None,
|
||
});
|
||
|
||
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,
|
||
response_write_complete: None,
|
||
});
|
||
|
||
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,
|
||
response_write_complete: None,
|
||
})
|
||
);
|
||
|
||
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,
|
||
response_write_complete: None,
|
||
})
|
||
);
|
||
|
||
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_detected_state(
|
||
Some(crate::detect::Agent::Pi),
|
||
crate::detect::AgentState::Idle,
|
||
);
|
||
server
|
||
.app
|
||
.state
|
||
.terminals
|
||
.get_mut(&terminal_id)
|
||
.unwrap()
|
||
.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession {
|
||
source: "herdr:pi".into(),
|
||
agent: "pi".into(),
|
||
session_ref: crate::agent_resume::AgentSessionRef::path(
|
||
std::env::current_dir()
|
||
.unwrap()
|
||
.join("headless-pi-session.jsonl")
|
||
.display()
|
||
.to_string(),
|
||
)
|
||
.unwrap(),
|
||
});
|
||
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,
|
||
seq: Some(19),
|
||
agent_session_id: None,
|
||
agent_session_path: None,
|
||
}),
|
||
},
|
||
respond_to,
|
||
response_write_complete: None,
|
||
});
|
||
|
||
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`
|
||
/// (or its `handle_internal_event_with_prefix_sync` wrapper) 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.contains("self.app.handle_internal_event_with_prefix_sync("))
|
||
&& !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 ")
|
||
);
|
||
}
|
||
}
|