aboutsummaryrefslogtreecommitdiff
path: root/src/server/headless.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/server/headless.rs')
-rw-r--r--src/server/headless.rs10126
1 files changed, 860 insertions, 9266 deletions
diff --git a/src/server/headless.rs b/src/server/headless.rs
index 7b6461b7..6bae0a98 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -19,9 +19,8 @@ use std::io;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
-use std::time::{Duration, Instant};
+use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
-use crossterm::event::{KeyModifiers, MouseEventKind};
use interprocess::local_socket::traits::Listener as _;
#[cfg(windows)]
use interprocess::local_socket::traits::Stream as _;
@@ -29,7 +28,9 @@ use interprocess::local_socket::traits::Stream as _;
use interprocess::local_socket::ListenerNonblockingMode;
use ratatui::layout::Rect;
use tokio::sync::mpsc;
-use tracing::{debug, error, info, warn};
+#[cfg(windows)]
+use tracing::error;
+use tracing::{debug, info, warn};
use base64::Engine;
use bytes::Bytes;
@@ -49,24 +50,41 @@ use crate::protocol::{
use crate::server::client_accept::{
accept_pending_client_connections, reject_pending_client_connections,
};
+use crate::server::client_shell::{
+ render_pane_surface as render_client_shell_pane_surface,
+ resize_popup_runtime as resize_client_shell_popup_runtime, snapshot as client_shell_snapshot,
+};
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,
+ latest_shell_client, render_targets, terminal_stream_client_ids, ClientConnection,
+ ClientConnectionMode, ClientShellInputTarget, 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::pane_input::{
+ apply_client_pane_input_events, apply_client_popup_input_events, apply_terminal_attach_input,
+ apply_terminal_attach_scroll, terminal_attach_mouse_position,
+};
use crate::server::socket_paths::{
client_socket_path, prepare_socket_path, restrict_socket_permissions,
};
use crate::server::terminal_attach::paste_payload_for_runtime;
+mod bootstrap;
+mod lifecycle;
+mod notifications;
mod pane_graphics;
+mod render;
+mod retained_surface;
+
+pub use bootstrap::run_server;
+use lifecycle::wait_for_live_handoff_response_write;
+#[cfg(unix)]
+use lifecycle::wait_for_old_public_sockets_to_close;
use crate::protocol::MAX_GRAPHICS_FRAME_SIZE;
-use pane_graphics::RetainedGraphicsOutcome;
#[cfg(test)]
use crate::protocol::RenderEncoding;
@@ -75,26 +93,6 @@ 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",
@@ -102,14 +100,16 @@ fn sound_notify_message(sound: crate::sound::Sound) -> &'static str {
}
}
-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 notification_show_result(
+ id: String,
+ shown: bool,
+ reason: api::schema::NotificationShowReason,
+) -> String {
+ serde_json::to_string(&api::schema::SuccessResponse {
+ id,
+ result: api::schema::ResponseResult::NotificationShow { shown, reason },
+ })
+ .unwrap_or_else(|_| "{}".to_string())
}
fn non_empty_body(value: &str) -> Option<String> {
@@ -143,42 +143,6 @@ impl RenderImpact {
}
}
-#[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",
@@ -190,64 +154,6 @@ fn record_render_impact(source: &'static str, impact: RenderImpact) {
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
// ---------------------------------------------------------------------------
@@ -300,6 +206,8 @@ pub struct HeadlessServer {
next_client_id: u64,
/// The client currently driving the shared pane runtime size, theme, and input keybindings.
foreground_client_id: Option<u64>,
+ /// Process-local identity used to reject shell replacements from an earlier server boot.
+ client_shell_boot_id: String,
/// Outer window title last pushed, paired with the client that received it.
/// Keying on the client means a newly attached terminal is written to even
/// when the title itself has not changed, without every code path that
@@ -342,86 +250,6 @@ pub struct HeadlessServer {
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
- .plain_page_keys_use_host_scrollback()
- .unwrap_or(false);
- 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 position = crate::input::mouse::Position::Cell {
- column: column.unwrap_or(0),
- row: row.unwrap_or(0),
- };
- let Some(bytes) = runtime.encode_mouse_wheel(
- wheel_kind,
- position,
- 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();
- if let Some(text) = crate::raw_input::complete_text_bracketed_paste(&data) {
- runtime
- .try_send_paste(text.to_owned())
- .map_err(|err| format!("terminal attach paste failed: {err}"))
- } else {
- 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,
@@ -517,6 +345,14 @@ impl HeadlessServer {
#[cfg(unix)]
next_client_id: 1,
foreground_client_id: None,
+ client_shell_boot_id: format!(
+ "{}-{}",
+ std::process::id(),
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_nanos()
+ ),
sent_window_title: None,
api_window_title: None,
server_keybindings,
@@ -540,8 +376,7 @@ impl HeadlessServer {
/// 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:
+ /// This is the server's main runtime loop. It:
/// - Drains internal events (pane death, state changes)
/// - Drains API requests (from the JSON socket)
/// - Accepts new client connections
@@ -556,10 +391,6 @@ impl HeadlessServer {
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;
@@ -671,12 +502,6 @@ impl HeadlessServer {
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;
@@ -684,14 +509,14 @@ impl HeadlessServer {
needs_graphics_render = false;
}
- if latest_app_client(&self.clients).is_some() && self.app.ensure_default_workspace() {
+ if latest_shell_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");
}
- if self.app.pane_graphics.retain_live_panes(&self.app.state) {
+ if self.retain_live_pane_graphics() {
needs_render = true;
needs_graphics_render = true;
}
@@ -703,7 +528,7 @@ impl HeadlessServer {
self.drain_client_config_reload_request();
self.sync_immediate_pty_sources();
self.stream_host_mouse_capture_mode();
- self.stream_host_keyboard_enhancement_flags();
+ self.stream_direct_terminal_keyboard_mode();
// 7. Render virtually and stream frames. Hidden-only PTY work keeps a
// bounded classification cadence without delaying presentation work
@@ -747,53 +572,22 @@ impl HeadlessServer {
needs_render = false;
continue;
}
- 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)
+ let hidden_only = pty_dirty
+ && !needs_full_render
+ && !needs_graphics_render
+ && !self.pty_sources_visible_to_any_render_target(&render_request.pty_sources);
+ if hidden_only {
+ crate::render_prof::event("render.skipped.hidden_sources");
+ } else if !needs_full_render
+ && !needs_graphics_render
+ && self.render_retained_pane_surface_and_stream(&render_request.pty_sources)
{
- PtyRenderState::Visible
+ crate::render_prof::event("retained_surface.invoke");
} else {
- PtyRenderState::Hidden
- };
- let mut deferred_graphics = false;
- let render_plan = retained_render_plan(RetainedRenderInput {
- needs_full_render,
- needs_graphics_render,
- pty,
- });
- let rendered_retained = match render_plan {
- 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
- .record_render_attempt(now, render_plan != RetainedRenderPlan::HiddenPty);
+ self.app.record_render_attempt(now, !hidden_only);
needs_render = false;
needs_full_render = false;
needs_graphics_render = false;
@@ -841,7 +635,10 @@ impl HeadlessServer {
LoopEvent::Internal(ev) => {
self.handle_internal_event_with_forwarding(ev);
}
- LoopEvent::ServerEvent(ServerEvent::ClientConnected { writer, .. }) => {
+ LoopEvent::ServerEvent(
+ ServerEvent::ClientConnected { writer, .. }
+ | ServerEvent::ClientShellConnected { writer, .. },
+ ) => {
if let Ok(message) =
Self::frame_server_message(&ServerMessage::ServerShutdown {
reason: Some("server is shutting down".to_owned()),
@@ -915,7 +712,7 @@ impl HeadlessServer {
}
// Save session on exit.
- if !self.app.no_session {
+ if self.app.policy.persist_session {
self.app.save_session_now();
}
@@ -923,175 +720,6 @@ impl HeadlessServer {
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,
- stream_active: 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);
@@ -1119,14 +747,31 @@ impl HeadlessServer {
let Some(client) = self.clients.get(&client_id) else {
return;
};
+ let client_shell = matches!(client.mode, ClientConnectionMode::ClientShell);
+ let cell_size = client.cell_size;
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() {
+ if client_shell {
+ let cell_size = if cell_size.is_known() {
+ cell_size
+ } else {
+ Default::default()
+ };
+ self.app.state.view.terminal_area = area;
+ let _ = resize_client_shell_popup_runtime(&self.app, area, cell_size);
+ crate::ui::compute_tab_surface(
+ &self.app.state,
+ &self.app.terminal_runtimes,
+ area,
+ true,
+ cell_size,
+ );
+ } else if self.app.state.kitty_graphics_enabled && cell_size.is_known() {
crate::ui::compute_view_with_cell_size(
&mut self.app.state,
&self.app.terminal_runtimes,
area,
- client.cell_size,
+ cell_size,
);
} else {
crate::ui::compute_view_with_runtime_registry(
@@ -1158,11 +803,11 @@ impl HeadlessServer {
}
}
- fn sync_headless_view_geometry(&mut self) {
+ fn sync_runtime_view_geometry(&mut self) {
crate::ui::compute_view_without_resizing_panes(
&mut self.app.state,
&self.app.terminal_runtimes,
- Rect::new(0, 0, self.headless_size.0, self.headless_size.1),
+ Rect::new(0, 0, self.effective_size.0, self.effective_size.1),
);
}
@@ -1180,7 +825,7 @@ impl HeadlessServer {
self.effective_size = self.headless_size;
self.app.state.outer_terminal_focus = None;
self.app.state.host_cell_size = crate::kitty_graphics::HostCellSize::default();
- self.sync_headless_view_geometry();
+ self.sync_runtime_view_geometry();
let server_keybindings = self.server_keybindings.clone();
apply_keybindings(&mut self.app, &server_keybindings);
self.sync_visible_server_config_diagnostic(false);
@@ -1191,7 +836,7 @@ impl HeadlessServer {
self.effective_size = self.headless_size;
self.app.state.outer_terminal_focus = None;
self.app.state.host_cell_size = crate::kitty_graphics::HostCellSize::default();
- self.sync_headless_view_geometry();
+ self.sync_runtime_view_geometry();
let server_keybindings = self.server_keybindings.clone();
apply_keybindings(&mut self.app, &server_keybindings);
self.sync_visible_server_config_diagnostic(false);
@@ -1199,7 +844,6 @@ impl HeadlessServer {
};
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
@@ -1209,18 +853,15 @@ impl HeadlessServer {
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();
+ let outer_terminal_focus = client.outer_terminal_focus;
self.effective_size = terminal_size;
+ self.sync_runtime_view_geometry();
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);
+ let server_keybindings = self.server_keybindings.clone();
+ apply_keybindings(&mut self.app, &server_keybindings);
+ self.sync_visible_server_config_diagnostic(false);
if outer_terminal_focus == Some(true) {
self.app.state.mark_active_tab_seen();
}
@@ -1231,230 +872,6 @@ impl HeadlessServer {
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,
- self.api_window_title.clone(),
- );
- 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
@@ -1468,68 +885,6 @@ impl HeadlessServer {
}
}
- #[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_with_stop_control(
- api_tx,
- self.app.event_hub.clone(),
- self.should_quit.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);
@@ -1572,7 +927,7 @@ impl HeadlessServer {
}
fn promote_latest_remaining_client(&mut self) -> bool {
- let next_foreground = latest_app_client(&self.clients);
+ let next_foreground = latest_shell_client(&self.clients);
let changed = next_foreground != self.foreground_client_id;
self.foreground_client_id = next_foreground;
self.sync_foreground_client_state();
@@ -1582,7 +937,7 @@ impl HeadlessServer {
fn app_client_count(&self) -> usize {
self.clients
.values()
- .filter(|client| client.is_full_app_client() && client.writer.is_some())
+ .filter(|client| client.is_shell_client() && client.writer.is_some())
.count()
}
@@ -1590,7 +945,10 @@ impl HeadlessServer {
self.app_client_count() == 1
&& self.foreground_client_id.is_some_and(|id| {
self.clients.get(&id).is_some_and(|client| {
- client.is_full_app_client() && client.writer.is_some() && client.direct_graphics
+ client.is_shell_client()
+ && client.writer.is_some()
+ && client.direct_graphics
+ && client.pixel_mouse
})
})
}
@@ -1602,10 +960,9 @@ impl HeadlessServer {
fn remove_client(&mut self, client_id: u64) -> bool {
self.retire_direct_graphics_for_client(client_id);
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 {
+ if let Some(mut removed) = removed {
+ self.release_client_shell_inputs(client_id, &mut 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);
@@ -1624,6 +981,36 @@ impl HeadlessServer {
}
}
+ fn release_client_shell_inputs(&mut self, client_id: u64, client: &mut ClientConnection) {
+ for held in client.drain_shell_held_inputs() {
+ let result = match held.target {
+ ClientShellInputTarget::Pane(pane_id) => {
+ let Some((workspace_index, runtime_pane_id)) = self.app.parse_pane_id(&pane_id)
+ else {
+ continue;
+ };
+ let Some(runtime) = self.app.state.runtime_for_pane_in_workspace(
+ &self.app.terminal_runtimes,
+ workspace_index,
+ runtime_pane_id,
+ ) else {
+ continue;
+ };
+ apply_client_pane_input_events(runtime, &[held.release])
+ }
+ ClientShellInputTarget::Popup(terminal_id) => {
+ let Some(runtime) = self.runtime_for_terminal_id_string(&terminal_id) else {
+ continue;
+ };
+ apply_client_popup_input_events(runtime, &[held.release])
+ }
+ };
+ if let Err(err) = result {
+ warn!(client_id, err = %err, "client shell teardown release failed");
+ }
+ }
+ }
+
fn client_removal_needs_shared_resize(&self, client_id: u64) -> bool {
if self.foreground_client_id == Some(client_id) {
return true;
@@ -1645,77 +1032,6 @@ impl HeadlessServer {
}
}
- 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;
- };
- writer.replace_with_cleanup(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<()> {
@@ -1766,7 +1082,9 @@ impl HeadlessServer {
async fn reject_late_client_connections(&mut self) {
self.server_event_rx.close();
while let Some(event) = self.server_event_rx.recv().await {
- if let ServerEvent::ClientConnected { writer, .. } = event {
+ if let ServerEvent::ClientConnected { writer, .. }
+ | ServerEvent::ClientShellConnected { writer, .. } = event
+ {
if let Ok(message) = Self::frame_server_message(&ServerMessage::ServerShutdown {
reason: Some("server is shutting down".to_owned()),
}) {
@@ -1803,47 +1121,142 @@ impl HeadlessServer {
.map(|resolved| resolved.terminal_id)
}
- fn write_client_clipboard_image(
- &mut self,
+ fn client_clipboard_image_target_is_valid(
+ &self,
+ client_id: u64,
+ target: &protocol::ClientClipboardImageTarget,
+ ) -> bool {
+ match target {
+ protocol::ClientClipboardImageTarget::DirectTerminal => {
+ self.clients.get(&client_id).is_some_and(|client| {
+ matches!(client.mode, ClientConnectionMode::TerminalAttach { .. })
+ })
+ }
+ protocol::ClientClipboardImageTarget::Pane(pane_id) => {
+ !self.handoff_in_progress
+ && self.app.state.popup_pane.is_none()
+ && self.clients.get(&client_id).is_some_and(|client| {
+ matches!(client.mode, ClientConnectionMode::ClientShell)
+ })
+ && self.app.parse_pane_id(pane_id).is_some()
+ }
+ protocol::ClientClipboardImageTarget::Popup(terminal_id) => {
+ !self.handoff_in_progress
+ && self.clients.get(&client_id).is_some_and(|client| {
+ matches!(client.mode, ClientConnectionMode::ClientShell)
+ })
+ && self
+ .app
+ .state
+ .popup_pane
+ .as_ref()
+ .is_some_and(|popup| popup.terminal_id.as_str() == terminal_id)
+ }
+ }
+ }
+
+ fn stage_client_clipboard_image(
+ &self,
client_id: u64,
extension: &str,
data: &[u8],
- ) -> std::io::Result<String> {
+ ) -> std::io::Result<crate::server::clipboard_image::StagedClipboardImage> {
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)
+ Ok(staged)
}
- 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");
+ fn paste_client_clipboard_image_path(
+ &mut self,
+ client_id: u64,
+ target: protocol::ClientClipboardImageTarget,
+ path: String,
+ ) -> bool {
+ match target {
+ protocol::ClientClipboardImageTarget::DirectTerminal => {
+ let Some(ClientConnection {
+ mode: ClientConnectionMode::TerminalAttach { terminal_id },
+ ..
+ }) = self.clients.get(&client_id)
+ else {
+ return false;
+ };
+ 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");
+ }
}
+ true
+ }
+ protocol::ClientClipboardImageTarget::Pane(pane_id) => {
+ if self.handoff_in_progress
+ || self.app.state.popup_pane.is_some()
+ || !self.clients.get(&client_id).is_some_and(|client| {
+ matches!(client.mode, ClientConnectionMode::ClientShell)
+ })
+ {
+ return false;
+ }
+ let Some((workspace_index, runtime_pane_id)) = self.app.parse_pane_id(&pane_id)
+ else {
+ return false;
+ };
+ let foreground_changed = self.promote_client_to_foreground(client_id);
+ if foreground_changed {
+ self.resize_shared_runtime_to_effective_size_before_input();
+ }
+ let Some(runtime) = self.app.state.runtime_for_pane_in_workspace(
+ &self.app.terminal_runtimes,
+ workspace_index,
+ runtime_pane_id,
+ ) else {
+ return foreground_changed;
+ };
+ if let Err(err) = apply_client_pane_input_events(
+ runtime,
+ &[protocol::ClientPaneInputEvent::Paste(path)],
+ ) {
+ warn!(client_id, pane_id, err = %err, "client shell clipboard image paste failed");
+ }
+ true
+ }
+ protocol::ClientClipboardImageTarget::Popup(terminal_id) => {
+ if self.handoff_in_progress
+ || !self.clients.get(&client_id).is_some_and(|client| {
+ matches!(client.mode, ClientConnectionMode::ClientShell)
+ })
+ {
+ return false;
+ }
+ let Some(popup_terminal_id) = self
+ .app
+ .state
+ .popup_pane
+ .as_ref()
+ .map(|popup| popup.terminal_id.clone())
+ else {
+ return false;
+ };
+ if popup_terminal_id.as_str() != terminal_id {
+ return false;
+ }
+ let foreground_changed = self.promote_client_to_foreground(client_id);
+ if foreground_changed {
+ self.resize_shared_runtime_to_effective_size_before_input();
+ }
+ let Some(runtime) = self.app.terminal_runtimes.get(&popup_terminal_id) else {
+ return foreground_changed;
+ };
+ if let Err(err) = apply_client_popup_input_events(
+ runtime,
+ &[protocol::ClientPaneInputEvent::Paste(path)],
+ ) {
+ warn!(client_id, terminal_id, err = %err, "client shell popup clipboard image paste failed");
+ }
+ true
}
- 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(
@@ -1897,7 +1310,6 @@ impl HeadlessServer {
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);
@@ -1947,230 +1359,54 @@ impl HeadlessServer {
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(
+ fn handle_terminal_attach_mouse(
&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 !update.suppress_completion && 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;
+ client_id: u64,
+ kind: protocol::ClientMouseKind,
+ position: protocol::ClientMousePosition,
+ geometry: Option<protocol::ClientMouseGeometry>,
+ modifiers: u8,
+ lines: u16,
+ ) -> bool {
+ if self.handoff_in_progress {
+ return false;
}
- let Some(kind) = crate::app::actions::notification_toast_for_pane_state_update(
- suppress_active_tab_notifications,
- update,
- ) else {
- return;
+ let Some(client) = self.clients.get(&client_id) else {
+ return false;
};
- let Some(ws) = self.app.state.workspaces.get(update.ws_idx) else {
- return;
+ let ClientConnectionMode::TerminalAttach { terminal_id } = &client.mode else {
+ return false;
};
- let Some(agent_label) = update.agent_label.as_deref() else {
- return;
+ let terminal_id = terminal_id.clone();
+ let terminal_size = client.terminal_size;
+ let cell_size = client.cell_size;
+ let pixel_mouse = client.pixel_mouse;
+ let host_sgr_pixels_active = client.host_sgr_pixels_active == Some(true);
+ let Some(runtime) = self.runtime_for_terminal_id_string(&terminal_id) else {
+ return false;
};
- 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 Some(position) = terminal_attach_mouse_position(
+ runtime,
+ terminal_size,
+ cell_size,
+ pixel_mouse,
+ host_sgr_pixels_active,
+ position,
+ geometry,
+ ) else {
+ return false;
};
- 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 {
+ let event = protocol::ClientPaneInputEvent::Mouse {
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(&params.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());
+ position,
+ geometry: None,
+ modifiers,
+ lines: lines.max(1),
};
-
- 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 => {}
+ if let Err(err) = apply_client_pane_input_events(runtime, &[event]) {
+ warn!(client_id, terminal_id = %terminal_id, err = %err, "terminal attach mouse input failed");
}
-
- 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())
+ true
}
/// Pulls only titles reported dirty by the PTY parser. A focused pane title
@@ -2292,367 +1528,6 @@ impl HeadlessServer {
.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::TerminalBell { pane_id, count } => {
- if !self.send_to_foreground_client(ServerMessage::TerminalBell { count: *count }) {
- debug!(
- pane = pane_id.raw(),
- count, "dropped terminal bell without a foreground client"
- );
- }
- false
- }
- 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();
- let suppress_completion = self
- .app
- .handle_internal_event_with_pane_updates(ev)
- .iter()
- .any(|update| update.pane_id == pane_id_val && update.suppress_completion);
-
- // 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 !suppress_completion
- && 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 !suppress_completion
- && 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();
- let suppress_completion = self
- .app
- .handle_internal_event_with_pane_updates(ev)
- .iter()
- .any(|update| update.pane_id == pane_id_val && update.suppress_completion);
-
- // 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 !suppress_completion
- && 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 !suppress_completion
- && 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 || self.should_quit.load(Ordering::Acquire) {
- 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;
@@ -2710,6 +1585,39 @@ impl HeadlessServer {
}
}
+ /// Sends an ephemeral semantic event to every connected client-rendered shell.
+ fn send_to_client_shells(&mut self, msg: ServerMessage) -> bool {
+ let serialized = match Self::frame_server_message(&msg) {
+ Ok(framed) => framed,
+ Err(err) => {
+ warn!(err = %err, "failed to serialize message for client shells");
+ return false;
+ }
+ };
+ let client_ids = self
+ .clients
+ .iter()
+ .filter_map(|(&client_id, client)| {
+ matches!(client.mode, ClientConnectionMode::ClientShell).then_some(client_id)
+ })
+ .collect::<Vec<_>>();
+ let mut sent = false;
+ for client_id in client_ids {
+ let Some(client) = self.clients.get(&client_id) else {
+ continue;
+ };
+ let Some(writer) = &client.writer else {
+ continue;
+ };
+ if writer.control.send(serialized.clone()).is_ok() {
+ sent = true;
+ } else {
+ self.remove_client_and_resize_if_needed(client_id);
+ }
+ }
+ sent
+ }
+
/// 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 {
@@ -2781,7 +1689,6 @@ impl HeadlessServer {
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 {
@@ -2883,7 +1790,6 @@ impl HeadlessServer {
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);
@@ -2907,92 +1813,12 @@ impl HeadlessServer {
}
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
+ 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))
- {
- // Focus loss is not a teardown, so the pending URL click stays.
- self.app.release_input_source_headless(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)
- }
+ .is_some_and(|client| matches!(client.mode, ClientConnectionMode::TerminalPending))
}
+ /// Handles a server event. Returns true if the event requires a re-render.
fn handle_server_event(&mut self, ev: ServerEvent) -> bool {
if self.handoff_in_progress && Self::ignore_client_event_during_handoff(&ev) {
return false;
@@ -3005,11 +1831,55 @@ impl HeadlessServer {
rows,
cell_width_px,
cell_height_px,
- keybindings,
+ pixel_mouse,
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;
+ }
+ info!(
+ client_id,
+ cols, rows, cell_width_px, cell_height_px, "direct terminal client connected"
+ );
+ let last_activity = self.allocate_activity_stamp();
+ let observed = crate::kitty_graphics::HostCellSize {
+ width_px: cell_width_px,
+ height_px: cell_height_px,
+ };
+ let pixel_mouse = pixel_mouse && observed.is_known();
+ let mut connection = ClientConnection::new_with_mode(
+ ClientConnectionMode::TerminalPending,
+ (cols, rows),
+ observed,
+ last_activity,
+ protocol::RenderEncoding::TerminalAnsi,
+ Some(writer),
+ );
+ connection.pixel_mouse = pixel_mouse;
+ self.clients.insert(client_id, connection);
+ false
+ }
+ ServerEvent::ClientShellConnected {
+ client_id,
+ surface_cols,
+ surface_rows,
+ cell_width_px,
+ cell_height_px,
+ pixel_mouse,
direct_graphics,
+ endpoint_keybindings,
+ mouse_capture,
+ writer,
} => {
if self.handoff_in_progress {
if let Ok(message) =
@@ -3024,38 +1894,53 @@ impl HeadlessServer {
}
return false;
}
- let first_app_client = !direct_attach_requested && self.app_client_count() == 0;
info!(
client_id,
- cols,
- rows,
+ cols = surface_cols,
+ rows = surface_rows,
cell_width_px,
cell_height_px,
- ?render_encoding,
+ render_encoding = ?protocol::RenderEncoding::SemanticFrame,
"client connected"
);
+ self.app.ensure_default_workspace();
+ let first_app_client = self.app_client_count() == 0;
let last_activity = self.allocate_activity_stamp();
+ let observed = crate::kitty_graphics::HostCellSize {
+ width_px: cell_width_px,
+ height_px: cell_height_px,
+ };
let mut connection = 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,
+ ClientConnectionMode::ClientShell,
+ (surface_cols, surface_rows),
+ observed,
last_activity,
- render_encoding,
- direct_attach_requested,
+ protocol::RenderEncoding::SemanticFrame,
Some(writer),
);
+ connection.pixel_mouse = pixel_mouse && observed.is_known();
connection.direct_graphics = direct_graphics;
- connection.pixel_mouse = direct_graphics;
+ connection.shell_uses_endpoint_keybindings = endpoint_keybindings;
+ connection.shell_mouse_capture = mouse_capture;
+ connection.shell_projection_revision = 1;
+ let config_diagnostic = if endpoint_keybindings {
+ self.server_config_diagnostic.as_deref()
+ } else {
+ self.server_config_diagnostic_without_keybindings.as_deref()
+ };
+ let snapshot = client_shell_snapshot(
+ &self.app,
+ &self.client_shell_boot_id,
+ connection.shell_projection_revision,
+ config_diagnostic,
+ );
+ connection.shell_snapshot = Some(snapshot.clone());
self.clients.insert(client_id, connection);
- if !direct_attach_requested {
- self.foreground_client_id = Some(client_id);
- }
+ self.send_to_client(
+ client_id,
+ ServerMessage::ClientShellSnapshot(Box::new(snapshot)),
+ );
+ self.foreground_client_id = Some(client_id);
if first_app_client {
self.app.mark_git_status_refresh_due(Instant::now());
}
@@ -3099,120 +1984,65 @@ impl HeadlessServer {
} => self.handle_terminal_attach_scroll(
client_id, source, direction, lines, column, row, modifiers,
),
- ServerEvent::ClientInputPixels {
+ ServerEvent::ClientAttachMouse {
client_id,
- data,
+ kind,
+ position,
geometry,
- } => {
- let coordinates_valid = crate::input::mouse::parse_report(&data)
- .and_then(|(x, y)| geometry.cell(x, y))
- .is_some();
- let valid = coordinates_valid
- && self.clients.get(&client_id).is_some_and(|client| {
- let cell = client.cell_size;
- client.is_full_app_client()
- && client.host_sgr_pixels_active == Some(true)
- && client.terminal_size == (geometry.cols, geometry.rows)
- && cell.is_known()
- && cell.width_px == geometry.width_px / u32::from(geometry.cols)
- && cell.height_px == geometry.height_px / u32::from(geometry.rows)
- });
- if !valid || self.handoff_in_progress || !self.focused_pane_graphics_demand() {
- return false;
- }
- let foreground_changed = self.promote_client_to_foreground(client_id);
- if foreground_changed {
- self.resize_shared_runtime_to_effective_size_before_input();
- }
- self.app
- .route_client_pixel_mouse(client_id, &data, geometry)
- || foreground_changed
- }
+ modifiers,
+ lines,
+ } => self.handle_terminal_attach_mouse(
+ client_id, kind, position, geometry, modifiers, lines,
+ ),
ServerEvent::ClientInput { client_id, data } => {
if self.handoff_in_progress {
debug!(
client_id,
len = data.len(),
- "ignored client input during handoff"
+ "ignored direct terminal input during handoff"
);
return false;
}
- debug!(client_id, len = data.len(), "client input received");
- if let Some(ClientConnection {
+ 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 { .. })
- ) {
+ else {
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;
+ 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);
+ }
}
- let events = events
- .iter()
- .map(crate::protocol::ClientInputEvent::to_raw_input_event)
- .collect();
- self.handle_client_input_events(client_id, events)
+ true
}
ServerEvent::ClientPasteRejected {
client_id,
size,
max,
} => {
- self.send_to_client(
- client_id,
+ let detail = format!("Input message is {size} bytes; Herdr's limit is {max} bytes");
+ let message = if matches!(
+ self.clients.get(&client_id).map(|client| &client.mode),
+ Some(ClientConnectionMode::ClientShell)
+ ) {
+ ServerMessage::ClientShellError {
+ message: format!("Paste rejected: {detail}"),
+ }
+ } else {
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"
- )),
- },
- );
+ body: Some(detail),
+ }
+ };
+ self.send_to_client(client_id, message);
false
}
ServerEvent::ClientClipboardImage {
client_id,
+ target,
extension,
data,
} => {
@@ -3222,14 +2052,28 @@ impl HeadlessServer {
extension = %extension,
"client clipboard image received"
);
- if matches!(
- self.clients.get(&client_id).map(|client| &client.mode),
- Some(ClientConnectionMode::TerminalObserve { .. })
- ) {
+ if !self.client_clipboard_image_target_is_valid(client_id, &target) {
return false;
}
- match self.write_client_clipboard_image(client_id, &extension, &data) {
- Ok(path) => self.paste_client_clipboard_image_path(client_id, path),
+ match self.stage_client_clipboard_image(client_id, &extension, &data) {
+ Ok(staged) => {
+ let routed = self.paste_client_clipboard_image_path(
+ client_id,
+ target,
+ staged.paste_text,
+ );
+ if routed {
+ if let Some(client) = self.clients.get_mut(&client_id) {
+ client.staged_clipboard_files.push(staged.path);
+ } else {
+ crate::server::clipboard_image::remove_files(vec![staged.path]);
+ return false;
+ }
+ } else {
+ crate::server::clipboard_image::remove_files(vec![staged.path]);
+ }
+ routed
+ }
Err(err) => {
warn!(client_id, err = %err, "failed to stage client clipboard image");
true
@@ -3242,27 +2086,29 @@ impl HeadlessServer {
rows,
cell_width_px,
cell_height_px,
+ pixel_mouse,
} => {
info!(
client_id,
- cols, rows, cell_width_px, cell_height_px, "client resize"
+ cols, rows, cell_width_px, cell_height_px, pixel_mouse, "client resize"
);
+ let observed = crate::kitty_graphics::HostCellSize {
+ width_px: cell_width_px,
+ height_px: cell_height_px,
+ };
+ let pixel_mouse = pixel_mouse && observed.is_known();
let direct_terminal_id = if let Some(ClientConnection {
mode: ClientConnectionMode::TerminalAttach { terminal_id },
terminal_size,
cell_size,
+ pixel_mouse: client_pixel_mouse,
render_state,
..
}) = self.clients.get_mut(&client_id)
{
*terminal_size = (cols, rows);
- let observed = crate::kitty_graphics::HostCellSize {
- width_px: cell_width_px,
- height_px: cell_height_px,
- };
- if observed.is_known() {
- *cell_size = observed;
- }
+ *cell_size = observed;
+ *client_pixel_mouse = pixel_mouse;
render_state.request_repaint();
Some((terminal_id.clone(), *cell_size))
} else {
@@ -3275,38 +2121,366 @@ impl HeadlessServer {
return true;
}
if let Some(ClientConnection {
- mode: ClientConnectionMode::TerminalObserve { .. },
+ mode:
+ ClientConnectionMode::TerminalObserve { .. }
+ | ClientConnectionMode::TerminalPending,
terminal_size,
cell_size,
+ pixel_mouse: client_pixel_mouse,
render_state,
..
}) = self.clients.get_mut(&client_id)
{
*terminal_size = (cols, rows);
- let observed = crate::kitty_graphics::HostCellSize {
- width_px: cell_width_px,
- height_px: cell_height_px,
- };
- if observed.is_known() {
- *cell_size = observed;
- }
+ *cell_size = observed;
+ *client_pixel_mouse = pixel_mouse;
render_state.request_repaint();
return true;
}
- if let Some(client) = self.clients.get_mut(&client_id) {
- client.terminal_size = (cols, rows);
- let observed = crate::kitty_graphics::HostCellSize {
- width_px: cell_width_px,
- height_px: cell_height_px,
- };
- if observed.is_known() {
- client.cell_size = observed;
- }
+ false
+ }
+ ServerEvent::ClientShellResize {
+ client_id,
+ surface_cols,
+ surface_rows,
+ cell_width_px,
+ cell_height_px,
+ pixel_mouse,
+ } => {
+ let Some(client) = self.clients.get_mut(&client_id) else {
+ return false;
+ };
+ if !matches!(client.mode, ClientConnectionMode::ClientShell) {
+ return false;
+ }
+ client.terminal_size = (surface_cols, surface_rows);
+ let observed = crate::kitty_graphics::HostCellSize {
+ width_px: cell_width_px,
+ height_px: cell_height_px,
+ };
+ if observed.is_known() {
+ client.cell_size = observed;
}
+ client.pixel_mouse = pixel_mouse && observed.is_known();
+ client.request_repaint();
self.promote_client_to_foreground(client_id);
self.resize_shared_runtime_to_effective_size();
true
}
+ ServerEvent::ClientShellHostTheme { client_id, update } => {
+ let Some(client) = self.clients.get_mut(&client_id) else {
+ return false;
+ };
+ if !matches!(client.mode, ClientConnectionMode::ClientShell) {
+ return false;
+ }
+ if !client.update_host_theme(&update) {
+ return false;
+ }
+ if self.foreground_client_id != Some(client_id) {
+ return false;
+ }
+ 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
+ }
+ ServerEvent::ClientShellFocus { client_id, focused } => {
+ let Some(client) = self.clients.get_mut(&client_id) else {
+ return false;
+ };
+ if !matches!(client.mode, ClientConnectionMode::ClientShell)
+ || client.outer_terminal_focus == Some(focused)
+ {
+ return false;
+ }
+ client.outer_terminal_focus = Some(focused);
+ if focused {
+ self.promote_client_to_foreground(client_id);
+ self.resize_shared_runtime_to_effective_size_before_input();
+ self.app
+ .send_outer_focus_event(crate::ghostty::FocusEvent::Gained);
+ true
+ } else if self.foreground_client_id == Some(client_id) {
+ self.app.state.outer_terminal_focus = Some(false);
+ self.app
+ .send_outer_focus_event(crate::ghostty::FocusEvent::Lost);
+ true
+ } else {
+ false
+ }
+ }
+ ServerEvent::ClientShellMouseCapture { client_id, enabled } => {
+ let Some(client) = self.clients.get_mut(&client_id) else {
+ return false;
+ };
+ if !matches!(client.mode, ClientConnectionMode::ClientShell)
+ || client.shell_mouse_capture == enabled
+ {
+ return false;
+ }
+ client.shell_mouse_capture = enabled;
+ client.host_mouse_capture_active = None;
+ true
+ }
+ ServerEvent::ClientShellPaneInput {
+ client_id,
+ pane_id,
+ events,
+ } => {
+ if self.handoff_in_progress
+ || !self.clients.get(&client_id).is_some_and(|client| {
+ matches!(client.mode, ClientConnectionMode::ClientShell)
+ })
+ {
+ return false;
+ }
+ let pixel_mouse = self.clients.get(&client_id).is_some_and(|client| {
+ client.pixel_mouse && client.host_sgr_pixels_active == Some(true)
+ });
+ let mut events = events;
+ let Some((workspace_index, runtime_pane_id)) = self.app.parse_pane_id(&pane_id)
+ else {
+ return false;
+ };
+ let Some(runtime) = self.app.state.runtime_for_pane_in_workspace(
+ &self.app.terminal_runtimes,
+ workspace_index,
+ runtime_pane_id,
+ ) else {
+ return false;
+ };
+ super::pane_input::downgrade_ineligible_pixel_mouse(
+ &mut events,
+ pixel_mouse,
+ runtime.current_size(),
+ runtime.pixel_size(),
+ );
+ if self.app.state.popup_pane.is_some()
+ || !self
+ .app
+ .state
+ .pane_visible_on_active_surface(workspace_index, runtime_pane_id)
+ {
+ let Some(runtime) = self.app.state.runtime_for_pane_in_workspace(
+ &self.app.terminal_runtimes,
+ workspace_index,
+ runtime_pane_id,
+ ) else {
+ return false;
+ };
+ let releases = events
+ .into_iter()
+ .filter(client_pane_input_releases_press)
+ .collect::<Vec<_>>();
+ if releases.is_empty() {
+ return false;
+ }
+ if let Some(client) = self.clients.get_mut(&client_id) {
+ client.track_shell_input(
+ ClientShellInputTarget::Pane(pane_id.clone()),
+ &releases,
+ );
+ }
+ let scroll_before = runtime.scroll_metrics();
+ if let Err(err) = apply_client_pane_input_events(runtime, &releases) {
+ warn!(client_id, pane_id, err = %err, "targeted client shell release failed");
+ }
+ return runtime.scroll_metrics() != scroll_before;
+ }
+ let interaction = client_pane_input_has_interaction(&events);
+ if let Some(client) = self.clients.get_mut(&client_id) {
+ client
+ .track_shell_input(ClientShellInputTarget::Pane(pane_id.clone()), &events);
+ }
+ let foreground_changed =
+ interaction && self.promote_client_to_foreground(client_id);
+ if foreground_changed {
+ self.resize_shared_runtime_to_effective_size_before_input();
+ }
+ let Some(runtime) = self.app.state.runtime_for_pane_in_workspace(
+ &self.app.terminal_runtimes,
+ workspace_index,
+ runtime_pane_id,
+ ) else {
+ return foreground_changed;
+ };
+ let scroll_before = runtime.scroll_metrics();
+ if let Err(err) = apply_client_pane_input_events(runtime, &events) {
+ warn!(client_id, pane_id, err = %err, "targeted client shell input failed");
+ }
+ foreground_changed || runtime.scroll_metrics() != scroll_before
+ }
+ ServerEvent::ClientShellPopupInput {
+ client_id,
+ terminal_id,
+ events,
+ } => {
+ if self.handoff_in_progress
+ || !self.clients.get(&client_id).is_some_and(|client| {
+ matches!(client.mode, ClientConnectionMode::ClientShell)
+ })
+ {
+ return false;
+ }
+ let pixel_mouse = self.clients.get(&client_id).is_some_and(|client| {
+ client.pixel_mouse && client.host_sgr_pixels_active == Some(true)
+ });
+ let mut events = events;
+ let Some(popup_terminal_id) = self
+ .app
+ .state
+ .popup_pane
+ .as_ref()
+ .map(|popup| popup.terminal_id.clone())
+ else {
+ return false;
+ };
+ if popup_terminal_id.as_str() != terminal_id {
+ return false;
+ }
+ let Some(runtime) = self.app.terminal_runtimes.get(&popup_terminal_id) else {
+ return false;
+ };
+ super::pane_input::downgrade_ineligible_pixel_mouse(
+ &mut events,
+ pixel_mouse,
+ runtime.current_size(),
+ runtime.pixel_size(),
+ );
+ let interaction = client_pane_input_has_interaction(&events);
+ if let Some(client) = self.clients.get_mut(&client_id) {
+ client.track_shell_input(
+ ClientShellInputTarget::Popup(terminal_id.clone()),
+ &events,
+ );
+ }
+ let foreground_changed =
+ interaction && self.promote_client_to_foreground(client_id);
+ if foreground_changed {
+ self.resize_shared_runtime_to_effective_size_before_input();
+ }
+ let Some(runtime) = self.app.terminal_runtimes.get(&popup_terminal_id) else {
+ return foreground_changed;
+ };
+ let scroll_before = runtime.scroll_metrics();
+ if let Err(err) = apply_client_popup_input_events(runtime, &events) {
+ warn!(client_id, terminal_id, err = %err, "targeted client popup input failed");
+ }
+ foreground_changed || runtime.scroll_metrics() != scroll_before
+ }
+ ServerEvent::ClientShellEndpointRequest {
+ client_id,
+ boot_id,
+ request,
+ } => {
+ let Some(client) = self.clients.get(&client_id) else {
+ return false;
+ };
+ if !matches!(client.mode, ClientConnectionMode::ClientShell) {
+ self.remove_client_and_resize_if_needed(client_id);
+ return true;
+ }
+ let request_id = request.id.clone();
+ if !crate::server::client_commands::supports_client_shell_method(&request.method) {
+ let message = crate::server::client_commands::error_message(
+ boot_id,
+ request_id,
+ "unsupported_endpoint_command",
+ "this method is not available through the client shell command lane",
+ );
+ self.send_to_client(client_id, message);
+ return false;
+ }
+ if boot_id != self.client_shell_boot_id {
+ let message = crate::server::client_commands::error_message(
+ boot_id,
+ request_id,
+ "stale_boot",
+ "endpoint command targeted an earlier server boot",
+ );
+ self.send_to_client(client_id, message);
+ return false;
+ }
+ if client.shell_endpoint_command_in_flight {
+ self.send_to_client(
+ client_id,
+ ServerMessage::ServerShutdown {
+ reason: Some("client sent concurrent endpoint commands".into()),
+ },
+ );
+ self.remove_client_and_resize_if_needed(client_id);
+ return true;
+ }
+
+ let (respond_to, response_rx) = std::sync::mpsc::channel();
+ if let Err(err) = crate::server::client_commands::spawn_response_waiter(
+ client_id,
+ boot_id.clone(),
+ request_id.clone(),
+ response_rx,
+ self.server_event_tx.clone(),
+ ) {
+ let message = crate::server::client_commands::error_message(
+ boot_id,
+ request_id,
+ "server_unavailable",
+ format!("failed to start endpoint response bridge: {err}"),
+ );
+ self.send_to_client(client_id, message);
+ return false;
+ }
+ if let Some(client) = self.clients.get_mut(&client_id) {
+ client.shell_endpoint_command_in_flight = true;
+ }
+ let foreground_changed = self.promote_client_to_foreground(client_id);
+ if foreground_changed {
+ self.resize_shared_runtime_to_effective_size_before_input();
+ }
+ foreground_changed
+ | self.handle_api_request_with_shutdown_check(api::ApiRequestMessage {
+ request: *request,
+ respond_to,
+ response_write_complete: None,
+ stream_active: None,
+ })
+ }
+ ServerEvent::ClientShellEndpointResponseChunkReady {
+ client_id,
+ boot_id,
+ request_id,
+ final_chunk,
+ data,
+ } => {
+ let valid = self.clients.get(&client_id).is_some_and(|client| {
+ matches!(client.mode, ClientConnectionMode::ClientShell)
+ && client.shell_endpoint_command_in_flight
+ && boot_id == self.client_shell_boot_id
+ });
+ if !valid {
+ return false;
+ }
+ if final_chunk {
+ if let Some(client) = self.clients.get_mut(&client_id) {
+ client.shell_endpoint_command_in_flight = false;
+ }
+ }
+ self.send_to_client(
+ client_id,
+ ServerMessage::ClientShellEndpointResponseChunk {
+ boot_id,
+ request_id,
+ final_chunk,
+ data,
+ },
+ );
+ false
+ }
ServerEvent::ClientDetach { client_id } => {
info!(client_id, "client detached");
self.send_terminal_stream_detach_shutdown(client_id);
@@ -3344,6 +2518,8 @@ impl HeadlessServer {
!matches!(
ev,
ServerEvent::ClientConnected { .. }
+ | ServerEvent::ClientShellConnected { .. }
+ | ServerEvent::ClientShellEndpointResponseChunkReady { .. }
| ServerEvent::ClientDisconnected { .. }
| ServerEvent::ClientWriterDrained { .. }
| ServerEvent::QuitSignal
@@ -3754,6 +2930,14 @@ impl HeadlessServer {
.handle_deferred_worktree_api_request(msg.request, msg.respond_to);
return changed | deferred_changed;
}
+ if self.foreground_client_id.is_some_and(|client_id| {
+ self.clients
+ .get(&client_id)
+ .is_some_and(|client| matches!(client.mode, ClientConnectionMode::ClientShell))
+ }) {
+ self.app.state.view.terminal_area =
+ Rect::new(0, 0, self.effective_size.0, self.effective_size.1);
+ }
let mut response = if matches!(
&msg.request.method,
api::schema::Method::ServerReloadConfig(_)
@@ -3861,13 +3045,9 @@ impl HeadlessServer {
let Some(pane_after) = pane_after else {
continue;
};
+ let terminal_id = pane_after.attached_terminal_id.clone();
- let Some(terminal_after) = self
- .app
- .state
- .terminals
- .get(&pane_after.attached_terminal_id)
- else {
+ let Some(terminal_after) = self.app.state.terminals.get(&terminal_id) else {
continue;
};
@@ -3891,6 +3071,15 @@ impl HeadlessServer {
agent = ?agent,
"pane effective state changed during API request, checking notification"
);
+ self.forward_semantic_agent_transition(
+ *ws_idx,
+ *pane_id,
+ *prev_state,
+ new_state,
+ prev_agent_label.as_deref(),
+ agent_label.as_deref(),
+ agent,
+ );
if !forwarded_toast_from_state
&& self.app.state.toast_config.delay_seconds == 0
@@ -3909,7 +3098,7 @@ impl HeadlessServer {
.app
.state
.terminals
- .get(&pane_after.attached_terminal_id)
+ .get(&terminal_id)
.and_then(|terminal| terminal.effective_agent_label())
{
let event_text = match kind {
@@ -3960,764 +3149,16 @@ impl HeadlessServer {
}
}
- if !skip_default_workspace && latest_app_client(&self.clients).is_some() {
+ if !skip_default_workspace && latest_shell_client(&self.clients).is_some() {
changed |= self.app.ensure_default_workspace();
}
changed
}
- fn focused_pane_graphics_demand(&self) -> bool {
- self.app
- .state
- .active
- .and_then(|ws_idx| self.app.state.workspaces.get(ws_idx))
- .and_then(crate::workspace::Workspace::focused_pane_id)
- .is_some_and(|pane_id| self.app.pane_graphics.active_for_pane(pane_id))
- }
-
- fn stream_host_mouse_capture_mode(&mut self) {
- let enabled = self
- .app
- .state
- .should_capture_host_mouse_from(&self.app.terminal_runtimes);
- let pixel_mouse_requested = self
- .clients
- .values()
- .any(|client| client.is_full_app_client() && client.pixel_mouse);
- let sgr_pixels = pixel_mouse_requested
- && self.focused_pane_graphics_demand()
- && self
- .app
- .state
- .active
- .and_then(|ws_idx| {
- self.app
- .state
- .workspaces
- .get(ws_idx)
- .and_then(crate::workspace::Workspace::focused_pane_id)
- .and_then(|pane_id| {
- self.app.state.runtime_for_pane_in_workspace(
- &self.app.terminal_runtimes,
- ws_idx,
- pane_id,
- )
- })
- })
- .is_some_and(crate::terminal::TerminalRuntime::sgr_pixel_mouse_enabled);
- let mut broken_clients: Vec<u64> = Vec::new();
- for (&client_id, client) in &mut self.clients {
- if !client.is_full_app_client() {
- continue;
- }
- let client_sgr_pixels = sgr_pixels && client.pixel_mouse;
- if client.host_mouse_capture_active == Some(enabled)
- && client.host_sgr_pixels_active == Some(client_sgr_pixels)
- {
- continue;
- }
- let Some(writer) = &client.writer else {
- continue;
- };
- let serialized = match Self::frame_server_message(&ServerMessage::MouseCapture {
- enabled,
- sgr_pixels: client_sgr_pixels,
- }) {
- Ok(framed) => framed,
- Err(err) => {
- warn!(err = %err, "failed to serialize mouse capture mode for client");
- continue;
- }
- };
- if writer.control.send(serialized).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);
- client.host_sgr_pixels_active = Some(client_sgr_pixels);
- }
-
- 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 has_pending_presentation_work(
- &self,
- needs_full_render: bool,
- needs_graphics_render: bool,
- ) -> bool {
- needs_full_render || needs_graphics_render || self.app.render_dirty.has_immediate_work()
- }
-
- fn sync_immediate_pty_sources(&self) {
- let (has_app_target, direct_terminal_targets) = self.pty_render_targets();
- let mut pane_ids = if has_app_target {
- self.app.state.app_surface_pane_ids()
- } else {
- HashSet::new()
- };
- if !direct_terminal_targets.is_empty() {
- for workspace in &self.app.state.workspaces {
- for tab in &workspace.tabs {
- pane_ids.extend(tab.panes.iter().filter_map(|(&pane_id, pane)| {
- direct_terminal_targets
- .contains(pane.attached_terminal_id.as_str())
- .then_some(pane_id)
- }));
- }
- }
- if let Some(popup) = &self.app.state.popup_pane {
- if direct_terminal_targets.contains(popup.terminal_id.as_str()) {
- pane_ids.insert(popup.pane_id);
- }
- }
- }
- self.app.render_dirty.set_immediate_pty_sources(pane_ids);
- }
-
- fn pty_render_targets(&self) -> (bool, HashSet<&str>) {
- 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 => {}
- }
- }
- (has_app_target, direct_terminal_targets)
- }
-
- fn pty_source_visible_to_render_targets(
- &self,
- pane_id: crate::layout::PaneId,
- has_app_target: bool,
- direct_terminal_targets: &HashSet<&str>,
- ) -> bool {
- 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 pty_sources_visible_to_any_render_target(
- &self,
- sources: &HashSet<crate::layout::PaneId>,
- ) -> bool {
- let (has_app_target, direct_terminal_targets) = self.pty_render_targets();
- if !has_app_target && direct_terminal_targets.is_empty() {
- return false;
- }
-
- sources.iter().copied().any(|pane_id| {
- self.pty_source_visible_to_render_targets(
- pane_id,
- has_app_target,
- &direct_terminal_targets,
- )
- })
- }
-
- 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.pane_graphics,
- &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 mut reset_graphics = Vec::new();
- let mut encoded = if is_app_client
- && self.app.state.kitty_graphics_enabled
- && cell_size.is_known()
- {
- if client.graphics_surface_reset_pending {
- if self.app.pane_graphics.slots.is_empty() {
- reset_graphics = next_graphics_cache.clear_bytes();
- } else {
- next_graphics_cache = crate::kitty_graphics::HostGraphicsCache::default();
- }
- }
- let graphics_started = crate::render_prof::timer();
- let encoded = crate::kitty_graphics::encode_local_pane_graphics(
- &self.app.state,
- &self.app.pane_graphics,
- &self.app.terminal_runtimes,
- self.app.state.view.tab_surface(),
- cell_size,
- Some(crate::kitty_graphics::HEADLESS_GRAPHICS_TRANSACTION_BUDGET),
- &mut next_graphics_cache,
- );
- crate::render_prof::duration_since("full_render.graphics_encode", graphics_started);
- encoded
- } else if self.app.pane_graphics.slots.is_empty() {
- crate::kitty_graphics::EncodedGraphics {
- bytes: next_graphics_cache.clear_bytes(),
- incomplete: false,
- }
- } else {
- next_graphics_cache.clear_next()
- };
- if !reset_graphics.is_empty() {
- reset_graphics.extend(encoded.bytes);
- encoded.bytes = reset_graphics;
- }
- frame.graphics = encoded.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;
- encoded.incomplete = false;
- }
- let has_graphics = !frame.graphics.is_empty();
- let Some(mut prepared) = client.render_state.prepare_frame(frame) else {
- if commit_graphics_cache {
- client.graphics_cache = next_graphics_cache;
- client.graphics_surface_reset_pending = false;
- }
- if encoded.incomplete {
- client.defer_full_render();
- deferred_frame = true;
- } else {
- client.clear_deferred_render();
- }
- crate::render_prof::event("full_render.skip_identical");
- continue;
- };
- let max = if has_graphics {
- MAX_GRAPHICS_FRAME_SIZE
- } else {
- crate::protocol::MAX_FRAME_SIZE
- };
- let serialized = match Self::frame_server_message_with_max(prepared.message(), max) {
- Ok(frame) => frame,
- 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");
- 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");
- 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");
- continue;
- }
- };
- prepared = text_only_prepared;
- commit_graphics_cache = false;
- encoded.incomplete = false;
- 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");
- continue;
- }
- Err(err) => {
- warn!(client_id, err = %err, "failed to serialize frame");
- broken_clients.push(client_id);
- crate::render_prof::event("full_render.serialize_error");
- continue;
- }
- };
- match writer.render.try_send(serialized) {
- Ok(()) => {
- if commit_graphics_cache {
- client.graphics_cache = next_graphics_cache;
- client.graphics_surface_reset_pending = false;
- }
- client.render_state.commit_sent_frame(prepared);
- if encoded.incomplete {
- client.defer_full_render();
- deferred_frame = true;
- } else {
- client.clear_deferred_render();
- }
- crate::render_prof::event("full_render.sent");
- }
- Err(std::sync::mpsc::TrySendError::Full(_)) => {
- client.defer_full_render();
- deferred_frame = true;
- }
- Err(std::sync::mpsc::TrySendError::Disconnected(_)) => {
- broken_clients.push(client_id);
- }
- }
- }
-
- 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).
+ /// Similar to the former App scheduler but without terminal resize polling.
fn handle_scheduled_tasks_headless(&mut self, now: Instant, geometry_dirty: bool) -> bool {
let mut changed = false;
@@ -4763,27 +3204,6 @@ impl HeadlessServer {
}
}
- 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);
}
@@ -4833,132 +3253,25 @@ impl HeadlessServer {
}
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.
- async fn complete_shutdown(&mut self) -> io::Result<()> {
- info!("completing server shutdown");
- self.reject_late_client_connections().await;
-
- // 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));
- }
-
- // Reject only the requests already queued when shutdown reached cleanup.
- self.reject_queued_api_requests_for_shutdown();
-
- // 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 client_pane_input_releases_press(event: &protocol::ClientPaneInputEvent) -> bool {
+ matches!(
+ event,
+ protocol::ClientPaneInputEvent::Key {
+ kind: protocol::ClientKeyKind::Release,
+ ..
+ } | protocol::ClientPaneInputEvent::Mouse {
+ kind: protocol::ClientMouseKind::Up(_),
+ ..
+ }
+ )
}
-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> {
+fn client_pane_input_has_interaction(events: &[protocol::ClientPaneInputEvent]) -> bool {
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()
+ .iter()
+ .any(|event| !client_pane_input_releases_press(event))
}
impl Drop for HeadlessServer {
@@ -5028,6736 +3341,17 @@ fn sanitize_notification_text(value: &str, max_chars: usize) -> Option<String> {
}
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),
+ config::config_diagnostic_summary_without_keybindings(diagnostics),
)
}
-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();
- let should_quit = Arc::new(AtomicBool::new(false));
-
- // Start the JSON API socket server.
- let _api_server = match api::start_server_with_stop_control(
- api_tx.clone(),
- event_hub.clone(),
- should_quit.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),
- should_quit,
- ) {
- 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 should_quit = Arc::new(AtomicBool::new(false));
-
- 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_with_stop_control(
- api_tx.clone(),
- event_hub.clone(),
- should_quit.clone(),
- )?;
- let mut server = HeadlessServer::new(
- app,
- &loaded_config.diagnostics,
- Some(api_tx.clone()),
- Some(api_server),
- should_quit,
- )?;
- // Carried across before any client attaches, so the first title sent is
- // the override rather than the configured one it replaced.
- server.api_window_title = received.manifest.api_window_title.take();
- 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::{CellData, CursorState};
- use unicode_width::UnicodeWidthStr;
-
- #[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);
- 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);
- let headless_size = app.state.headless_size;
-
- 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,
- sent_window_title: None,
- api_window_title: 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,
- headless_size,
- effective_size: headless_size,
- shutting_down: false,
- handoff_in_progress: false,
- #[cfg(unix)]
- pending_handoff_repaint_nudge: false,
- 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 protocol::read_message(&mut std::io::Cursor::new(bytes), MAX_GRAPHICS_FRAME_SIZE)
- .expect("decode server frame")
- {
- ServerMessage::Frame(frame) => frame,
- other => panic!("expected frame, got {other:?}"),
- }
- }
-
- fn frame_text(frame: &FrameData) -> String {
- frame
- .cells
- .chunks(usize::from(frame.width))
- .map(|row| {
- row.iter()
- .map(|cell| cell.symbol.as_str())
- .collect::<String>()
- })
- .collect::<Vec<_>>()
- .join("\n")
- }
-
- fn read_server_shutdown_reason(bytes: Vec<u8>) -> Option<String> {
- match read_server_message(bytes) {
- ServerMessage::ServerShutdown { reason } => reason,
- other => panic!("expected shutdown, got {other:?}"),
- }
- }
-
- #[test]
- fn default_headless_size_is_effective_without_clients() {
- let server = test_headless_server();
-
- assert_eq!(
- server.headless_size,
- (
- crate::config::DEFAULT_HEADLESS_COLS,
- crate::config::DEFAULT_HEADLESS_ROWS
- )
- );
- assert_eq!(server.effective_size, server.headless_size);
- }
-
- #[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);
- server.app.render_dirty.request_terminal_title(pane_id);
-
- 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\\");
- server.app.render_dirty.request_terminal_title(pane_id);
- 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,
- stream_active: 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 server_stop_interrupts_server_event_backlog() {
- let mut server = test_headless_server();
- for client_id in 1..=64 {
- server
- .server_event_tx
- .try_send(ServerEvent::ClientDisconnected { client_id })
- .unwrap();
- }
-
- server.should_quit.store(true, Ordering::Release);
-
- assert!(!server.drain_server_events());
- assert!(server.server_event_rx.try_recv().is_ok());
- shutdown_test_runtimes(&mut server);
- }
-
- #[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,
- stream_active: 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 window_title_test_server() -> (HeadlessServer, std::sync::mpsc::Receiver<Vec<u8>>) {
- let mut server = test_headless_server();
- server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("herd")];
- server.app.state.active = Some(0);
- server.app.state.selected = 0;
-
- let (client_tx, control_rx, _render_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.promote_client_to_foreground(1);
- drain_window_titles(&control_rx);
- (server, control_rx)
- }
-
- /// The test client writer drains its queue on a background thread, so
- /// reading a pushed message needs a timeout rather than `try_recv`.
- fn next_window_title(
- control_rx: &std::sync::mpsc::Receiver<Vec<u8>>,
- ) -> Option<Option<String>> {
- let deadline = Instant::now() + Duration::from_secs(5);
- while let Some(remaining) = deadline.checked_duration_since(Instant::now()) {
- let Ok(bytes) = control_rx.recv_timeout(remaining) else {
- return None;
- };
- if let ServerMessage::WindowTitle { title } = read_server_message(bytes) {
- return Some(title);
- }
- }
- None
- }
-
- fn drain_window_titles(control_rx: &std::sync::mpsc::Receiver<Vec<u8>>) {
- while control_rx.recv_timeout(Duration::from_millis(50)).is_ok() {}
- }
-
- fn no_window_title(control_rx: &std::sync::mpsc::Receiver<Vec<u8>>) -> bool {
- while let Ok(bytes) = control_rx.recv_timeout(Duration::from_millis(200)) {
- if let ServerMessage::WindowTitle { .. } = read_server_message(bytes) {
- return false;
- }
- }
- true
- }
-
- #[test]
- fn window_title_waits_for_a_foreground_client_to_exist() {
- let mut server = test_headless_server();
- server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("herd")];
- server.app.state.active = Some(0);
- server.app.configure_window_title("{workspace}");
-
- // The server renders before the first client attaches. Nothing was
- // delivered, so nothing may be recorded as delivered either.
- server.sync_window_title();
- assert_eq!(server.sent_window_title, None);
-
- let (client_tx, control_rx, _render_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.promote_client_to_foreground(1);
- server.sync_window_title();
-
- assert_eq!(
- next_window_title(&control_rx),
- Some(Some("herd".to_string()))
- );
- shutdown_test_runtimes(&mut server);
- }
-
- #[test]
- fn an_attaching_client_gets_the_title_even_when_it_has_not_changed() {
- let (mut server, first_control_rx) = window_title_test_server();
- server.app.configure_window_title("{workspace}");
- server.sync_window_title();
- assert_eq!(
- next_window_title(&first_control_rx),
- Some(Some("herd".to_string()))
- );
-
- // ClientConnected assigns the foreground client directly rather than
- // going through promote_client_to_foreground, so the cache must notice
- // the new client on its own.
- let (client_tx, second_control_rx, _render_rx) = test_client_writer();
- server.clients.insert(
- 2,
- ClientConnection::new(
- (80, 24),
- crate::kitty_graphics::HostCellSize::default(),
- crate::terminal_theme::TerminalTheme::default(),
- None,
- 2,
- RenderEncoding::SemanticFrame,
- Some(client_tx),
- ),
- );
- server.foreground_client_id = Some(2);
- server.sync_window_title();
-
- assert_eq!(
- next_window_title(&second_control_rx),
- Some(Some("herd".to_string()))
- );
- shutdown_test_runtimes(&mut server);
- }
-
- #[test]
- fn configured_window_title_reaches_the_foreground_client_once_per_change() {
- let (mut server, control_rx) = window_title_test_server();
- server.app.configure_window_title("{workspace}/{tab}");
-
- server.sync_window_title();
- assert_eq!(
- next_window_title(&control_rx),
- Some(Some("herd/1".to_string()))
- );
-
- // An unchanged title must not re-emit an OSC on every render.
- server.sync_window_title();
- assert!(no_window_title(&control_rx));
-
- server.app.state.workspaces[0].tabs[0].custom_name = Some("build".into());
- server.sync_window_title();
- assert_eq!(
- next_window_title(&control_rx),
- Some(Some("herd/build".to_string()))
- );
-
- shutdown_test_runtimes(&mut server);
- }
-
- #[tokio::test]
- async fn focused_terminal_title_syncs_without_requesting_a_sidebar_render() {
- let (mut server, control_rx) = window_title_test_server();
- server.app.configure_window_title("{terminal_title}");
- server.app.state.ensure_test_terminals();
- let pane_id = server.app.state.workspaces[0].tabs[0].root_pane;
- let terminal_id = server.app.state.workspaces[0]
- .terminal_id(pane_id)
- .expect("terminal")
- .clone();
- let runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b"");
- runtime.test_process_pty_bytes("\x1b]0;⠋ building\x07".as_bytes());
- server
- .app
- .terminal_runtimes
- .insert(terminal_id.clone(), runtime);
-
- assert_eq!(
- server.sync_terminal_title_sources(&HashSet::from([pane_id])),
- (false, true)
- );
- assert_eq!(
- next_window_title(&control_rx),
- Some(Some("building".to_string()))
- );
-
- server
- .app
- .terminal_runtimes
- .get(&terminal_id)
- .expect("runtime")
- .test_process_pty_bytes("\x1b]0;⠙ building\x07".as_bytes());
- assert_eq!(
- server.sync_terminal_title_sources(&HashSet::from([pane_id])),
- (false, true)
- );
- assert!(no_window_title(&control_rx));
-
- shutdown_test_runtimes(&mut server);
- }
-
- #[test]
- fn a_foreground_client_without_a_writer_does_not_cache_the_window_title() {
- let (mut server, _control_rx) = window_title_test_server();
- server.app.configure_window_title("{workspace}");
-
- // A detached client keeps its entry but loses its writer, so nothing
- // reaches a terminal even though the targeted send reports success.
- if let Some(client) = server.clients.get_mut(&1) {
- client.writer = None;
- }
- server.sync_window_title();
- assert!(server.sent_window_title.is_none());
-
- // Attaching again has to deliver the title rather than skip it as sent.
- let (client_tx, control_rx, _render_rx) = test_client_writer();
- if let Some(client) = server.clients.get_mut(&1) {
- client.writer = Some(client_tx);
- }
- server.sync_window_title();
- assert_eq!(
- next_window_title(&control_rx),
- Some(Some("herd".to_string()))
- );
-
- shutdown_test_runtimes(&mut server);
- }
-
- #[test]
- fn empty_window_title_config_leaves_the_outer_title_alone() {
- let (mut server, control_rx) = window_title_test_server();
- server.app.configure_window_title("");
-
- server.sync_window_title();
-
- assert!(no_window_title(&control_rx));
- shutdown_test_runtimes(&mut server);
- }
-
- #[test]
- fn api_window_title_wins_until_it_is_cleared() {
- let (mut server, control_rx) = window_title_test_server();
- server.app.configure_window_title("{workspace}");
-
- server.handle_client_window_title_api("set".into(), Some("herdr api".into()));
- assert_eq!(
- next_window_title(&control_rx),
- Some(Some("herdr api".to_string()))
- );
-
- server.app.state.workspaces[0].custom_name = Some("ops".into());
- server.sync_window_title();
- assert!(no_window_title(&control_rx));
-
- // Clearing hands the title back to ui.window_title, not to "herdr".
- server.handle_client_window_title_api("clear".into(), None);
- assert_eq!(
- next_window_title(&control_rx),
- Some(Some("ops".to_string()))
- );
-
- shutdown_test_runtimes(&mut server);
- }
-
- #[test]
- fn clearing_the_api_title_falls_back_to_herdr_when_window_titles_are_disabled() {
- let (mut server, control_rx) = window_title_test_server();
- server.app.configure_window_title("");
-
- server.handle_client_window_title_api("set".into(), Some("herdr api".into()));
- assert_eq!(
- next_window_title(&control_rx),
- Some(Some("herdr api".to_string()))
- );
-
- server.handle_client_window_title_api("clear".into(), None);
- assert_eq!(next_window_title(&control_rx), Some(None));
-
- shutdown_test_runtimes(&mut server);
- }
-
- #[test]
- fn a_newly_promoted_client_gets_the_window_title_again() {
- let (mut server, first_control_rx) = window_title_test_server();
- server.app.configure_window_title("{workspace}");
- server.sync_window_title();
- assert_eq!(
- next_window_title(&first_control_rx),
- Some(Some("herd".to_string()))
- );
-
- // A second terminal starts on whatever its shell or ssh left behind.
- let (client_tx, second_control_rx, _render_rx) = test_client_writer();
- server.clients.insert(
- 2,
- ClientConnection::new(
- (80, 24),
- crate::kitty_graphics::HostCellSize::default(),
- crate::terminal_theme::TerminalTheme::default(),
- None,
- 2,
- RenderEncoding::SemanticFrame,
- Some(client_tx),
- ),
- );
- server.promote_client_to_foreground(2);
- server.sync_window_title();
-
- assert_eq!(
- next_window_title(&second_control_rx),
- Some(Some("herd".to_string()))
- );
- 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()
- {
- if cells_equivalent_for_frame_compare(
- &actual.cells,
- &expected.cells,
- usize::from(actual.width),
- idx,
- actual_cell,
- expected_cell,
- ) {
- continue;
- }
- assert_eq!(
- actual_cell,
- expected_cell,
- "cell mismatch at index {idx} (x={}, y={})",
- idx % usize::from(actual.width),
- idx / usize::from(actual.width),
- );
- }
- }
-
- fn cells_equivalent_for_frame_compare(
- actual_cells: &[CellData],
- expected_cells: &[CellData],
- width: usize,
- idx: usize,
- actual: &CellData,
- expected: &CellData,
- ) -> bool {
- if actual == expected {
- return true;
- }
- if !cell_style_without_symbol_eq(actual, expected) {
- return false;
- }
- if !matches!(
- (actual.symbol.as_str(), expected.symbol.as_str()),
- ("", " ") | (" ", "")
- ) {
- return false;
- }
- covered_by_previous_wide_cell(actual_cells, width, idx)
- || covered_by_previous_wide_cell(expected_cells, width, idx)
- }
-
- fn cell_style_without_symbol_eq(a: &CellData, b: &CellData) -> bool {
- a.fg == b.fg
- && a.bg == b.bg
- && a.modifier == b.modifier
- && a.skip == b.skip
- && a.hyperlink == b.hyperlink
- }
-
- fn covered_by_previous_wide_cell(cells: &[CellData], width: usize, idx: usize) -> bool {
- if idx == 0 || idx.is_multiple_of(width) {
- return false;
- }
- frame_cell_display_width(&cells[idx - 1]) > 1
- }
-
- fn frame_cell_display_width(cell: &CellData) -> usize {
- if is_halfwidth_katakana_voiced_grapheme(&cell.symbol) {
- return 2;
- }
- cell.symbol.width()
- }
-
- fn is_halfwidth_katakana_voiced_grapheme(symbol: &str) -> bool {
- let mut chars = symbol.chars();
- let Some(base) = chars.next() else {
- return false;
- };
- let Some(mark) = chars.next() else {
- return false;
- };
- chars.next().is_none()
- && ('\u{ff66}'..='\u{ff9d}').contains(&base)
- && matches!(mark, '\u{ff9e}' | '\u{ff9f}')
- }
-
- #[test]
- fn direct_graphics_requires_one_negotiated_app_client() {
- let mut server = test_headless_server();
- let (writer_a, _control_a, _render_a) = test_client_writer();
- assert!(server.handle_server_event(ServerEvent::ClientConnected {
- client_id: 1,
- cols: 80,
- rows: 24,
- cell_width_px: 10,
- cell_height_px: 20,
- render_encoding: RenderEncoding::SemanticFrame,
- keybindings: None,
- direct_attach_requested: false,
- direct_graphics: true,
- writer: writer_a,
- }));
- assert!(server.clients[&1].direct_graphics);
- assert!(server.clients[&1].pixel_mouse);
- assert!(server.direct_graphics_available());
-
- let (writer_b, _control_b, _render_b) = test_client_writer();
- assert!(server.handle_server_event(ServerEvent::ClientConnected {
- client_id: 2,
- cols: 80,
- rows: 24,
- cell_width_px: 10,
- cell_height_px: 20,
- render_encoding: RenderEncoding::SemanticFrame,
- keybindings: None,
- direct_attach_requested: false,
- direct_graphics: false,
- writer: writer_b,
- }));
- assert!(!server.direct_graphics_available());
- }
-
- #[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,
- direct_graphics: 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,
- direct_graphics: 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,
- direct_graphics: 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,
- direct_graphics: 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,
- direct_graphics: 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,
- direct_graphics: 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,
- direct_graphics: 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,
- direct_graphics: false,
- 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,
- direct_graphics: false,
- 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(),
- },
- 0,
- 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,
- direct_graphics: 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,
- direct_graphics: false,
- 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,
- direct_graphics: 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,
- direct_graphics: false,
- 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_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_paste_uses_plain_text_when_runtime_did_not_enable_brackets() {
- with_terminal_attach_runtime(b"", 0, |runtime, input_rx| {
- apply_terminal_attach_input(runtime, b"\x1b[200~line one\nline two\x1b[201~".to_vec())
- .expect("attach paste");
-
- assert_eq!(
- input_rx.try_recv().expect("forwarded paste"),
- Bytes::from_static(b"line one\nline two")
- );
- });
- }
-
- #[test]
- fn terminal_attach_paste_preserves_brackets_when_runtime_enabled_them() {
- with_terminal_attach_runtime(b"\x1b[?2004h", 0, |runtime, input_rx| {
- apply_terminal_attach_input(runtime, b"\x1b[200~line one\nline two\x1b[201~".to_vec())
- .expect("attach paste");
-
- assert_eq!(
- input_rx.try_recv().expect("forwarded paste"),
- Bytes::from_static(b"\x1b[200~line one\nline two\x1b[201~")
- );
- });
- }
-
- #[test]
- fn terminal_attach_page_key_host_scrolls_plain_terminal() {
- with_terminal_attach_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_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_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_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_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,
- // Expiry is advanced with the captured deadline below; keep the
- // pre-expiry assertion independent of wall-clock scheduling.
- ttl: Some(Duration::from_secs(60)),
- })
- );
-
- 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));
- }
-
- #[test]
- fn terminal_attach_resize_preserves_known_cell_size_when_pixels_are_omitted() {
- with_terminal_session_test_server(|server, _terminal_id, terminal_id, _pane_id| {
- let mut client = ClientConnection::new(
- (80, 24),
- crate::kitty_graphics::HostCellSize {
- width_px: 10,
- height_px: 20,
- },
- crate::terminal_theme::TerminalTheme::default(),
- Some(true),
- 1,
- RenderEncoding::SemanticFrame,
- None,
- );
- client.mode = ClientConnectionMode::TerminalAttach {
- terminal_id: terminal_id.clone(),
- };
- server.clients.insert(1, client);
-
- assert!(server.handle_server_event(ServerEvent::ClientResize {
- client_id: 1,
- cols: 100,
- rows: 30,
- cell_width_px: 0,
- cell_height_px: 0,
- }));
-
- assert_eq!(
- server
- .runtime_for_terminal_id_string(&terminal_id)
- .unwrap()
- .pixel_size(),
- Some((1_000, 600))
- );
- assert_eq!(
- server.clients[&1].cell_size,
- crate::kitty_graphics::HostCellSize {
- width_px: 10,
- height_px: 20,
- }
- );
- });
- }
-
- #[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,
- direct_graphics: false,
- 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 render_and_stream_sends_large_terminal_frame_for_terminal_ansi_client() {
- let mut server = test_headless_server();
- server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("test")];
- 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;
- let (client_tx, _client_control_rx, client_rx) = test_client_writer();
-
- server.clients.insert(
- 1,
- ClientConnection::new(
- (278, 85),
- crate::kitty_graphics::HostCellSize::default(),
- crate::terminal_theme::TerminalTheme::default(),
- None,
- 1,
- RenderEncoding::TerminalAnsi,
- Some(client_tx),
- ),
- );
- server.foreground_client_id = Some(1);
- server.sync_foreground_client_state();
-
- server.render_and_stream();
- match read_server_message(
- client_rx
- .recv_timeout(Duration::from_millis(100))
- .expect("initial terminal frame"),
- ) {
- ServerMessage::Terminal(frame) => {
- assert_eq!(frame.seq, 1);
- assert_eq!((frame.width, frame.height), (278, 85));
- assert!(frame.full);
- }
- other => panic!("expected terminal frame, got {other:?}"),
- }
-
- assert!(server.handle_server_event(ServerEvent::ClientResize {
- client_id: 1,
- cols: 710,
- rows: 202,
- cell_width_px: 0,
- cell_height_px: 0,
- }));
- server.render_and_stream();
-
- match read_server_message(
- client_rx
- .recv_timeout(Duration::from_millis(100))
- .expect("large terminal frame"),
- ) {
- ServerMessage::Terminal(frame) => {
- assert_eq!(frame.seq, 2);
- assert_eq!((frame.width, frame.height), (710, 202));
- assert!(frame.full);
- assert!(!frame.bytes.is_empty());
- }
- other => panic!("expected terminal frame, got {other:?}"),
- }
-
- server.app.state.mode = crate::app::Mode::Navigate;
- server.render_and_stream();
- match read_server_message(
- client_rx
- .recv_timeout(Duration::from_millis(100))
- .expect("follow-up terminal frame"),
- ) {
- ServerMessage::Terminal(frame) => assert_eq!(frame.seq, 3),
- other => panic!("expected terminal frame, got {other:?}"),
- }
- }
-
- #[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[&1]
- .writer
- .as_ref()
- .unwrap()
- .test_fill_render(queued);
-
- 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.test_fill_render(queued);
-
- 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.test_fill_render(queued);
-
- 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 visible_source_wakes_pending_hidden_work() {
- let (server, background_pane) = hidden_pty_visibility_test_server(&[(120, 40)]);
- let visible_pane = server.app.state.workspaces[0].tabs[0].root_pane;
- server.sync_immediate_pty_sources();
-
- assert!(server.app.render_dirty.request_pty(background_pane));
- assert!(!server.has_pending_presentation_work(false, false));
- assert!(server.app.render_dirty.request_pty(visible_pane));
- assert!(server.has_pending_presentation_work(false, false));
- }
-
- #[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_with_app_client() {
- let (mut server, background_pane) = hidden_pty_visibility_test_server(&[(120, 40)]);
- assert!(!server.pty_sources_visible_to_any_render_target(&HashSet::from([background_pane])));
-
- 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(
- 2,
- ClientConnection::new_with_mode(
- ClientConnectionMode::TerminalObserve { terminal_id },
- None,
- (80, 24),
- crate::kitty_graphics::HostCellSize::default(),
- crate::terminal_theme::TerminalTheme::default(),
- None,
- 2,
- RenderEncoding::SemanticFrame,
- false,
- Some(client_tx),
- ),
- );
-
- assert!(server.pty_sources_visible_to_any_render_target(&HashSet::from([background_pane])));
-
- let hidden_pane = server.app.state.workspaces[0].tabs[0].root_pane;
- server.sync_immediate_pty_sources();
- assert!(server.app.render_dirty.request_pty(background_pane));
- assert!(server.has_pending_presentation_work(false, false));
- assert!(server.app.render_dirty.request_pty(hidden_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[&1]
- .writer
- .as_ref()
- .unwrap()
- .test_fill_render(queued);
- 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 terminal_bell_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);
-
- let changed = server.handle_internal_event_with_forwarding(AppEvent::TerminalBell {
- pane_id: crate::layout::PaneId::from_raw(1),
- count: 3,
- });
-
- assert!(!changed);
- match read_server_message(
- foreground_control_rx
- .recv_timeout(Duration::from_millis(100))
- .expect("foreground terminal bell message"),
- ) {
- ServerMessage::TerminalBell { count } => assert_eq!(count, 3),
- other => panic!("expected terminal bell message, got {other:?}"),
- }
- assert!(
- background_control_rx
- .recv_timeout(Duration::from_millis(50))
- .is_err(),
- "background client should not receive terminal bells"
- );
-
- server.foreground_client_id = None;
- server.handle_internal_event_with_forwarding(AppEvent::TerminalBell {
- pane_id: crate::layout::PaneId::from_raw(1),
- count: 1,
- });
- assert!(
- foreground_control_rx
- .recv_timeout(Duration::from_millis(50))
- .is_err(),
- "bells without a foreground client must not be retained"
- );
- }
-
- #[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);
- foreground_tx.test_close();
-
- 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,
- stream_active: 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,
- stream_active: 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,
- stream_active: 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,
- stream_active: 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,
- stream_active: 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,
- stream_active: 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 startup_idle_does_not_forward_completion() {
- 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.toast_config.delivery = crate::config::ToastDelivery::System;
- server.app.state.toast_config.delay_seconds = 0;
- server.app.state.sound.enabled = true;
-
- assert!(
- server.handle_internal_event_with_forwarding(AppEvent::AgentProcessDetected {
- pane_id,
- agent: crate::detect::Agent::Pi,
- observed_at: Instant::now(),
- })
- );
-
- 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();
- while client_control_rx
- .recv_timeout(Duration::from_millis(20))
- .is_ok()
- {}
-
- assert!(
- server.handle_internal_event_with_forwarding(AppEvent::StateChanged {
- pane_id,
- agent: Some(crate::detect::Agent::Pi),
- state: crate::detect::AgentState::Idle,
- visible_blocker: false,
- visible_working: false,
- process_exited: false,
- observed_at: Instant::now(),
- })
- );
- assert!(
- client_control_rx
- .recv_timeout(Duration::from_millis(50))
- .is_err(),
- "startup readiness should not forward a completion notification"
- );
- }
-
- #[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,
- stream_active: 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 ")
- );
- }
-}
+mod tests;