use anyhow::{Context, Result};
use bmux_appearance::{RUNTIME_APPEARANCE_STATE_KIND, RuntimeAppearance};
use bmux_attach_layout_protocol::attach_layout_protocol::{
AttachLayoutSnapshot, AttachSurfaceSummary, STATE_KIND as ATTACH_LAYOUT_STATE_KIND,
};
use bmux_attach_layout_protocol::{
AttachLayer as SurfaceLayer, AttachMouseProtocolEncoding, AttachMouseProtocolMode, AttachRect,
AttachScene, AttachSurface, AttachSurfaceKind,
};
use bmux_attach_pipeline::TerminalGraphicsCache;
use bmux_attach_pipeline::mouse as attach_mouse;
use bmux_attach_pipeline::reconcile::{
apply_attach_output_chunk_with, attach_scene_damage_between,
};
use bmux_attach_pipeline::{
AttachChunkApplyOutcome, AttachOutputChunkMeta, DamageCoalescingPolicy, DamageRect,
FrameDamageStats, PaneScrollbackWindow, RetainedDamage, RetainedOpacity,
RetainedRepaintSurface, RetainedSurface, RetainedSurfacePayload, ScrollbackViewportBase,
frame_damage_from_retained_repaint_plan, merge_retained_damages,
queue_render_ops_with_capabilities, retained_damage_from_absolute_rects,
retained_frame_damage_from_frame_damage, retained_layer_order,
retained_surfaces_from_attach_scene, retained_surfaces_from_plugin_surfaces,
update_protocol_hints_from_state,
};
use bmux_attach_view_protocol::{AttachLocalPresentationSnapshot, AttachViewComponent};
use bmux_client::{
AttachContinuityValidator, AttachControlValidator, AttachInputPayload, AttachLayoutState,
AttachMouseButton as ProviderMouseButton, AttachMouseInput as ProviderMouseInput,
AttachMousePhase as ProviderMousePhase, AttachPaneSnapshotState, AttachProviderBackend,
AttachProviderChange, AttachProviderEvent, AttachProviderInput, AttachProviderSnapshot,
AttachProviderViewport, AttachSession, AttachSnapshotState, AttachStreamCursor, ClientError,
StreamingBmuxClient,
};
use bmux_config::{
BmuxConfig, ClipboardRemoteSyncMode, ConfigPaths, PaneRestoreMethod, ResolvedTimeout,
};
use bmux_context_state::ContextSelector;
use bmux_ipc::InvokeServiceKind;
use bmux_keybind::{action_to_config_name, parse_action};
use bmux_pane_runtime_plugin_api::pane_runtime_events as pane_events;
use bmux_permissions_plugin_api::session_policy_state;
use bmux_plugin::layout::{PluginLayoutRequest, resolve_plugin_layout};
use bmux_plugin::{
AttachInputEvent, AttachInputHook, AttachInputModifiers, AttachInputPaneContext,
AttachInputResult, AttachVisualProjectionUpdate, ExtensionRect,
};
use bmux_plugin_sdk::{
COMMAND_OUTCOME_STATUS_MESSAGE_KEY, HostScope, PluginCommandOutcome, ServiceKind,
ServiceRequest,
perf_telemetry::{PhaseChannel, emit as emit_phase_timing},
};
use bmux_recording_plugin_api::recording_state;
use bmux_recording_protocol::{DisplayActivityKind, RecordingCaptureTarget};
use bmux_session_models::SessionSelector;
use bmux_tui::component::{Component, Constraints, LayoutCx};
use bmux_tui::composition::TextBlock;
use bmux_tui::frame::Frame as TuiFrame;
use bmux_tui::geometry::{Insets as TuiInsets, Rect as TuiRect, Size as TuiSize};
use bmux_tui::paint::{LocalRect, PaintCx};
use bmux_tui::prelude::Line as TuiLine;
use bmux_tui_components::key_hint_bar::{KeyHint, KeyHintBarComponent, KeyHintBarStyles};
use bmux_tui_components::modal_frame::{ModalFrame, ModalFrameComponent, ModalSizing};
use bmux_tui_components::theme::ComponentTheme;
use crossterm::cursor::{Hide, MoveTo, SavePosition, Show};
use crossterm::event::{
DisableBracketedPaste, DisableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind,
KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
};
#[cfg(test)]
use crossterm::event::{EnableBracketedPaste, EnableMouseCapture};
macro_rules! queue {
($($args:tt)*) => {
if bmux_attach_pipeline::projection::encoding_terminal() {
crossterm::queue!($($args)*)
} else { Ok::<(), std::io::Error>(()) }
};
}
use crossterm::style::Print;
use crossterm::terminal;
use crossterm::terminal::{BeginSynchronizedUpdate, Clear, ClearType, EndSynchronizedUpdate};
use std::collections::{BTreeMap, BTreeSet};
use std::future::Future;
use std::hash::{Hash, Hasher};
use std::io::{self, Write};
use std::path::Path;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use tracing::{debug, trace, warn};
use uuid::Uuid;
pub type AttachTerminalEventFuture<'a> =
Pin<Box<dyn Future<Output = Result<Option<Event>>> + Send + 'a>>;
#[must_use]
pub const fn bracketed_paste_enabled(config_enabled: bool) -> bool {
cfg!(feature = "bracketed-paste") && config_enabled
}
pub trait AttachTerminal: Write {
fn presentation_only(&self) -> bool {
false
}
fn viewport_guard(&self) -> Option<Arc<dyn Fn() -> bool + Send + Sync>> {
None
}
fn geometry(&self) -> TerminalGeometry;
fn render_provider(&self) -> Option<Arc<dyn bmux_client::AttachProvider>> {
None
}
fn accept_geometry(&mut self, _geometry: TerminalGeometry) -> Result<()> {
anyhow::bail!("driver cannot follow provider geometry")
}
fn enter_attach_mode(
&mut self,
kitty_keyboard_enabled: bool,
mouse_capture_enabled: bool,
bracketed_paste_enabled: bool,
) -> Result<bool>;
fn next_event(&mut self) -> AttachTerminalEventFuture<'_>;
fn take_owned_action(&mut self) -> Option<String> {
None
}
fn restore_after_attach_ui(&mut self) -> Result<()>;
fn set_attached_session_id(&self, _session_id: Uuid) {}
#[cfg(test)]
fn observe_scrollback(&self, _active: bool) {}
#[cfg(any(
feature = "image-sixel",
feature = "image-kitty",
feature = "image-iterm2"
))]
fn detect_image_capabilities(&mut self) -> bmux_image::host_caps::HostImageCapabilities;
}
pub struct RealAttachTerminal {
terminal: bmux_attach_pipeline::terminal::TerminalIo<io::Stdout>,
}
impl RealAttachTerminal {
#[must_use]
pub fn new() -> Self {
Self {
terminal: bmux_attach_pipeline::terminal::TerminalIo::new(io::stdout()),
}
}
}
impl Default for RealAttachTerminal {
fn default() -> Self {
Self::new()
}
}
impl Write for RealAttachTerminal {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.terminal.write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.terminal.flush()
}
}
struct HeadlessTerminalOutput {
bytes: Vec<u8>,
resizes: Vec<(usize, TerminalGeometry)>,
}
#[derive(Clone)]
pub struct HeadlessAttachTerminalHandle {
#[cfg(test)]
scrollback: Arc<Mutex<bool>>,
#[cfg(test)]
screen: Arc<Mutex<bmux_terminal_grid::TerminalGridStream>>,
event_tx: tokio::sync::mpsc::UnboundedSender<Event>,
geometry: Arc<Mutex<TerminalGeometry>>,
output: Arc<Mutex<HeadlessTerminalOutput>>,
pending_output_chunks: Arc<Mutex<Vec<Vec<u8>>>>,
attached_session_id: Arc<Mutex<Option<Uuid>>>,
}
impl HeadlessAttachTerminalHandle {
#[cfg(test)]
pub fn scrollback_active(&self) -> bool {
*self
.scrollback
.lock()
.expect("headless scrollback lock poisoned")
}
pub fn send_event(&self, event: Event) -> Result<()> {
self.event_tx
.send(event)
.map_err(|_| anyhow::anyhow!("headless attach terminal event receiver closed"))
}
pub fn resize(&self, cols: u16, rows: u16) -> Result<()> {
let mut output = self
.output
.lock()
.map_err(|_| anyhow::anyhow!("headless attach terminal output lock poisoned"))?;
*self
.geometry
.lock()
.map_err(|_| anyhow::anyhow!("headless attach terminal geometry lock poisoned"))? =
TerminalGeometry { cols, rows };
#[cfg(test)]
self.screen
.lock()
.map_err(|_| anyhow::anyhow!("headless screen lock poisoned"))?
.resize(cols, rows)?;
let offset = output.bytes.len();
output
.resizes
.push((offset, TerminalGeometry { cols, rows }));
drop(output);
self.send_event(Event::Resize(cols, rows))
}
#[must_use]
pub fn output_bytes(&self) -> Vec<u8> {
self.output
.lock()
.map_or_else(|_| Vec::new(), |out| out.bytes.clone())
}
pub fn output_grid(&self) -> Result<bmux_terminal_grid::TerminalGridStream> {
let output = self
.output
.lock()
.map_err(|_| anyhow::anyhow!("headless attach terminal output lock poisoned"))?;
let (_, initial) = output.resizes[0];
let mut stream = bmux_terminal_grid::TerminalGridStream::new(
initial.cols,
initial.rows,
bmux_terminal_grid::GridLimits::default(),
)?;
let mut start = 0;
for &(offset, geometry) in &output.resizes[1..] {
stream.process(&output.bytes[start..offset]);
stream.resize(geometry.cols, geometry.rows)?;
start = offset;
}
stream.process(&output.bytes[start..]);
drop(output);
Ok(stream)
}
#[cfg(test)]
#[must_use]
pub fn output_screen_text(&self, cols: u16, rows: u16) -> String {
let stream = self.screen.lock().expect("headless screen lock poisoned");
assert_eq!(stream.grid().width(), usize::from(cols));
assert_eq!(stream.grid().height(), usize::from(rows));
bmux_terminal_grid::visible_text(stream.grid(), 0, usize::from(rows))
}
#[must_use]
pub fn attached_session_id(&self) -> Option<Uuid> {
self.attached_session_id.lock().ok().and_then(|id| *id)
}
#[must_use]
pub fn drain_output_chunks(&self) -> Vec<Vec<u8>> {
self.pending_output_chunks
.lock()
.map_or_else(|_| Vec::new(), |mut chunks| chunks.drain(..).collect())
}
}
pub struct HeadlessAttachTerminal {
#[cfg(test)]
scrollback: Arc<Mutex<bool>>,
#[cfg(test)]
screen: Arc<Mutex<bmux_terminal_grid::TerminalGridStream>>,
geometry: Arc<Mutex<TerminalGeometry>>,
event_rx: tokio::sync::mpsc::UnboundedReceiver<Event>,
output: Arc<Mutex<HeadlessTerminalOutput>>,
pending_output_chunks: Arc<Mutex<Vec<Vec<u8>>>>,
attached_session_id: Arc<Mutex<Option<Uuid>>>,
}
impl HeadlessAttachTerminal {
#[must_use]
pub fn new(cols: u16, rows: u16) -> (Self, HeadlessAttachTerminalHandle) {
let (event_tx, event_rx) = tokio::sync::mpsc::unbounded_channel();
#[cfg(test)]
let scrollback = Arc::new(Mutex::new(false));
#[cfg(test)]
let screen = Arc::new(Mutex::new(
bmux_terminal_grid::TerminalGridStream::new(
cols,
rows,
bmux_terminal_grid::GridLimits::default(),
)
.expect("headless terminal dimensions must be nonzero"),
));
let geometry = Arc::new(Mutex::new(TerminalGeometry { cols, rows }));
let output = Arc::new(Mutex::new(HeadlessTerminalOutput {
bytes: Vec::new(),
resizes: vec![(0, TerminalGeometry { cols, rows })],
}));
let pending_output_chunks = Arc::new(Mutex::new(Vec::new()));
let attached_session_id = Arc::new(Mutex::new(None));
(
Self {
#[cfg(test)]
scrollback: Arc::clone(&scrollback),
#[cfg(test)]
screen: Arc::clone(&screen),
geometry: Arc::clone(&geometry),
event_rx,
output: Arc::clone(&output),
pending_output_chunks: Arc::clone(&pending_output_chunks),
attached_session_id: Arc::clone(&attached_session_id),
},
HeadlessAttachTerminalHandle {
#[cfg(test)]
scrollback,
#[cfg(test)]
screen,
event_tx,
geometry,
output,
pending_output_chunks,
attached_session_id,
},
)
}
}
impl Write for HeadlessAttachTerminal {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
#[cfg(test)]
self.screen
.lock()
.map_err(|_| io::Error::other("headless screen lock poisoned"))?
.process(buf);
self.output
.lock()
.map_err(|_| io::Error::other("headless attach terminal output lock poisoned"))?
.bytes
.extend_from_slice(buf);
self.pending_output_chunks
.lock()
.map_err(|_| io::Error::other("headless attach terminal output lock poisoned"))?
.push(buf.to_vec());
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
impl AttachTerminal for HeadlessAttachTerminal {
#[cfg(test)]
fn observe_scrollback(&self, active: bool) {
*self
.scrollback
.lock()
.expect("headless scrollback lock poisoned") = active;
}
fn geometry(&self) -> TerminalGeometry {
self.geometry
.lock()
.map_or(TerminalGeometry { cols: 80, rows: 24 }, |geometry| {
*geometry
})
}
fn enter_attach_mode(
&mut self,
_kitty_keyboard_enabled: bool,
_mouse_capture_enabled: bool,
_bracketed_paste_enabled: bool,
) -> Result<bool> {
Ok(false)
}
fn next_event(&mut self) -> AttachTerminalEventFuture<'_> {
Box::pin(async move { Ok(self.event_rx.recv().await) })
}
fn restore_after_attach_ui(&mut self) -> Result<()> {
Ok(())
}
fn set_attached_session_id(&self, session_id: Uuid) {
if let Ok(mut attached_session_id) = self.attached_session_id.lock() {
*attached_session_id = Some(session_id);
}
}
#[cfg(any(
feature = "image-sixel",
feature = "image-kitty",
feature = "image-iterm2"
))]
fn detect_image_capabilities(&mut self) -> bmux_image::host_caps::HostImageCapabilities {
headless_image_capabilities_from_env()
}
}
#[cfg(any(
feature = "image-sixel",
feature = "image-kitty",
feature = "image-iterm2"
))]
fn headless_image_capabilities_from_env() -> bmux_image::host_caps::HostImageCapabilities {
let mut caps = bmux_image::host_caps::HostImageCapabilities {
cell_pixel_width: 8,
cell_pixel_height: 16,
..bmux_image::host_caps::HostImageCapabilities::default()
};
match std::env::var("BMUX_PLAYBOOK_HEADLESS_IMAGE_PROTOCOL")
.unwrap_or_default()
.as_str()
{
"kitty" => caps.kitty_graphics = true,
"sixel" => caps.sixel = true,
"iterm2" => caps.iterm2_inline = true,
"env" => {
caps = bmux_image::host_caps::detect_from_env();
if caps.cell_pixel_width == 0 {
caps.cell_pixel_width = 8;
}
if caps.cell_pixel_height == 0 {
caps.cell_pixel_height = 16;
}
}
_ => {}
}
caps
}
impl AttachTerminal for RealAttachTerminal {
fn geometry(&self) -> TerminalGeometry {
current_attach_terminal_geometry()
}
fn enter_attach_mode(
&mut self,
kitty_keyboard_enabled: bool,
mouse_capture_enabled: bool,
bracketed_paste_enabled: bool,
) -> Result<bool> {
#[cfg(feature = "kitty-keyboard")]
let keyboard_enhanced = kitty_keyboard_enabled;
#[cfg(not(feature = "kitty-keyboard"))]
let keyboard_enhanced = false;
let _ = kitty_keyboard_enabled;
self.terminal
.enter(bmux_attach_pipeline::terminal::TerminalOptions {
keyboard_enhanced,
mouse_capture: mouse_capture_enabled,
bracketed_paste: bracketed_paste_enabled,
..bmux_attach_pipeline::terminal::TerminalOptions::default()
})
.context("failed to enable raw mode for attach")?;
Ok(self.terminal.keyboard_enhanced())
}
fn next_event(&mut self) -> AttachTerminalEventFuture<'_> {
Box::pin(async move {
self.terminal
.next_event()
.await
.context("failed reading terminal event")
})
}
fn restore_after_attach_ui(&mut self) -> Result<()> {
let modes = self
.terminal
.restore()
.context("failed restoring attach terminal modes");
let display = restore_terminal_after_attach_ui();
modes.and(display)
}
#[cfg(any(
feature = "image-sixel",
feature = "image-kitty",
feature = "image-iterm2"
))]
fn detect_image_capabilities(&mut self) -> bmux_image::host_caps::HostImageCapabilities {
let mut caps = bmux_image::host_caps::detect_with_queries();
let (cpw, cph) = bmux_image::host_caps::query_cell_pixel_size();
caps.cell_pixel_width = cpw;
caps.cell_pixel_height = cph;
fill_missing_image_cell_pixels(&mut caps);
caps
}
}
#[cfg(any(
feature = "image-sixel",
feature = "image-kitty",
feature = "image-iterm2"
))]
fn fill_missing_image_cell_pixels(caps: &mut bmux_image::host_caps::HostImageCapabilities) {
if !caps.any_supported() || (caps.cell_pixel_width > 0 && caps.cell_pixel_height > 0) {
return;
}
if caps.cell_pixel_width == 0 {
caps.cell_pixel_width = 8;
}
if caps.cell_pixel_height == 0 {
caps.cell_pixel_height = 16;
}
}
use super::super::prompt::{self, PromptOption, PromptRequest, PromptResponse, PromptValue};
use super::super::{
ATTACH_SCROLLBACK_UNAVAILABLE_STATUS, ATTACH_SELECTION_CLEARED_STATUS,
ATTACH_SELECTION_COPIED_STATUS, ATTACH_SELECTION_EMPTY_STATUS, ATTACH_SELECTION_STARTED_STATUS,
ATTACH_TRANSIENT_STATUS_TTL, BmuxClient, HELP_OVERLAY_SURFACE_ID, InputProcessor,
KernelClientFactory, Keymap, RuntimeAction, action_dispatch, attach,
attach_quit_failure_status, available_capability_providers, available_service_descriptors,
command_accepts_repeat, effective_enabled_plugins, enter_host_kernel_connection,
host_kernel_bridge, load_plugin, map_attach_client_error, merged_runtime_keybindings,
parse_session_selector, parse_uuid_value, perf_telemetry, plugin_command_policy_hints,
plugin_host_metadata, resolve_plugin_search_paths,
run_plugin_keybinding_command_with_active_bindings, scan_available_plugins,
};
use super::adapters::{AttachClock, SystemAttachClock};
use super::cursor::apply_attach_cursor_state;
use super::display_capture;
const ATTACH_LOCAL_PRESENTATION_STATE_KIND: bmux_plugin_sdk::PluginEventKind =
bmux_plugin_sdk::PluginEventKind::from_static("bmux.attach/local-presentation");
use super::events::{AttachLoopControl, AttachLoopEvent, AttachTerminalEvent};
use super::input::{
TerminalGeometry, TerminalInputEvent, TerminalKeyEvent, TerminalMouseButton,
TerminalMouseEvent, TerminalMousePhase,
};
use super::prompt_ui::{
AttachCloseFallbackTarget, AttachInternalPromptAction, AttachPromptCompletion,
AttachPromptOrigin, AttachPromptOverlayRender, PromptKeyDisposition, prompt_accepts_key_kind,
};
use super::render::{
AttachRenderTrace, AttachRenderTraceOp, AttachSceneRenderStats, ExtensionRenderStats,
append_pane_output, collect_visual_projection_updates, frame_damage_overlay_rects,
frame_damage_overlay_render_ops, plugin_scene_items_to_render_items,
queue_render_items_for_surface, queue_render_ops,
render_attach_scene_with_stats_and_trace_with_capabilities,
render_attach_scene_with_stats_and_trace_with_capabilities_and_occluders,
visible_scene_pane_ids,
};
use super::scrollback_modes::{cached_mode, set_cached_mode_debounced, set_runtime_mode};
use super::state::{
AttachDirtyFlags, AttachDirtySource, AttachEventAction, AttachExitReason,
AttachInputHookCapture, AttachMouseFloatingDrag, AttachMouseResizeAxisDrag,
AttachMouseResizeDrag, AttachMouseSelectionDrag, AttachPointerOwner, AttachScrollbackCursor,
AttachScrollbackPosition, AttachUiEffect, AttachUiMode, AttachUiReduction, AttachViewState,
PaneRenderBuffer, PaneScrollbackView, ScrollbackPin,
};
use super::tui_surface::{buffer_render_ops, component_theme, surface_buffer};
use crate::connection::CliAttachEndpointConnector;
use crate::pane_runtime_client::{
BmuxPaneRuntimeClientExt, StreamingAttachInputExt, attach_pane_grid_delta_state_streaming,
attach_pane_grid_snapshot_state_streaming, attach_pane_scrollback_pin_streaming,
attach_pane_scrollback_unpin_streaming, pane_scrollback_rule_metadata_streaming,
};
use bmux_plugin::{RenderDamage, RenderExtensionContext, RenderExtensionLayer, RenderOp};
const ATTACH_OUTPUT_BATCH_MAX_BYTES: usize = 8 * 1024;
const ATTACH_STRUCTURED_SNAPSHOT_MAX_BYTES_PER_PANE: usize = 0;
const ATTACH_GRID_SNAPSHOT_FALLBACK_ROWS_PER_PANE: usize = 1;
const ATTACH_GRID_DELTA_MAX_BATCHES_PER_PANE: usize = 128;
const ATTACH_OUTPUT_DRAIN_MAX_ROUNDS: usize = 8;
const COMMAND_OUTCOME_SELECTED_CONTEXT_ID_KEY: &str = "bmux.contexts.selected_context_id";
const ATTACH_PLUGIN_STATUS_MESSAGE_MAX_CHARS: usize = 180;
const ATTACH_OUTPUT_DRAIN_TIME_BUDGET: Duration = Duration::from_millis(4);
const ATTACH_OVERRENDER_MIN_FRAMES: u64 = 10;
const ATTACH_OVERRENDER_WINDOW_RATIO_PERCENT: u64 = 20;
const ATTACH_OVERRENDER_CACHED_SKIP_RATIO_PERCENT: u64 = 90;
const ATTACH_OVERRENDER_MIN_ROWS_EXAMINED: u64 = 10;
const ATTACH_OVERRENDER_NON_FULL_FRAME_CELL_RATIO_PERCENT: u64 = 50;
const ATTACH_OVERRENDER_EXTENSION_FULL_SURFACE_RATIO_PERCENT: u64 = 80;
const ATTACH_OVERRENDER_EXTENSION_IMPERATIVE_OR_MISS_RATIO_PERCENT: u64 = 80;
const ATTACH_OVERRENDER_SLOW_TERMINAL_WRITE_MS_PER_KIB: u64 = 8;
const DAMAGE_OVERLAY_SURFACE_ID: Uuid = Uuid::from_u128(4);
fn emit_attach_phase_timing(payload: &serde_json::Value) {
emit_phase_timing(PhaseChannel::Attach, payload);
}
#[allow(clippy::too_many_arguments)]
fn emit_attach_plugin_command_timing(
plugin_id: &str,
command_name: &str,
status: &str,
before_context_id: Option<Uuid>,
after_context_id: Option<Uuid>,
attached_context_id: Option<Uuid>,
attached_session_id: Uuid,
before_us: u128,
policy_us: u128,
run_us: u128,
outcome_us: u128,
after_us: u128,
retarget_us: u128,
total_us: u128,
) {
emit_attach_phase_timing(&serde_json::json!({
"phase": "attach.plugin_command",
"plugin_id": plugin_id,
"command_name": command_name,
"status": status,
"before_context_id": before_context_id,
"after_context_id": after_context_id,
"attached_context_id": attached_context_id,
"attached_session_id": attached_session_id,
"before_us": before_us,
"policy_us": policy_us,
"run_us": run_us,
"outcome_us": outcome_us,
"after_us": after_us,
"retarget_us": retarget_us,
"total_us": total_us,
}));
}
use bmux_clients_plugin_api::{clients_state, clients_state::ClientSummary};
use bmux_contexts_plugin_api::{
contexts_state, contexts_state::ContextSummary as PluginContextSummary,
};
use bmux_control_catalog_plugin_api::control_catalog_state::{
self, ContextRow, ContextSessionBinding, SessionRow,
};
use bmux_performance_plugin_api::performance_state;
use bmux_plugin_sdk::{TypedServiceClientError, TypedServiceEndpoint};
use bmux_sessions_plugin_api::{sessions_commands, sessions_state};
use bmux_tabs_plugin_api::{tabs_commands, tabs_state};
fn typed_client_error(error: &TypedServiceClientError) -> ClientError {
ClientError::ServerError {
code: bmux_ipc::ErrorCode::Internal,
message: error.to_string(),
}
}
#[must_use]
fn ipc_to_windows_selector(selector: SessionSelector) -> tabs_commands::Selector {
match selector {
SessionSelector::ById(id) => tabs_commands::Selector {
id: Some(id),
name: None,
index: None,
},
SessionSelector::ByName(name) => tabs_commands::Selector {
id: None,
name: Some(name),
index: None,
},
}
}
#[must_use]
const fn pane_id_windows_selector(id: Uuid) -> tabs_commands::Selector {
tabs_commands::Selector {
id: Some(id),
name: None,
index: None,
}
}
#[must_use]
fn ipc_to_session_selector(selector: SessionSelector) -> sessions_state::SessionSelector {
match selector {
SessionSelector::ById(id) => sessions_state::SessionSelector {
id: Some(id),
name: None,
},
SessionSelector::ByName(name) => sessions_state::SessionSelector {
id: None,
name: Some(name),
},
}
}
async fn typed_kill_session_attach(
client: &mut StreamingBmuxClient,
selector: SessionSelector,
force_local: bool,
) -> std::result::Result<
std::result::Result<
bmux_sessions_plugin_api::sessions_commands::SessionAck,
bmux_sessions_plugin_api::sessions_commands::KillSessionError,
>,
ClientError,
> {
sessions_commands::client::kill_session(client, ipc_to_session_selector(selector), force_local)
.await
.map_err(|error| typed_client_error(&error))
}
async fn typed_list_contexts_attach(
client: &mut StreamingBmuxClient,
) -> std::result::Result<Vec<PluginContextSummary>, ClientError> {
contexts_state::client::list_contexts(client)
.await
.map_err(|error| typed_client_error(&error))
}
async fn typed_list_windows_attach(
client: &mut StreamingBmuxClient,
) -> std::result::Result<Vec<tabs_state::TabEntry>, ClientError> {
tabs_state::client::list_tabs(client, None)
.await
.map_err(|error| typed_client_error(&error))
}
#[derive(Debug, serde::Serialize)]
struct ActiveAppearanceForScopeRequest {
scope: String,
attributes: BTreeMap<String, String>,
}
async fn typed_active_runtime_appearance_attach(
client: &mut StreamingBmuxClient,
) -> std::result::Result<RuntimeAppearance, ClientError> {
typed_runtime_appearance_service(client, "active-appearance", &()).await
}
async fn typed_active_runtime_appearance_for_cwd_attach(
client: &mut StreamingBmuxClient,
cwd: &str,
) -> std::result::Result<RuntimeAppearance, ClientError> {
typed_active_runtime_appearance_for_scope_attach(
client,
"pane",
BTreeMap::from([("cwd".to_string(), cwd.to_string())]),
)
.await
}
async fn typed_active_runtime_appearance_for_scope_attach(
client: &mut StreamingBmuxClient,
scope: impl Into<String>,
attributes: BTreeMap<String, String>,
) -> std::result::Result<RuntimeAppearance, ClientError> {
typed_runtime_appearance_service(
client,
"active-appearance-for-scope",
&ActiveAppearanceForScopeRequest {
scope: scope.into(),
attributes,
},
)
.await
}
async fn typed_runtime_appearance_service<Request>(
client: &mut StreamingBmuxClient,
operation: &str,
request: &Request,
) -> std::result::Result<RuntimeAppearance, ClientError>
where
Request: serde::Serialize + Sync,
{
let payload =
bmux_codec::to_positional_vec(request).map_err(|error| ClientError::ServerError {
code: bmux_ipc::ErrorCode::Internal,
message: format!("encoding {operation} args: {error}"),
})?;
let response_bytes = client
.invoke_service_raw(
"bmux.theme.read",
InvokeServiceKind::Query,
"theme-state",
operation,
payload,
)
.await?;
bmux_codec::from_positional_bytes(&response_bytes).map_err(|error| ClientError::ServerError {
code: bmux_ipc::ErrorCode::Internal,
message: format!("decoding {operation} response: {error}"),
})
}
async fn typed_current_context_attach(
client: &mut StreamingBmuxClient,
) -> std::result::Result<Option<PluginContextSummary>, ClientError> {
contexts_state::client::current_context(client)
.await
.map_err(|error| typed_client_error(&error))
}
async fn typed_list_clients_bmux(
client: &mut BmuxClient,
) -> std::result::Result<Vec<ClientSummary>, ClientError> {
clients_state::client::list_clients(client)
.await
.map_err(|error| typed_client_error(&error))
}
async fn typed_list_contexts_bmux(
client: &mut BmuxClient,
) -> std::result::Result<Vec<PluginContextSummary>, ClientError> {
contexts_state::client::list_contexts(client)
.await
.map_err(|error| typed_client_error(&error))
}
async fn invoke_windows_command<Req, Resp>(
client: &mut StreamingBmuxClient,
operation: &str,
args: &Req,
) -> std::result::Result<Resp, ClientError>
where
Req: serde::Serialize + Sync,
Resp: serde::de::DeserializeOwned,
{
let payload = bmux_plugin_sdk::encode_service_message(args).map_err(|error| {
ClientError::ServerError {
code: bmux_ipc::ErrorCode::Internal,
message: format!("encoding {operation}: {error}"),
}
})?;
let response_bytes = client
.invoke_service_raw(
tabs_commands::client::FocusPaneEndpoint::CAPABILITY.as_str(),
InvokeServiceKind::Command,
tabs_commands::INTERFACE_ID.as_str(),
operation,
payload,
)
.await?;
bmux_plugin_sdk::decode_service_message::<Resp>(&response_bytes).map_err(|error| {
ClientError::ServerError {
code: bmux_ipc::ErrorCode::Internal,
message: format!("decoding {operation} response: {error}"),
}
})
}
#[derive(Default)]
pub struct DisplayCaptureFanout {
writers: BTreeMap<Uuid, display_capture::DisplayCaptureWriter>,
}
impl DisplayCaptureFanout {
fn open_target(&mut self, target: &RecordingCaptureTarget, client_id: Uuid) {
if self.writers.contains_key(&target.recording_id) {
return;
}
match display_capture::DisplayCaptureWriter::open(
target.recording_id,
Path::new(&target.path),
client_id,
target.rolling_window_secs,
) {
Ok(writer) => {
self.writers.insert(target.recording_id, writer);
}
Err(error) => {
tracing::warn!(
"failed starting display capture for recording {}: {error}",
target.recording_id
);
}
}
}
fn close_recording(&mut self, recording_id: Uuid) {
if let Some(mut writer) = self.writers.remove(&recording_id) {
let _ = writer.record_stream_closed();
let _ = writer.flush();
}
}
fn close_all(&mut self) {
let ids: Vec<Uuid> = self.writers.keys().copied().collect();
for id in ids {
self.close_recording(id);
}
}
fn record_resize(&mut self, cols: u16, rows: u16) {
let mut failed = Vec::new();
for (id, writer) in &mut self.writers {
if writer.record_resize(cols, rows).is_err() {
failed.push(*id);
}
}
for id in failed {
self.close_recording(id);
}
}
fn record_frame(
&mut self,
data: &[u8],
cursor_state: Option<super::state::AttachCursorState>,
cursor_changed: bool,
) {
if data.is_empty()
|| !self
.writers
.values()
.any(display_capture::DisplayCaptureWriter::has_frame_capacity)
{
return;
}
let data = Arc::<[u8]>::from(data);
let mut failed = Vec::new();
for (id, writer) in &mut self.writers {
if writer
.record_frame(Arc::clone(&data), cursor_state, cursor_changed)
.is_err()
{
failed.push(*id);
}
}
for id in failed {
self.close_recording(id);
}
}
fn record_activity(&mut self, kind: DisplayActivityKind) {
let mut failed = Vec::new();
for (id, writer) in &mut self.writers {
if writer.record_activity(kind).is_err() {
failed.push(*id);
}
}
for id in failed {
self.close_recording(id);
}
}
#[cfg(any(
feature = "image-sixel",
feature = "image-kitty",
feature = "image-iterm2"
))]
fn record_images(&mut self, images: &[bmux_attach_image_protocol::AttachPaneImage]) {
let mut failed = Vec::new();
for (id, writer) in &mut self.writers {
if writer.record_images(images).is_err() {
failed.push(*id);
}
}
for id in failed {
self.close_recording(id);
}
}
}
fn apply_attach_output_chunk(
view_state: &mut AttachViewState,
pane_id: Uuid,
bytes: &[u8],
meta: AttachOutputChunkMeta,
frame_needs_render: &mut bool,
) -> AttachChunkApplyOutcome {
let pane_mouse_protocol_hints = &mut view_state.pane_mouse_protocol_hints;
let pane_input_mode_hints = &mut view_state.pane_input_mode_hints;
let mut toggled_alternate = false;
let outcome = apply_attach_output_chunk_with(
&mut view_state.pane_buffers,
pane_id,
bytes,
meta,
|buffer, data| {
if data.is_empty() {
return false;
}
toggled_alternate =
super::render::append_pane_output(buffer, data) || toggled_alternate;
update_protocol_hints_from_state(
pane_mouse_protocol_hints,
pane_input_mode_hints,
pane_id,
buffer.protocol_tracker.protocol_state(),
);
true
},
);
if outcome == (AttachChunkApplyOutcome::Applied { had_data: true }) {
view_state
.dirty
.mark_pane_dirty(pane_id, AttachDirtySource::PaneOutput);
*frame_needs_render = true;
}
if toggled_alternate {
view_state
.dirty
.mark_full_frame(AttachDirtySource::AlternateScreenTransition);
view_state.force_cursor_move_next_frame = true;
}
outcome
}
fn bytes_contain_sequence(bytes: &[u8], needle: &[u8]) -> bool {
!needle.is_empty() && bytes.windows(needle.len()).any(|window| window == needle)
}
fn chunk_may_disable_mouse_protocol(bytes: &[u8]) -> bool {
const MOUSE_DISABLE_SEQUENCES: &[&[u8]] = &[
b"\x1b[?9l",
b"\x1b[?1000l",
b"\x1b[?1002l",
b"\x1b[?1003l",
b"\x1b[?1005l",
b"\x1b[?1006l",
b"\x1b[?1015l",
];
MOUSE_DISABLE_SEQUENCES
.iter()
.any(|sequence| bytes_contain_sequence(bytes, sequence))
}
fn apply_attach_output_chunk_protocol_only(
view_state: &mut AttachViewState,
pane_id: Uuid,
bytes: &[u8],
meta: AttachOutputChunkMeta,
) -> AttachChunkApplyOutcome {
let pane_mouse_protocol_hints = &mut view_state.pane_mouse_protocol_hints;
let pane_input_mode_hints = &mut view_state.pane_input_mode_hints;
let mut toggled_alternate = false;
let outcome = apply_attach_output_chunk_with(
&mut view_state.pane_buffers,
pane_id,
bytes,
meta,
|buffer, data| {
if data.is_empty() {
return false;
}
let previous_mouse_hint = pane_mouse_protocol_hints.get(&pane_id).copied();
let was_alternate = buffer.protocol_tracker.alternate_screen();
let protocol_outcome = buffer.protocol_tracker.process(data);
let is_alternate = buffer.protocol_tracker.alternate_screen();
toggled_alternate = toggled_alternate
|| protocol_outcome.toggled_alternate
|| was_alternate != is_alternate;
update_protocol_hints_from_state(
pane_mouse_protocol_hints,
pane_input_mode_hints,
pane_id,
buffer.protocol_tracker.protocol_state(),
);
if pane_mouse_protocol_hints
.get(&pane_id)
.is_some_and(|hint| hint.mode == AttachMouseProtocolMode::None)
&& !chunk_may_disable_mouse_protocol(data)
&& let Some(previous) = previous_mouse_hint
{
pane_mouse_protocol_hints.insert(pane_id, previous);
}
true
},
);
if toggled_alternate {
view_state
.dirty
.mark_full_frame(AttachDirtySource::AlternateScreenTransition);
view_state.force_cursor_move_next_frame = true;
}
outcome
}
async fn recover_attach_output_desync_for_pane(
client: &mut StreamingBmuxClient,
view_state: &mut AttachViewState,
pane_id: Uuid,
) -> std::result::Result<(), ClientError> {
if let Some(layout_state) = view_state.cached_layout_state.clone()
&& attach_layout_pane_id_set(&layout_state).contains(&pane_id)
&& hydrate_attach_revealed_panes_from_snapshot(
client,
view_state,
&layout_state,
&[pane_id],
)
.await
.is_ok()
{
view_state
.dirty
.mark_full_frame(AttachDirtySource::SnapshotHydration);
return Ok(());
}
hydrate_attach_state_from_snapshot_mode(client, view_state, SnapshotHydrationMode::FullResync)
.await
}
#[derive(Debug, Clone, Default)]
#[allow(clippy::struct_excessive_bools)] pub struct AttachFrameRenderStats {
pub frame_bytes: usize,
pub terminal_write_ms: u64,
pub damage_rects: usize,
pub damage_area_cells: u64,
pub full_surface_fallbacks: usize,
pub full_frame_fallback: bool,
pub scene_render: AttachSceneRenderStats,
pub status_rendered: bool,
pub overlay_rendered: bool,
pub synchronized_update: bool,
pub dirty_event_count: usize,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
struct AttachRenderInefficiencyFlags {
bits: u16,
}
impl AttachRenderInefficiencyFlags {
const DIRTY_NO_VISIBLE_ROW_CHANGE: u16 = 1 << 0;
const HIGH_CACHED_SKIP_RATIO: u16 = 1 << 1;
const LARGE_PARTIAL_FRAME: u16 = 1 << 2;
const EXTENSION_FULL_SURFACE_EXCESSIVE: u16 = 1 << 3;
const FULL_FRAME_FALLBACK: u16 = 1 << 4;
const EXTENSION_IMPERATIVE_OR_CACHE_MISS: u16 = 1 << 5;
const STATUS_OVERLAY_ONLY_EMITS_PANE_WORK: u16 = 1 << 6;
const SLOW_TERMINAL_WRITE_PER_KIB: u16 = 1 << 7;
const fn is_empty(self) -> bool {
self.bits == 0
}
const fn contains(self, flag: u16) -> bool {
self.bits & flag != 0
}
const fn with(mut self, flag: u16, enabled: bool) -> Self {
if enabled {
self.bits |= flag;
}
self
}
}
const fn percent_at_least(part: u64, total: u64, threshold_percent: u64) -> bool {
total > 0 && part.saturating_mul(100) >= total.saturating_mul(threshold_percent)
}
fn classify_attach_render_inefficiency(
stats: &AttachFrameRenderStats,
) -> AttachRenderInefficiencyFlags {
let emitted_units = stats
.scene_render
.pane_rows_emitted
.saturating_add(stats.scene_render.pane_row_segments_emitted);
let dirty_no_visible_row_change = stats.dirty_event_count > 0
&& stats.scene_render.pane_rows_examined > 0
&& emitted_units == 0;
let high_cached_skip_ratio = stats.scene_render.pane_rows_examined
>= ATTACH_OVERRENDER_MIN_ROWS_EXAMINED
&& percent_at_least(
stats.scene_render.pane_rows_cached_skipped,
stats.scene_render.pane_rows_examined,
ATTACH_OVERRENDER_CACHED_SKIP_RATIO_PERCENT,
);
let large_partial_frame = !stats.full_frame_fallback
&& percent_at_least(
stats.scene_render.pane_cells_emitted,
stats.scene_render.viewport_cells,
ATTACH_OVERRENDER_NON_FULL_FRAME_CELL_RATIO_PERCENT,
);
let extension_full_surface_excessive = percent_at_least(
stats.scene_render.extension_full_surface_calls,
stats.scene_render.extension_render_calls,
ATTACH_OVERRENDER_EXTENSION_FULL_SURFACE_RATIO_PERCENT,
);
let extension_cache_misses = stats
.scene_render
.extension_render_calls
.saturating_sub(stats.scene_render.extension_cache_hits)
.saturating_sub(stats.scene_render.retained_scene_calls);
let extension_imperative_or_cache_miss = percent_at_least(
stats
.scene_render
.extension_imperative_calls
.saturating_add(extension_cache_misses),
stats.scene_render.extension_render_calls,
ATTACH_OVERRENDER_EXTENSION_IMPERATIVE_OR_MISS_RATIO_PERCENT,
);
let status_overlay_only_emits_pane_work = !stats.full_frame_fallback
&& stats.damage_rects == 0
&& stats.full_surface_fallbacks == 0
&& (stats.status_rendered || stats.overlay_rendered)
&& stats.scene_render.pane_cells_emitted > 0;
let slow_terminal_write_per_kib = stats.frame_bytes > 0
&& stats.terminal_write_ms.saturating_mul(1024)
>= u64::try_from(stats.frame_bytes)
.unwrap_or(u64::MAX)
.saturating_mul(ATTACH_OVERRENDER_SLOW_TERMINAL_WRITE_MS_PER_KIB);
AttachRenderInefficiencyFlags::default()
.with(
AttachRenderInefficiencyFlags::DIRTY_NO_VISIBLE_ROW_CHANGE,
dirty_no_visible_row_change,
)
.with(
AttachRenderInefficiencyFlags::HIGH_CACHED_SKIP_RATIO,
high_cached_skip_ratio,
)
.with(
AttachRenderInefficiencyFlags::LARGE_PARTIAL_FRAME,
large_partial_frame,
)
.with(
AttachRenderInefficiencyFlags::EXTENSION_FULL_SURFACE_EXCESSIVE,
extension_full_surface_excessive,
)
.with(
AttachRenderInefficiencyFlags::FULL_FRAME_FALLBACK,
stats.full_frame_fallback,
)
.with(
AttachRenderInefficiencyFlags::EXTENSION_IMPERATIVE_OR_CACHE_MISS,
extension_imperative_or_cache_miss,
)
.with(
AttachRenderInefficiencyFlags::STATUS_OVERLAY_ONLY_EMITS_PANE_WORK,
status_overlay_only_emits_pane_work,
)
.with(
AttachRenderInefficiencyFlags::SLOW_TERMINAL_WRITE_PER_KIB,
slow_terminal_write_per_kib,
)
}
#[derive(Debug, Clone, Copy)]
enum AttachWakeSource {
Server,
Terminal,
Prompt,
ActionDispatch,
Appearance,
Scene,
TabList,
}
#[derive(Debug, Clone, Copy)]
struct AttachDirtyReasons {
bits: u8,
}
impl AttachDirtyReasons {
const FULL_PANE: u8 = 1 << 0;
const OVERLAY: u8 = 1 << 1;
const PANE_DIRTY: u8 = 1 << 2;
const LAYOUT: u8 = 1 << 3;
const SCENE_HYDRATED: u8 = 1 << 4;
const EXTENSION: u8 = 1 << 5;
const PRECISE_DAMAGE: u8 = 1 << 6;
fn from_view_state(view_state: &AttachViewState, layout: bool, scene_hydrated: bool) -> Self {
let mut bits = 0;
if view_state.dirty.full_pane_redraw {
bits |= Self::FULL_PANE;
}
if view_state.dirty.overlay_needs_redraw {
bits |= Self::OVERLAY;
}
if !view_state.dirty.pane_dirty_ids.is_empty() {
bits |= Self::PANE_DIRTY;
}
if layout {
bits |= Self::LAYOUT;
}
if scene_hydrated {
bits |= Self::SCENE_HYDRATED;
}
if view_state.dirty.extension_needs_redraw {
bits |= Self::EXTENSION;
}
if !view_state.dirty.precise_frame_damage.is_empty() {
bits |= Self::PRECISE_DAMAGE;
}
Self { bits }
}
const fn contains(self, flag: u8) -> bool {
self.bits & flag != 0
}
fn labels(self) -> Vec<&'static str> {
let mut labels = Vec::new();
if self.contains(Self::FULL_PANE) {
labels.push("full_pane");
}
if self.contains(Self::OVERLAY) {
labels.push("overlay");
}
if self.contains(Self::PANE_DIRTY) {
labels.push("pane_dirty");
}
if self.contains(Self::LAYOUT) {
labels.push("layout");
}
if self.contains(Self::SCENE_HYDRATED) {
labels.push("scene_hydrated");
}
if self.contains(Self::EXTENSION) {
labels.push("extension");
}
if self.contains(Self::PRECISE_DAMAGE) {
labels.push("precise_damage");
}
labels
}
}
#[derive(Debug, Clone)]
struct AttachPerfWindow {
started_at: Instant,
drain_rounds: u64,
drain_rounds_with_data: u64,
drain_sync_active_rounds: u64,
drain_budget_hits: u64,
drain_ipc_calls: u64,
drain_bytes: u64,
drain_ipc_ms_sum: u64,
drain_ipc_ms_max: u64,
render_frames: u64,
render_ms_sum: u64,
render_ms_max: u64,
render_ms_samples: Vec<u64>,
frame_bytes_max: u64,
terminal_write_ms_max: u64,
damage_rects_max: u64,
damage_area_cells_max: u64,
full_surface_fallbacks: u64,
full_frame_fallbacks: u64,
pane_rows_examined: u64,
pane_rows_emitted: u64,
pane_row_segments_emitted: u64,
pane_rows_cached_skipped: u64,
pane_rows_sync_deferred: u64,
pane_cells_emitted: u64,
extension_render_calls: u64,
extension_render_op_calls: u64,
extension_imperative_calls: u64,
extension_cache_hits: u64,
extension_full_surface_calls: u64,
retained_scene_calls: u64,
retained_scene_items: u64,
retained_scene_diff_items: u64,
retained_scene_output_items: u64,
retained_scene_graphic_items: u64,
retained_scene_output_graphic_items: u64,
retained_scene_added_items: u64,
retained_scene_changed_items: u64,
retained_scene_removed_items: u64,
retained_scene_unchanged_items: u64,
retained_scene_update_cells: u64,
retained_scene_stale_cleanup_cells: u64,
retained_scene_content_replay_cells: u64,
retained_scene_full_surface_fallbacks: u64,
extension_stats: BTreeMap<String, ExtensionRenderStats>,
terminal_graphic_transmits: u64,
terminal_graphic_places: u64,
terminal_graphic_deletes: u64,
terminal_graphic_bytes: u64,
status_rendered_frames: u64,
overlay_rendered_frames: u64,
synchronized_update_frames: u64,
dirty_events: u64,
overrender_flagged_frames: u64,
dirty_no_visible_row_change_frames: u64,
high_cached_skip_ratio_frames: u64,
large_partial_frame_frames: u64,
extension_full_surface_excessive_frames: u64,
full_frame_fallback_flagged_frames: u64,
extension_imperative_or_cache_miss_frames: u64,
status_overlay_only_emits_pane_work_frames: u64,
slow_terminal_write_per_kib_frames: u64,
event_burst_drained_events: u64,
event_burst_max_events: u64,
wake_server_events: u64,
wake_terminal_events: u64,
wake_prompt_events: u64,
wake_action_dispatch_events: u64,
wake_appearance_events: u64,
wake_scene_events: u64,
wake_window_list_events: u64,
dirty_status_frames: u64,
dirty_full_pane_frames: u64,
dirty_overlay_frames: u64,
dirty_pane_frames: u64,
dirty_layout_frames: u64,
dirty_scene_hydrated_frames: u64,
dirty_extension_frames: u64,
}
impl AttachPerfWindow {
fn new() -> Self {
Self {
started_at: Instant::now(),
drain_rounds: 0,
drain_rounds_with_data: 0,
drain_sync_active_rounds: 0,
drain_budget_hits: 0,
drain_ipc_calls: 0,
drain_bytes: 0,
drain_ipc_ms_sum: 0,
drain_ipc_ms_max: 0,
render_frames: 0,
render_ms_sum: 0,
render_ms_max: 0,
render_ms_samples: Vec::new(),
frame_bytes_max: 0,
terminal_write_ms_max: 0,
damage_rects_max: 0,
damage_area_cells_max: 0,
full_surface_fallbacks: 0,
full_frame_fallbacks: 0,
pane_rows_examined: 0,
pane_rows_emitted: 0,
pane_row_segments_emitted: 0,
pane_rows_cached_skipped: 0,
pane_rows_sync_deferred: 0,
pane_cells_emitted: 0,
extension_render_calls: 0,
extension_render_op_calls: 0,
extension_imperative_calls: 0,
extension_cache_hits: 0,
extension_full_surface_calls: 0,
retained_scene_calls: 0,
retained_scene_items: 0,
retained_scene_diff_items: 0,
retained_scene_output_items: 0,
retained_scene_graphic_items: 0,
retained_scene_output_graphic_items: 0,
retained_scene_added_items: 0,
retained_scene_changed_items: 0,
retained_scene_removed_items: 0,
retained_scene_unchanged_items: 0,
retained_scene_update_cells: 0,
retained_scene_stale_cleanup_cells: 0,
retained_scene_content_replay_cells: 0,
retained_scene_full_surface_fallbacks: 0,
extension_stats: BTreeMap::new(),
terminal_graphic_transmits: 0,
terminal_graphic_places: 0,
terminal_graphic_deletes: 0,
terminal_graphic_bytes: 0,
status_rendered_frames: 0,
overlay_rendered_frames: 0,
synchronized_update_frames: 0,
dirty_events: 0,
overrender_flagged_frames: 0,
dirty_no_visible_row_change_frames: 0,
high_cached_skip_ratio_frames: 0,
large_partial_frame_frames: 0,
extension_full_surface_excessive_frames: 0,
full_frame_fallback_flagged_frames: 0,
extension_imperative_or_cache_miss_frames: 0,
status_overlay_only_emits_pane_work_frames: 0,
slow_terminal_write_per_kib_frames: 0,
event_burst_drained_events: 0,
event_burst_max_events: 0,
wake_server_events: 0,
wake_terminal_events: 0,
wake_prompt_events: 0,
wake_action_dispatch_events: 0,
wake_appearance_events: 0,
wake_scene_events: 0,
wake_window_list_events: 0,
dirty_status_frames: 0,
dirty_full_pane_frames: 0,
dirty_overlay_frames: 0,
dirty_pane_frames: 0,
dirty_layout_frames: 0,
dirty_scene_hydrated_frames: 0,
dirty_extension_frames: 0,
}
}
const fn record_drain_round(&mut self) {
self.drain_rounds = self.drain_rounds.saturating_add(1);
}
const fn record_drain_result(&mut self, had_data: bool, sync_active: bool) {
if had_data {
self.drain_rounds_with_data = self.drain_rounds_with_data.saturating_add(1);
}
if sync_active {
self.drain_sync_active_rounds = self.drain_sync_active_rounds.saturating_add(1);
}
}
const fn record_drain_budget_hit(&mut self) {
self.drain_budget_hits = self.drain_budget_hits.saturating_add(1);
}
fn record_drain_ipc(&mut self, elapsed_ms: u64, bytes: usize) {
self.drain_ipc_calls = self.drain_ipc_calls.saturating_add(1);
self.drain_ipc_ms_sum = self.drain_ipc_ms_sum.saturating_add(elapsed_ms);
self.drain_ipc_ms_max = self.drain_ipc_ms_max.max(elapsed_ms);
self.drain_bytes = self
.drain_bytes
.saturating_add(u64::try_from(bytes).unwrap_or(u64::MAX));
}
fn record_render_frame(&mut self, elapsed_ms: u64, stats: &AttachFrameRenderStats) {
let inefficiency_flags = classify_attach_render_inefficiency(stats);
self.render_frames = self.render_frames.saturating_add(1);
self.render_ms_sum = self.render_ms_sum.saturating_add(elapsed_ms);
self.render_ms_max = self.render_ms_max.max(elapsed_ms);
self.render_ms_samples.push(elapsed_ms);
self.frame_bytes_max = self
.frame_bytes_max
.max(u64::try_from(stats.frame_bytes).unwrap_or(u64::MAX));
self.terminal_write_ms_max = self.terminal_write_ms_max.max(stats.terminal_write_ms);
self.damage_rects_max = self
.damage_rects_max
.max(u64::try_from(stats.damage_rects).unwrap_or(u64::MAX));
self.damage_area_cells_max = self.damage_area_cells_max.max(stats.damage_area_cells);
self.full_surface_fallbacks = self
.full_surface_fallbacks
.saturating_add(u64::try_from(stats.full_surface_fallbacks).unwrap_or(u64::MAX));
if stats.full_frame_fallback {
self.full_frame_fallbacks = self.full_frame_fallbacks.saturating_add(1);
}
self.record_scene_render_stats(&stats.scene_render);
if stats.status_rendered {
self.status_rendered_frames = self.status_rendered_frames.saturating_add(1);
}
if stats.overlay_rendered {
self.overlay_rendered_frames = self.overlay_rendered_frames.saturating_add(1);
}
if stats.synchronized_update {
self.synchronized_update_frames = self.synchronized_update_frames.saturating_add(1);
}
self.dirty_events = self
.dirty_events
.saturating_add(u64::try_from(stats.dirty_event_count).unwrap_or(u64::MAX));
self.record_render_inefficiency_flags(inefficiency_flags);
}
fn record_scene_render_stats(&mut self, stats: &AttachSceneRenderStats) {
self.pane_rows_examined = self
.pane_rows_examined
.saturating_add(stats.pane_rows_examined);
self.pane_rows_emitted = self
.pane_rows_emitted
.saturating_add(stats.pane_rows_emitted);
self.pane_row_segments_emitted = self
.pane_row_segments_emitted
.saturating_add(stats.pane_row_segments_emitted);
self.pane_rows_cached_skipped = self
.pane_rows_cached_skipped
.saturating_add(stats.pane_rows_cached_skipped);
self.pane_rows_sync_deferred = self
.pane_rows_sync_deferred
.saturating_add(stats.pane_rows_sync_deferred);
self.pane_cells_emitted = self
.pane_cells_emitted
.saturating_add(stats.pane_cells_emitted);
self.record_extension_render_stats(stats);
self.record_terminal_graphic_stats(stats);
}
fn record_extension_render_stats(&mut self, stats: &AttachSceneRenderStats) {
self.extension_render_calls = self
.extension_render_calls
.saturating_add(stats.extension_render_calls);
self.extension_render_op_calls = self
.extension_render_op_calls
.saturating_add(stats.extension_render_op_calls);
self.extension_imperative_calls = self
.extension_imperative_calls
.saturating_add(stats.extension_imperative_calls);
self.extension_cache_hits = self
.extension_cache_hits
.saturating_add(stats.extension_cache_hits);
self.extension_full_surface_calls = self
.extension_full_surface_calls
.saturating_add(stats.extension_full_surface_calls);
self.retained_scene_calls = self
.retained_scene_calls
.saturating_add(stats.retained_scene_calls);
self.retained_scene_items = self
.retained_scene_items
.saturating_add(stats.retained_scene_items);
self.retained_scene_diff_items = self
.retained_scene_diff_items
.saturating_add(stats.retained_scene_diff_items);
self.retained_scene_output_items = self
.retained_scene_output_items
.saturating_add(stats.retained_scene_output_items);
self.retained_scene_graphic_items = self
.retained_scene_graphic_items
.saturating_add(stats.retained_scene_graphic_items);
self.retained_scene_output_graphic_items = self
.retained_scene_output_graphic_items
.saturating_add(stats.retained_scene_output_graphic_items);
self.retained_scene_added_items = self
.retained_scene_added_items
.saturating_add(stats.retained_scene_added_items);
self.retained_scene_changed_items = self
.retained_scene_changed_items
.saturating_add(stats.retained_scene_changed_items);
self.retained_scene_removed_items = self
.retained_scene_removed_items
.saturating_add(stats.retained_scene_removed_items);
self.retained_scene_unchanged_items = self
.retained_scene_unchanged_items
.saturating_add(stats.retained_scene_unchanged_items);
self.retained_scene_update_cells = self
.retained_scene_update_cells
.saturating_add(stats.retained_scene_update_cells);
self.retained_scene_stale_cleanup_cells = self
.retained_scene_stale_cleanup_cells
.saturating_add(stats.retained_scene_stale_cleanup_cells);
self.retained_scene_content_replay_cells = self
.retained_scene_content_replay_cells
.saturating_add(stats.retained_scene_content_replay_cells);
self.retained_scene_full_surface_fallbacks = self
.retained_scene_full_surface_fallbacks
.saturating_add(stats.retained_scene_full_surface_fallbacks);
aggregate_extension_render_stats(&mut self.extension_stats, &stats.extension_stats);
}
const fn record_terminal_graphic_stats(&mut self, stats: &AttachSceneRenderStats) {
self.terminal_graphic_transmits = self
.terminal_graphic_transmits
.saturating_add(stats.terminal_graphic_transmits);
self.terminal_graphic_places = self
.terminal_graphic_places
.saturating_add(stats.terminal_graphic_places);
self.terminal_graphic_deletes = self
.terminal_graphic_deletes
.saturating_add(stats.terminal_graphic_deletes);
self.terminal_graphic_bytes = self
.terminal_graphic_bytes
.saturating_add(stats.terminal_graphic_bytes);
}
const fn record_render_inefficiency_flags(
&mut self,
inefficiency_flags: AttachRenderInefficiencyFlags,
) {
if !inefficiency_flags.is_empty() {
self.overrender_flagged_frames = self.overrender_flagged_frames.saturating_add(1);
}
if inefficiency_flags.contains(AttachRenderInefficiencyFlags::DIRTY_NO_VISIBLE_ROW_CHANGE) {
self.dirty_no_visible_row_change_frames =
self.dirty_no_visible_row_change_frames.saturating_add(1);
}
if inefficiency_flags.contains(AttachRenderInefficiencyFlags::HIGH_CACHED_SKIP_RATIO) {
self.high_cached_skip_ratio_frames =
self.high_cached_skip_ratio_frames.saturating_add(1);
}
if inefficiency_flags.contains(AttachRenderInefficiencyFlags::LARGE_PARTIAL_FRAME) {
self.large_partial_frame_frames = self.large_partial_frame_frames.saturating_add(1);
}
if inefficiency_flags
.contains(AttachRenderInefficiencyFlags::EXTENSION_FULL_SURFACE_EXCESSIVE)
{
self.extension_full_surface_excessive_frames = self
.extension_full_surface_excessive_frames
.saturating_add(1);
}
if inefficiency_flags.contains(AttachRenderInefficiencyFlags::FULL_FRAME_FALLBACK) {
self.full_frame_fallback_flagged_frames =
self.full_frame_fallback_flagged_frames.saturating_add(1);
}
if inefficiency_flags
.contains(AttachRenderInefficiencyFlags::EXTENSION_IMPERATIVE_OR_CACHE_MISS)
{
self.extension_imperative_or_cache_miss_frames = self
.extension_imperative_or_cache_miss_frames
.saturating_add(1);
}
if inefficiency_flags
.contains(AttachRenderInefficiencyFlags::STATUS_OVERLAY_ONLY_EMITS_PANE_WORK)
{
self.status_overlay_only_emits_pane_work_frames = self
.status_overlay_only_emits_pane_work_frames
.saturating_add(1);
}
if inefficiency_flags.contains(AttachRenderInefficiencyFlags::SLOW_TERMINAL_WRITE_PER_KIB) {
self.slow_terminal_write_per_kib_frames =
self.slow_terminal_write_per_kib_frames.saturating_add(1);
}
}
fn record_event_burst(&mut self, drained_events: usize) {
let drained_events = u64::try_from(drained_events).unwrap_or(u64::MAX);
self.event_burst_drained_events = self
.event_burst_drained_events
.saturating_add(drained_events);
self.event_burst_max_events = self.event_burst_max_events.max(drained_events);
}
const fn record_wake(&mut self, source: AttachWakeSource) {
match source {
AttachWakeSource::Server => {
self.wake_server_events = self.wake_server_events.saturating_add(1);
}
AttachWakeSource::Terminal => {
self.wake_terminal_events = self.wake_terminal_events.saturating_add(1);
}
AttachWakeSource::Prompt => {
self.wake_prompt_events = self.wake_prompt_events.saturating_add(1);
}
AttachWakeSource::ActionDispatch => {
self.wake_action_dispatch_events =
self.wake_action_dispatch_events.saturating_add(1);
}
AttachWakeSource::Appearance => {
self.wake_appearance_events = self.wake_appearance_events.saturating_add(1);
}
AttachWakeSource::Scene => {
self.wake_scene_events = self.wake_scene_events.saturating_add(1);
}
AttachWakeSource::TabList => {
self.wake_window_list_events = self.wake_window_list_events.saturating_add(1);
}
}
}
const fn record_dirty_reasons(&mut self, reasons: AttachDirtyReasons) {
if reasons.contains(AttachDirtyReasons::FULL_PANE) {
self.dirty_full_pane_frames = self.dirty_full_pane_frames.saturating_add(1);
}
if reasons.contains(AttachDirtyReasons::OVERLAY) {
self.dirty_overlay_frames = self.dirty_overlay_frames.saturating_add(1);
}
if reasons.contains(AttachDirtyReasons::PANE_DIRTY) {
self.dirty_pane_frames = self.dirty_pane_frames.saturating_add(1);
}
if reasons.contains(AttachDirtyReasons::LAYOUT) {
self.dirty_layout_frames = self.dirty_layout_frames.saturating_add(1);
}
if reasons.contains(AttachDirtyReasons::SCENE_HYDRATED) {
self.dirty_scene_hydrated_frames = self.dirty_scene_hydrated_frames.saturating_add(1);
}
if reasons.contains(AttachDirtyReasons::EXTENSION) {
self.dirty_extension_frames = self.dirty_extension_frames.saturating_add(1);
}
}
fn reset(&mut self) {
*self = Self::new();
}
}
#[allow(clippy::cast_possible_truncation)]
fn duration_millis_u64(duration: Duration) -> u64 {
duration.as_millis().min(u128::from(u64::MAX)) as u64
}
async fn notify_extensions_of_layout(
client: &mut bmux_client::StreamingBmuxClient,
bus: &bmux_plugin::EventBus,
_previous: Option<&bmux_client::AttachLayoutState>,
current: Option<&bmux_client::AttachLayoutState>,
) -> std::collections::BTreeSet<Uuid> {
use bmux_scene_protocol::scene_protocol::Rect as SceneRect;
let Some(current) = current else {
publish_attach_layout_snapshot(client, bus, &[]).await;
return std::collections::BTreeSet::new();
};
let layout_entries: Vec<AttachSurfaceSummary> = current
.scene
.surfaces
.iter()
.map(|surface| AttachSurfaceSummary {
surface_id: surface.id,
pane_id: surface.pane_id,
rect: SceneRect {
x: surface.rect.x,
y: surface.rect.y,
w: surface.rect.w,
h: surface.rect.h,
},
content_rect: SceneRect {
x: surface.content_rect.x,
y: surface.content_rect.y,
w: surface.content_rect.w,
h: surface.content_rect.h,
},
visible: surface.visible,
})
.collect();
publish_attach_layout_snapshot(client, bus, &layout_entries).await;
let mut current_pane_ids = std::collections::BTreeSet::new();
for surface in ¤t.scene.surfaces {
let Some(pane_id) = surface.pane_id else {
continue;
};
if !surface.visible {
continue;
}
current_pane_ids.insert(pane_id);
}
current_pane_ids
}
fn mark_pane_surface_dirty(
dirty: &mut AttachDirtyFlags,
layout_state: &AttachLayoutState,
pane_id: Uuid,
source: AttachDirtySource,
) -> bool {
let mut marked = false;
for surface in &layout_state.scene.surfaces {
if !surface.visible || surface.pane_id != Some(pane_id) {
continue;
}
dirty.mark_surface_changed(surface.id, source);
marked = true;
}
marked
}
async fn publish_attach_layout_snapshot(
client: &mut bmux_client::StreamingBmuxClient,
bus: &bmux_plugin::EventBus,
surfaces: &[AttachSurfaceSummary],
) {
use std::sync::atomic::{AtomicU64, Ordering};
static REVISION: AtomicU64 = AtomicU64::new(0);
let revision = REVISION.fetch_add(1, Ordering::Relaxed) + 1;
let payload = AttachLayoutSnapshot {
surfaces: surfaces.to_vec(),
revision,
};
let _ = bus.publish_state(&ATTACH_LAYOUT_STATE_KIND, payload.clone());
let encoded = match serde_json::to_vec(&payload) {
Ok(bytes) => bytes,
Err(error) => {
tracing::warn!(%error, "failed encoding attach-layout snapshot for plugin-bus relay");
return;
}
};
if let Err(error) = client
.emit_on_plugin_bus(ATTACH_LAYOUT_STATE_KIND.as_str(), encoded)
.await
{
tracing::debug!(%error, "emit_on_plugin_bus for attach-layout failed");
}
}
fn aggregate_extension_render_stats(
target: &mut BTreeMap<String, ExtensionRenderStats>,
source: &BTreeMap<String, ExtensionRenderStats>,
) {
for (name, stats) in source {
let entry = target.entry(name.clone()).or_default();
entry.render_calls = entry.render_calls.saturating_add(stats.render_calls);
entry.render_op_calls = entry.render_op_calls.saturating_add(stats.render_op_calls);
entry.imperative_calls = entry
.imperative_calls
.saturating_add(stats.imperative_calls);
entry.cache_hits = entry.cache_hits.saturating_add(stats.cache_hits);
entry.full_surface_calls = entry
.full_surface_calls
.saturating_add(stats.full_surface_calls);
entry.region_count = entry.region_count.saturating_add(stats.region_count);
entry.retained_scene_calls = entry
.retained_scene_calls
.saturating_add(stats.retained_scene_calls);
entry.retained_scene_items = entry
.retained_scene_items
.saturating_add(stats.retained_scene_items);
entry.retained_scene_diff_items = entry
.retained_scene_diff_items
.saturating_add(stats.retained_scene_diff_items);
entry.retained_scene_output_items = entry
.retained_scene_output_items
.saturating_add(stats.retained_scene_output_items);
entry.retained_scene_graphic_items = entry
.retained_scene_graphic_items
.saturating_add(stats.retained_scene_graphic_items);
entry.retained_scene_output_graphic_items = entry
.retained_scene_output_graphic_items
.saturating_add(stats.retained_scene_output_graphic_items);
entry.retained_scene_added_items = entry
.retained_scene_added_items
.saturating_add(stats.retained_scene_added_items);
entry.retained_scene_changed_items = entry
.retained_scene_changed_items
.saturating_add(stats.retained_scene_changed_items);
entry.retained_scene_removed_items = entry
.retained_scene_removed_items
.saturating_add(stats.retained_scene_removed_items);
entry.retained_scene_unchanged_items = entry
.retained_scene_unchanged_items
.saturating_add(stats.retained_scene_unchanged_items);
entry.retained_scene_update_cells = entry
.retained_scene_update_cells
.saturating_add(stats.retained_scene_update_cells);
entry.retained_scene_stale_cleanup_cells = entry
.retained_scene_stale_cleanup_cells
.saturating_add(stats.retained_scene_stale_cleanup_cells);
entry.retained_scene_content_replay_cells = entry
.retained_scene_content_replay_cells
.saturating_add(stats.retained_scene_content_replay_cells);
entry.retained_scene_full_surface_fallbacks = entry
.retained_scene_full_surface_fallbacks
.saturating_add(stats.retained_scene_full_surface_fallbacks);
}
}
fn extension_render_stats_payload(
extension_stats: &BTreeMap<String, ExtensionRenderStats>,
) -> serde_json::Value {
serde_json::Value::Object(
extension_stats
.iter()
.map(|(name, stats)| {
(
name.clone(),
serde_json::json!({
"render_calls": stats.render_calls,
"render_op_calls": stats.render_op_calls,
"imperative_calls": stats.imperative_calls,
"cache_hits": stats.cache_hits,
"full_surface_calls": stats.full_surface_calls,
"region_count": stats.region_count,
"retained_scene_calls": stats.retained_scene_calls,
"retained_scene_items": stats.retained_scene_items,
"retained_scene_diff_items": stats.retained_scene_diff_items,
"retained_scene_output_items": stats.retained_scene_output_items,
"retained_scene_graphic_items": stats.retained_scene_graphic_items,
"retained_scene_output_graphic_items": stats.retained_scene_output_graphic_items,
"retained_scene_added_items": stats.retained_scene_added_items,
"retained_scene_changed_items": stats.retained_scene_changed_items,
"retained_scene_removed_items": stats.retained_scene_removed_items,
"retained_scene_unchanged_items": stats.retained_scene_unchanged_items,
"retained_scene_update_cells": stats.retained_scene_update_cells,
"retained_scene_stale_cleanup_cells": stats.retained_scene_stale_cleanup_cells,
"retained_scene_content_replay_cells": stats.retained_scene_content_replay_cells,
"retained_scene_full_surface_fallbacks": stats.retained_scene_full_surface_fallbacks,
}),
)
})
.collect(),
)
}
fn insert_attach_retained_scene_payload(
object: &mut serde_json::Map<String, serde_json::Value>,
window: &AttachPerfWindow,
) {
object.insert(
"retained_scene_calls".to_string(),
window.retained_scene_calls.into(),
);
object.insert(
"retained_scene_items".to_string(),
window.retained_scene_items.into(),
);
object.insert(
"retained_scene_diff_items".to_string(),
window.retained_scene_diff_items.into(),
);
object.insert(
"retained_scene_output_items".to_string(),
window.retained_scene_output_items.into(),
);
object.insert(
"retained_scene_graphic_items".to_string(),
window.retained_scene_graphic_items.into(),
);
object.insert(
"retained_scene_output_graphic_items".to_string(),
window.retained_scene_output_graphic_items.into(),
);
object.insert(
"retained_scene_added_items".to_string(),
window.retained_scene_added_items.into(),
);
object.insert(
"retained_scene_changed_items".to_string(),
window.retained_scene_changed_items.into(),
);
object.insert(
"retained_scene_removed_items".to_string(),
window.retained_scene_removed_items.into(),
);
object.insert(
"retained_scene_unchanged_items".to_string(),
window.retained_scene_unchanged_items.into(),
);
object.insert(
"retained_scene_update_cells".to_string(),
window.retained_scene_update_cells.into(),
);
object.insert(
"retained_scene_stale_cleanup_cells".to_string(),
window.retained_scene_stale_cleanup_cells.into(),
);
object.insert(
"retained_scene_content_replay_cells".to_string(),
window.retained_scene_content_replay_cells.into(),
);
object.insert(
"retained_scene_full_surface_fallbacks".to_string(),
window.retained_scene_full_surface_fallbacks.into(),
);
}
fn insert_attach_frame_retained_scene_payload(
object: &mut serde_json::Map<String, serde_json::Value>,
stats: &AttachSceneRenderStats,
) {
object.insert(
"retained_scene_calls".to_string(),
stats.retained_scene_calls.into(),
);
object.insert(
"retained_scene_items".to_string(),
stats.retained_scene_items.into(),
);
object.insert(
"retained_scene_diff_items".to_string(),
stats.retained_scene_diff_items.into(),
);
object.insert(
"retained_scene_output_items".to_string(),
stats.retained_scene_output_items.into(),
);
object.insert(
"retained_scene_graphic_items".to_string(),
stats.retained_scene_graphic_items.into(),
);
object.insert(
"retained_scene_output_graphic_items".to_string(),
stats.retained_scene_output_graphic_items.into(),
);
object.insert(
"retained_scene_added_items".to_string(),
stats.retained_scene_added_items.into(),
);
object.insert(
"retained_scene_changed_items".to_string(),
stats.retained_scene_changed_items.into(),
);
object.insert(
"retained_scene_removed_items".to_string(),
stats.retained_scene_removed_items.into(),
);
object.insert(
"retained_scene_unchanged_items".to_string(),
stats.retained_scene_unchanged_items.into(),
);
object.insert(
"retained_scene_update_cells".to_string(),
stats.retained_scene_update_cells.into(),
);
object.insert(
"retained_scene_stale_cleanup_cells".to_string(),
stats.retained_scene_stale_cleanup_cells.into(),
);
object.insert(
"retained_scene_content_replay_cells".to_string(),
stats.retained_scene_content_replay_cells.into(),
);
object.insert(
"retained_scene_full_surface_fallbacks".to_string(),
stats.retained_scene_full_surface_fallbacks.into(),
);
}
fn insert_attach_terminal_graphics_payload(
object: &mut serde_json::Map<String, serde_json::Value>,
window: &AttachPerfWindow,
) {
object.insert(
"terminal_graphic_transmits".to_string(),
window.terminal_graphic_transmits.into(),
);
object.insert(
"terminal_graphic_places".to_string(),
window.terminal_graphic_places.into(),
);
object.insert(
"terminal_graphic_deletes".to_string(),
window.terminal_graphic_deletes.into(),
);
object.insert(
"terminal_graphic_bytes".to_string(),
window.terminal_graphic_bytes.into(),
);
}
fn insert_attach_extension_work_payload(
object: &mut serde_json::Map<String, serde_json::Value>,
window: &AttachPerfWindow,
) {
object.insert(
"extension_render_calls".to_string(),
window.extension_render_calls.into(),
);
object.insert(
"extension_render_op_calls".to_string(),
window.extension_render_op_calls.into(),
);
object.insert(
"extension_imperative_calls".to_string(),
window.extension_imperative_calls.into(),
);
object.insert(
"extension_cache_hits".to_string(),
window.extension_cache_hits.into(),
);
object.insert(
"extension_full_surface_calls".to_string(),
window.extension_full_surface_calls.into(),
);
insert_attach_retained_scene_payload(object, window);
if !window.extension_stats.is_empty() {
object.insert(
"extension_stats".to_string(),
extension_render_stats_payload(&window.extension_stats),
);
}
}
fn insert_attach_render_work_payload(
object: &mut serde_json::Map<String, serde_json::Value>,
window: &AttachPerfWindow,
) {
object.insert(
"pane_rows_examined".to_string(),
window.pane_rows_examined.into(),
);
object.insert(
"pane_rows_emitted".to_string(),
window.pane_rows_emitted.into(),
);
object.insert(
"pane_row_segments_emitted".to_string(),
window.pane_row_segments_emitted.into(),
);
object.insert(
"pane_rows_cached_skipped".to_string(),
window.pane_rows_cached_skipped.into(),
);
object.insert(
"pane_rows_sync_deferred".to_string(),
window.pane_rows_sync_deferred.into(),
);
object.insert(
"pane_cells_emitted".to_string(),
window.pane_cells_emitted.into(),
);
insert_attach_extension_work_payload(object, window);
insert_attach_terminal_graphics_payload(object, window);
object.insert(
"status_rendered_frames".to_string(),
window.status_rendered_frames.into(),
);
object.insert(
"overlay_rendered_frames".to_string(),
window.overlay_rendered_frames.into(),
);
object.insert(
"synchronized_update_frames".to_string(),
window.synchronized_update_frames.into(),
);
object.insert("dirty_events".to_string(), window.dirty_events.into());
object.insert(
"overrender_flagged_frames".to_string(),
window.overrender_flagged_frames.into(),
);
object.insert(
"dirty_no_visible_row_change_frames".to_string(),
window.dirty_no_visible_row_change_frames.into(),
);
object.insert(
"high_cached_skip_ratio_frames".to_string(),
window.high_cached_skip_ratio_frames.into(),
);
object.insert(
"large_partial_frame_frames".to_string(),
window.large_partial_frame_frames.into(),
);
object.insert(
"extension_full_surface_excessive_frames".to_string(),
window.extension_full_surface_excessive_frames.into(),
);
object.insert(
"full_frame_fallback_flagged_frames".to_string(),
window.full_frame_fallback_flagged_frames.into(),
);
object.insert(
"extension_imperative_or_cache_miss_frames".to_string(),
window.extension_imperative_or_cache_miss_frames.into(),
);
object.insert(
"status_overlay_only_emits_pane_work_frames".to_string(),
window.status_overlay_only_emits_pane_work_frames.into(),
);
object.insert(
"slow_terminal_write_per_kib_frames".to_string(),
window.slow_terminal_write_per_kib_frames.into(),
);
}
fn insert_attach_perf_detailed_payload(
object: &mut serde_json::Map<String, serde_json::Value>,
window: &AttachPerfWindow,
) {
object.insert(
"drain_ipc_ms_sum".to_string(),
window.drain_ipc_ms_sum.into(),
);
object.insert(
"drain_ipc_ms_max".to_string(),
window.drain_ipc_ms_max.into(),
);
object.insert("render_ms_sum".to_string(), window.render_ms_sum.into());
object.insert("render_ms_max".to_string(), window.render_ms_max.into());
object.insert(
"render_ms_samples".to_string(),
serde_json::Value::Array(
window
.render_ms_samples
.iter()
.copied()
.map(serde_json::Value::from)
.collect(),
),
);
object.insert(
"event_burst_drained_events".to_string(),
window.event_burst_drained_events.into(),
);
object.insert(
"event_burst_max_events".to_string(),
window.event_burst_max_events.into(),
);
object.insert(
"wake_server_events".to_string(),
window.wake_server_events.into(),
);
object.insert(
"wake_terminal_events".to_string(),
window.wake_terminal_events.into(),
);
object.insert(
"wake_prompt_events".to_string(),
window.wake_prompt_events.into(),
);
object.insert(
"wake_action_dispatch_events".to_string(),
window.wake_action_dispatch_events.into(),
);
object.insert(
"wake_appearance_events".to_string(),
window.wake_appearance_events.into(),
);
object.insert(
"wake_scene_events".to_string(),
window.wake_scene_events.into(),
);
object.insert(
"wake_window_list_events".to_string(),
window.wake_window_list_events.into(),
);
object.insert(
"dirty_status_frames".to_string(),
window.dirty_status_frames.into(),
);
object.insert(
"dirty_full_pane_frames".to_string(),
window.dirty_full_pane_frames.into(),
);
object.insert(
"dirty_overlay_frames".to_string(),
window.dirty_overlay_frames.into(),
);
object.insert(
"dirty_pane_frames".to_string(),
window.dirty_pane_frames.into(),
);
object.insert(
"dirty_layout_frames".to_string(),
window.dirty_layout_frames.into(),
);
object.insert(
"dirty_scene_hydrated_frames".to_string(),
window.dirty_scene_hydrated_frames.into(),
);
object.insert(
"dirty_extension_frames".to_string(),
window.dirty_extension_frames.into(),
);
insert_attach_render_work_payload(object, window);
if let Some(avg) = window.drain_ipc_ms_sum.checked_div(window.drain_ipc_calls) {
object.insert("drain_ipc_ms_avg".to_string(), avg.into());
}
if let Some(avg) = window.render_ms_sum.checked_div(window.render_frames) {
object.insert("render_ms_avg".to_string(), avg.into());
}
}
fn attach_perf_window_payload(
window: &AttachPerfWindow,
elapsed: Duration,
detailed: bool,
trace: bool,
) -> serde_json::Value {
let mut payload = serde_json::json!({
"window_elapsed_ms": duration_millis_u64(elapsed),
"drain_rounds": window.drain_rounds,
"drain_ipc_calls": window.drain_ipc_calls,
"drain_bytes": window.drain_bytes,
"render_frames": window.render_frames,
"frame_bytes_max": window.frame_bytes_max,
"terminal_write_ms_max": window.terminal_write_ms_max,
"damage_rects_max": window.damage_rects_max,
"damage_area_cells_max": window.damage_area_cells_max,
"full_surface_fallbacks": window.full_surface_fallbacks,
"full_frame_fallbacks": window.full_frame_fallbacks,
"event_burst_drained_events": window.event_burst_drained_events,
"event_burst_max_events": window.event_burst_max_events,
});
if detailed && let Some(object) = payload.as_object_mut() {
insert_attach_perf_detailed_payload(object, window);
}
if trace && let Some(object) = payload.as_object_mut() {
object.insert(
"drain_rounds_with_data".to_string(),
window.drain_rounds_with_data.into(),
);
object.insert(
"drain_sync_active_rounds".to_string(),
window.drain_sync_active_rounds.into(),
);
object.insert(
"drain_budget_hits".to_string(),
window.drain_budget_hits.into(),
);
}
payload
}
async fn maybe_emit_attach_perf_window(
perf_emitter: &mut perf_telemetry::PerfEventEmitter,
client: &mut StreamingBmuxClient,
session_id: Uuid,
window: &mut AttachPerfWindow,
) -> Result<()> {
let elapsed = window.started_at.elapsed();
if elapsed < Duration::from_millis(perf_emitter.window_ms()) {
return Ok(());
}
if window.render_frames >= ATTACH_OVERRENDER_MIN_FRAMES
&& percent_at_least(
window.overrender_flagged_frames,
window.render_frames,
ATTACH_OVERRENDER_WINDOW_RATIO_PERCENT,
)
{
tracing::warn!(
session_id = %session_id,
render_frames = window.render_frames,
overrender_flagged_frames = window.overrender_flagged_frames,
dirty_no_visible_row_change_frames = window.dirty_no_visible_row_change_frames,
high_cached_skip_ratio_frames = window.high_cached_skip_ratio_frames,
large_partial_frame_frames = window.large_partial_frame_frames,
extension_full_surface_excessive_frames = window.extension_full_surface_excessive_frames,
full_frame_fallback_flagged_frames = window.full_frame_fallback_flagged_frames,
extension_imperative_or_cache_miss_frames = window.extension_imperative_or_cache_miss_frames,
status_overlay_only_emits_pane_work_frames = window.status_overlay_only_emits_pane_work_frames,
slow_terminal_write_per_kib_frames = window.slow_terminal_write_per_kib_frames,
"attach.render.overrender.window"
);
}
tracing::info!(
session_id = %session_id,
window_elapsed_ms = duration_millis_u64(elapsed),
drain_rounds = window.drain_rounds,
drain_ipc_calls = window.drain_ipc_calls,
drain_bytes = window.drain_bytes,
drain_ipc_ms_max = window.drain_ipc_ms_max,
render_frames = window.render_frames,
render_ms_max = window.render_ms_max,
frame_bytes_max = window.frame_bytes_max,
terminal_write_ms_max = window.terminal_write_ms_max,
damage_rects_max = window.damage_rects_max,
damage_area_cells_max = window.damage_area_cells_max,
full_surface_fallbacks = window.full_surface_fallbacks,
full_frame_fallbacks = window.full_frame_fallbacks,
drain_budget_hits = window.drain_budget_hits,
event_burst_drained_events = window.event_burst_drained_events,
event_burst_max_events = window.event_burst_max_events,
wake_server_events = window.wake_server_events,
wake_terminal_events = window.wake_terminal_events,
wake_prompt_events = window.wake_prompt_events,
wake_action_dispatch_events = window.wake_action_dispatch_events,
wake_appearance_events = window.wake_appearance_events,
wake_scene_events = window.wake_scene_events,
wake_window_list_events = window.wake_window_list_events,
dirty_status_frames = window.dirty_status_frames,
dirty_full_pane_frames = window.dirty_full_pane_frames,
dirty_overlay_frames = window.dirty_overlay_frames,
dirty_pane_frames = window.dirty_pane_frames,
dirty_layout_frames = window.dirty_layout_frames,
dirty_scene_hydrated_frames = window.dirty_scene_hydrated_frames,
dirty_extension_frames = window.dirty_extension_frames,
"attach.metrics.window"
);
if !perf_emitter.enabled() {
window.reset();
return Ok(());
}
let payload = attach_perf_window_payload(
window,
elapsed,
perf_emitter.level_at_least(perf_telemetry::PerfCaptureLevel::Detailed),
perf_emitter.level_at_least(perf_telemetry::PerfCaptureLevel::Trace),
);
perf_emitter
.emit_with_streaming_client(client, Some(session_id), None, "attach.window", payload)
.await?;
window.reset();
Ok(())
}
#[allow(clippy::too_many_arguments)] fn attach_frame_trace_payload(
rendered_frame_count: u64,
since_attach_start_ms: u64,
frame_render_ms: u64,
scene_hydrated: bool,
dirty_reasons: AttachDirtyReasons,
dirty_events: &[super::state::AttachDirtyEvent],
frame_stats: &AttachFrameRenderStats,
) -> serde_json::Value {
let mut payload = serde_json::json!({
"frame_render_ms": frame_render_ms,
"frame_index": rendered_frame_count,
"since_attach_start_ms": since_attach_start_ms,
"scene_hydrated": scene_hydrated,
"frame_bytes": frame_stats.frame_bytes,
"terminal_write_ms": frame_stats.terminal_write_ms,
"damage_rects": frame_stats.damage_rects,
"damage_area_cells": frame_stats.damage_area_cells,
"full_surface_fallbacks": frame_stats.full_surface_fallbacks,
"full_frame_fallback": frame_stats.full_frame_fallback,
"status_rendered": frame_stats.status_rendered,
"overlay_rendered": frame_stats.overlay_rendered,
"synchronized_update": frame_stats.synchronized_update,
"dirty_event_count": frame_stats.dirty_event_count,
"dirty_reasons": dirty_reasons.labels(),
"dirty_events": dirty_events.iter().map(|event| serde_json::json!({
"source": format!("{:?}", event.source),
"kind": format!("{:?}", event.kind),
"pane_id": event.pane_id,
})).collect::<Vec<_>>(),
"pane_rows_examined": frame_stats.scene_render.pane_rows_examined,
"pane_rows_emitted": frame_stats.scene_render.pane_rows_emitted,
"pane_row_segments_emitted": frame_stats.scene_render.pane_row_segments_emitted,
"pane_rows_cached_skipped": frame_stats.scene_render.pane_rows_cached_skipped,
"pane_cells_emitted": frame_stats.scene_render.pane_cells_emitted,
"extension_render_calls": frame_stats.scene_render.extension_render_calls,
"extension_render_op_calls": frame_stats.scene_render.extension_render_op_calls,
"extension_imperative_calls": frame_stats.scene_render.extension_imperative_calls,
"extension_cache_hits": frame_stats.scene_render.extension_cache_hits,
"extension_full_surface_calls": frame_stats.scene_render.extension_full_surface_calls,
"extension_region_count": frame_stats.scene_render.extension_region_count,
"extension_stats": extension_render_stats_payload(&frame_stats.scene_render.extension_stats),
"terminal_graphic_transmits": frame_stats.scene_render.terminal_graphic_transmits,
"terminal_graphic_places": frame_stats.scene_render.terminal_graphic_places,
"terminal_graphic_deletes": frame_stats.scene_render.terminal_graphic_deletes,
"terminal_graphic_bytes": frame_stats.scene_render.terminal_graphic_bytes,
});
if let serde_json::Value::Object(object) = &mut payload {
insert_attach_frame_retained_scene_payload(object, &frame_stats.scene_render);
}
payload
}
#[allow(clippy::too_many_arguments)] async fn maybe_emit_attach_frame_perf(
perf_emitter: &mut perf_telemetry::PerfEventEmitter,
client: &mut StreamingBmuxClient,
session_id: Uuid,
attach_started_at: Instant,
rendered_frame_count: u64,
frame_render_ms: u64,
scene_hydrated: bool,
dirty_reasons: AttachDirtyReasons,
dirty_events: &[super::state::AttachDirtyEvent],
frame_stats: &AttachFrameRenderStats,
first_frame_emitted: &mut bool,
interactive_ready_emitted: &mut bool,
) -> Result<()> {
if !perf_emitter.enabled() {
return Ok(());
}
let since_attach_start_ms = duration_millis_u64(attach_started_at.elapsed());
if !*first_frame_emitted && perf_emitter.level_at_least(perf_telemetry::PerfCaptureLevel::Basic)
{
perf_emitter
.emit_with_streaming_client(
client,
Some(session_id),
None,
"attach.first_frame",
serde_json::json!({
"time_to_first_frame_ms": since_attach_start_ms,
"frame_render_ms": frame_render_ms,
"frame_index": rendered_frame_count,
"scene_hydrated": scene_hydrated,
}),
)
.await?;
*first_frame_emitted = true;
}
if scene_hydrated
&& !*interactive_ready_emitted
&& perf_emitter.level_at_least(perf_telemetry::PerfCaptureLevel::Basic)
{
perf_emitter
.emit_with_streaming_client(
client,
Some(session_id),
None,
"attach.interactive.ready",
serde_json::json!({
"time_to_interactive_ms": since_attach_start_ms,
"frame_render_ms": frame_render_ms,
"frame_index": rendered_frame_count,
}),
)
.await?;
*interactive_ready_emitted = true;
}
if perf_emitter.level_at_least(perf_telemetry::PerfCaptureLevel::Trace) {
perf_emitter
.emit_with_streaming_client(
client,
Some(session_id),
None,
"attach.frame.trace",
attach_frame_trace_payload(
rendered_frame_count,
since_attach_start_ms,
frame_render_ms,
scene_hydrated,
dirty_reasons,
dirty_events,
frame_stats,
),
)
.await?;
}
Ok(())
}
fn install_bundled_render_extensions(view_state: &mut AttachViewState) {
let registry = std::sync::Arc::new(bmux_plugin::RenderExtensionRegistry::new());
#[cfg(feature = "bundled-plugin-decoration")]
registry.register(bmux_decoration_plugin_renderer::renderer_for_bus(
&view_state.presentation_events,
));
view_state.presentation_extensions = registry;
#[cfg(feature = "bundled-visual-adapters")]
bmux_visual_adapters_plugin_renderer::install();
}
pub async fn run_session_attach_with_client(
client: BmuxClient,
target: Option<&str>,
follow: Option<&str>,
global: bool,
kernel_client_factory: Option<KernelClientFactory>,
) -> Result<AttachRunOutcome> {
let provider = super::provider::resolve(target)?;
let session = provider
.open(
None,
bmux_client::AttachProviderOpenContext {
fallback_client: Some(client),
endpoint_connector: Some(std::sync::Arc::new(CliAttachEndpointConnector)),
},
)
.await?;
Box::pin(run_session_attach_with_provider_session(
session,
target,
follow,
global,
kernel_client_factory,
))
.await
}
#[allow(clippy::too_many_arguments)] pub async fn run_session_attach_with_client_headless(
client: BmuxClient,
target: Option<&str>,
follow: Option<&str>,
global: bool,
kernel_client_factory: Option<KernelClientFactory>,
mut driver: super::headless::HeadlessAttachDriver,
observer: super::observer::AttachObserverPublisher,
) -> Result<AttachRunOutcome> {
let provider = super::provider::resolve(target)?;
if driver.observe_only {
driver.render_provider = Some(provider.render_provider());
}
let session = provider
.open(
None,
bmux_client::AttachProviderOpenContext {
fallback_client: Some(client),
endpoint_connector: Some(std::sync::Arc::new(CliAttachEndpointConnector)),
},
)
.await?;
let effective_target = session.target.as_deref().or(target);
match session.backend {
AttachProviderBackend::Legacy(client) => {
Box::pin(run_session_attach_with_terminal_config_observer(
client,
effective_target,
follow,
global,
kernel_client_factory,
&mut driver,
None,
Some(observer),
))
.await
}
AttachProviderBackend::Session(_) => {
anyhow::bail!("native provider headless presentation is unsupported")
}
}
}
pub async fn run_session_attach_with_client_observer(
client: BmuxClient,
target: Option<&str>,
follow: Option<&str>,
global: bool,
kernel_client_factory: Option<KernelClientFactory>,
observer: super::observer::AttachObserverPublisher,
) -> Result<AttachRunOutcome> {
let provider = super::provider::resolve(target)?;
let session = provider
.open(
None,
bmux_client::AttachProviderOpenContext {
fallback_client: Some(client),
endpoint_connector: Some(std::sync::Arc::new(CliAttachEndpointConnector)),
},
)
.await?;
let effective_target = session.target.as_deref().or(target);
match session.backend {
AttachProviderBackend::Legacy(client) => {
let mut terminal = RealAttachTerminal::new();
Box::pin(run_session_attach_with_terminal_config_observer(
client,
effective_target,
follow,
global,
kernel_client_factory,
&mut terminal,
None,
Some(observer),
))
.await
}
AttachProviderBackend::Session(_) => anyhow::bail!(
"full-view browser observation requires a local retained attach; provider-only observation is unsupported"
),
}
}
pub async fn run_session_attach_with_provider_session(
session: bmux_client::AttachProviderSession,
original_target: Option<&str>,
follow: Option<&str>,
global: bool,
kernel_client_factory: Option<KernelClientFactory>,
) -> Result<AttachRunOutcome> {
let effective_target = session.target.as_deref().or(original_target);
match session.backend {
AttachProviderBackend::Legacy(client) => {
let mut terminal = RealAttachTerminal::new();
Box::pin(run_session_attach_with_terminal(
client,
effective_target,
follow,
global,
kernel_client_factory,
&mut terminal,
))
.await
}
AttachProviderBackend::Session(provider_session) => {
let mut terminal = RealAttachTerminal::new();
run_native_attach_session_with_terminal(provider_session, &mut terminal).await
}
}
}
#[allow(clippy::too_many_lines)] pub async fn run_native_attach_session_with_terminal<T: AttachTerminal>(
mut session: Box<dyn AttachSession>,
terminal: &mut T,
) -> Result<AttachRunOutcome> {
let mut continuity = AttachContinuityValidator::default();
let mut controls = AttachControlValidator::default();
let snapshot = session
.initial_snapshot()
.await
.map_err(|error| anyhow::anyhow!("attach provider snapshot failed: {error}"))?;
continuity
.apply_snapshot(&snapshot)
.map_err(|error| anyhow::anyhow!("invalid attach provider snapshot: {error}"))?;
let keyboard_enhanced = terminal.enter_attach_mode(true, true, true)?;
let native_config = match BmuxConfig::load() {
Ok(config) => config,
Err(error) => {
eprintln!(
"bmux warning: failed loading config for provider attach keymap, using defaults ({error})"
);
BmuxConfig::default()
}
};
let mut input_processor =
InputProcessor::new(attach_keymap_from_config(&native_config), keyboard_enhanced);
let mut scene = snapshot.scene.clone();
let mut pane_buffers = provider_pane_buffers_from_snapshot(&snapshot)?;
let mut terminal_graphics_cache = TerminalGraphicsCache::new();
let mut focused_stream = focused_provider_stream(&snapshot);
render_native_provider_scene(
terminal,
&scene,
&mut pane_buffers,
&mut terminal_graphics_cache,
)?;
let geometry = terminal.geometry();
let mut command_sequence = 1_u64;
let viewport = AttachProviderViewport {
command_sequence,
columns: geometry.cols,
rows: geometry.rows,
pixel_width: None,
pixel_height: None,
};
controls
.validate_viewport(&viewport)
.map_err(|error| anyhow::anyhow!("invalid initial viewport: {error}"))?;
let ack = session
.update_viewport(viewport)
.await
.map_err(|error| anyhow::anyhow!("attach provider viewport failed: {error}"))?;
ensure_provider_ack("viewport", &ack)?;
let outcome = loop {
tokio::select! {
biased;
event = session.next_event() => {
let event = event.map_err(|error| anyhow::anyhow!("attach provider event failed: {error}"))?;
if let Some(event_outcome) = handle_native_provider_event(
terminal,
&mut continuity,
&mut scene,
&mut pane_buffers,
&mut terminal_graphics_cache,
&mut focused_stream,
event,
)? {
break event_outcome;
}
}
event = terminal.next_event() => {
let Some(event) = event? else {
break AttachRunOutcome {
status_code: 0,
exit_reason: AttachExitReason::Quit,
resume: None,
};
};
command_sequence = command_sequence.saturating_add(1);
if handle_native_provider_terminal_event(
session.as_mut(),
&continuity,
&mut controls,
&scene,
&pane_buffers,
&mut input_processor,
focused_stream.as_ref(),
command_sequence,
keyboard_enhanced,
event,
).await? {
break AttachRunOutcome {
status_code: 0,
exit_reason: AttachExitReason::Detached,
resume: None,
};
}
}
}
};
let detach_result = session.detach().await;
let _ = controls.detach();
terminal.restore_after_attach_ui()?;
if let Err(error) = detach_result
&& outcome.exit_reason != AttachExitReason::StreamClosed
{
return Err(anyhow::anyhow!("attach provider detach failed: {error}"));
}
Ok(outcome)
}
fn handle_native_provider_event<W: AttachTerminal>(
terminal: &mut W,
continuity: &mut AttachContinuityValidator,
scene: &mut bmux_attach_layout_protocol::AttachScene,
pane_buffers: &mut BTreeMap<Uuid, PaneRenderBuffer>,
terminal_graphics_cache: &mut TerminalGraphicsCache,
focused_stream: &mut Option<AttachStreamCursor>,
event: AttachProviderEvent,
) -> Result<Option<AttachRunOutcome>> {
match event {
AttachProviderEvent::Delta(delta) => {
continuity
.apply_delta(&delta)
.map_err(|error| anyhow::anyhow!("invalid attach provider delta: {error}"))?;
if let Some(replacement) = delta.changes.iter().find_map(|change| {
if let AttachProviderChange::Scene(replacement) = change {
Some(replacement)
} else {
None
}
}) {
scene.clone_from(replacement);
*focused_stream = focused_provider_stream_for_scene(
replacement,
&continuity.resume_state().streams,
);
}
apply_provider_delta(scene, pane_buffers, &delta)?;
render_native_provider_scene(terminal, scene, pane_buffers, terminal_graphics_cache)?;
Ok(None)
}
AttachProviderEvent::Disconnected(disconnect) => {
let resume = if disconnect.recoverable {
let validated = continuity.resume_state();
if let Some(candidate) = disconnect.resume {
if candidate != validated {
anyhow::bail!(
"attach provider disconnect resume state did not match validated state"
);
}
Some(candidate)
} else {
Some(validated)
}
} else {
None
};
Ok(Some(AttachRunOutcome {
status_code: u8::from(!disconnect.recoverable),
exit_reason: AttachExitReason::StreamClosed,
resume,
}))
}
AttachProviderEvent::Detached => Ok(Some(AttachRunOutcome {
status_code: 0,
exit_reason: AttachExitReason::Detached,
resume: None,
})),
}
}
fn provider_pane_buffers_from_snapshot(
snapshot: &AttachProviderSnapshot,
) -> Result<BTreeMap<Uuid, PaneRenderBuffer>> {
let mut buffers = BTreeMap::new();
for stream in &snapshot.streams {
let mut buffer = provider_render_buffer(&snapshot.scene, stream.cursor.surface_id)?;
append_pane_output(&mut buffer, &stream.snapshot);
buffers.insert(stream.cursor.surface_id, buffer);
}
Ok(buffers)
}
fn provider_render_buffer(
scene: &bmux_attach_layout_protocol::AttachScene,
surface_id: Uuid,
) -> Result<PaneRenderBuffer> {
let surface = scene
.surfaces
.iter()
.find(|surface| surface.id == surface_id)
.ok_or_else(|| anyhow::anyhow!("attach provider stream surface {surface_id} is missing"))?;
let mut buffer = PaneRenderBuffer::default();
buffer
.terminal_grid
.resize(surface.content_rect.w.max(1), surface.content_rect.h.max(1))
.map_err(|error| anyhow::anyhow!("attach provider surface grid is invalid: {error}"))?;
Ok(buffer)
}
fn apply_provider_delta(
scene: &bmux_attach_layout_protocol::AttachScene,
pane_buffers: &mut BTreeMap<Uuid, PaneRenderBuffer>,
delta: &bmux_client::AttachProviderDelta,
) -> Result<()> {
for change in &delta.changes {
match change {
AttachProviderChange::StreamAppend { cursor, bytes, .. } => {
let buffer = pane_buffers
.entry(cursor.surface_id)
.or_insert(provider_render_buffer(scene, cursor.surface_id)?);
append_pane_output(buffer, bytes);
}
AttachProviderChange::StreamRepair(snapshot) => {
let mut buffer = provider_render_buffer(scene, snapshot.cursor.surface_id)?;
append_pane_output(&mut buffer, &snapshot.snapshot);
pane_buffers.insert(snapshot.cursor.surface_id, buffer);
}
AttachProviderChange::Scene(replacement) => {
for surface in &replacement.surfaces {
if let Some(buffer) = pane_buffers.get_mut(&surface.id) {
buffer
.terminal_grid
.resize(surface.content_rect.w.max(1), surface.content_rect.h.max(1))
.map_err(|error| {
anyhow::anyhow!(
"attach provider replacement surface grid is invalid: {error}"
)
})?;
}
}
}
AttachProviderChange::StreamRemoved { stream_id: _ }
| AttachProviderChange::Status { message: _ } => {}
}
}
let visible = scene
.surfaces
.iter()
.map(|surface| surface.id)
.collect::<BTreeSet<_>>();
pane_buffers.retain(|surface_id, _| visible.contains(surface_id));
Ok(())
}
fn render_native_provider_scene<W: AttachTerminal>(
terminal: &mut W,
scene: &bmux_attach_layout_protocol::AttachScene,
pane_buffers: &mut BTreeMap<Uuid, PaneRenderBuffer>,
terminal_graphics_cache: &mut TerminalGraphicsCache,
) -> Result<()> {
let panes = scene
.surfaces
.iter()
.filter_map(|surface| surface.pane_id)
.enumerate()
.map(
|(index, pane_id)| bmux_attach_layout_protocol::PaneSummary {
id: pane_id,
index: u32::try_from(index).unwrap_or(u32::MAX),
name: None,
focused: matches!(
scene.focus,
bmux_attach_layout_protocol::AttachFocusTarget::Pane { pane_id: focused }
if focused == pane_id
),
state: bmux_attach_layout_protocol::PaneState::Running,
state_reason: None,
},
)
.collect::<Vec<_>>();
let geometry = terminal.geometry();
let _ = render_attach_scene_with_stats_and_trace_with_capabilities(
terminal,
scene,
&panes,
pane_buffers,
terminal_graphics_cache,
&bmux_attach_pipeline::FrameDamage::full_frame(),
0,
0,
&bmux_attach_pipeline::PaneScrollbackViews::new(),
false,
(geometry.cols, geometry.rows),
&RuntimeAppearance::default(),
DamageCoalescingPolicy::default(),
&[],
bmux_plugin::TerminalRenderCapabilities::default(),
None,
)?;
terminal.flush()?;
Ok(())
}
fn focused_provider_stream(snapshot: &AttachProviderSnapshot) -> Option<AttachStreamCursor> {
focused_provider_stream_for_scene(&snapshot.scene, &snapshot.resume.streams)
}
fn focused_provider_stream_for_scene(
scene: &bmux_attach_layout_protocol::AttachScene,
streams: &[AttachStreamCursor],
) -> Option<AttachStreamCursor> {
let surface_id = match scene.focus {
bmux_attach_layout_protocol::AttachFocusTarget::Pane { pane_id } => pane_id,
bmux_attach_layout_protocol::AttachFocusTarget::Surface { surface_id } => surface_id,
bmux_attach_layout_protocol::AttachFocusTarget::None => return None,
};
streams
.iter()
.find(|cursor| cursor.surface_id == surface_id)
.cloned()
}
#[allow(clippy::too_many_arguments, clippy::too_many_lines)] async fn handle_native_provider_terminal_event(
session: &mut dyn AttachSession,
continuity: &AttachContinuityValidator,
controls: &mut AttachControlValidator,
scene: &bmux_attach_layout_protocol::AttachScene,
pane_buffers: &BTreeMap<Uuid, PaneRenderBuffer>,
input_processor: &mut InputProcessor,
focused_stream: Option<&AttachStreamCursor>,
command_sequence: u64,
keyboard_enhanced: bool,
event: Event,
) -> Result<bool> {
match event {
Event::Resize(columns, rows) => {
let viewport = AttachProviderViewport {
command_sequence,
columns,
rows,
pixel_width: None,
pixel_height: None,
};
controls
.validate_viewport(&viewport)
.map_err(anyhow::Error::from)?;
let ack = session
.update_viewport(viewport)
.await
.map_err(anyhow::Error::from)?;
ensure_provider_ack("viewport", &ack)?;
}
Event::Paste(text) => {
send_native_provider_input(
session,
continuity,
controls,
focused_stream,
command_sequence,
AttachInputPayload::Paste(text.into_bytes()),
)
.await?;
}
Event::Key(key) if key.kind != KeyEventKind::Release => {
let protocol = focused_provider_protocol(pane_buffers, focused_stream);
input_processor
.set_pane_input_mode(protocol.application_cursor, protocol.application_keypad);
let actions = attach_key_event_actions(&key, input_processor, AttachUiMode::Normal)?;
for action in actions {
match action {
AttachEventAction::Send(bytes) => {
let payload = bmux_keyboard::crossterm::crossterm_key_event_to_stroke(&key)
.map_or(AttachInputPayload::Bytes(bytes), |stroke| {
AttachInputPayload::Key {
stroke,
enhanced: keyboard_enhanced,
}
});
send_native_provider_input(
session,
continuity,
controls,
focused_stream,
command_sequence,
payload,
)
.await?;
}
AttachEventAction::PluginCommand {
plugin_id,
command_name,
args,
}
| AttachEventAction::Ui(RuntimeAction::PluginCommand {
plugin_id,
command_name,
args,
}) => {
let provider_action = native_provider_plugin_action(
command_sequence,
focused_stream,
&plugin_id,
&command_name,
&args,
)?;
execute_native_provider_action(session, controls, provider_action).await?;
}
AttachEventAction::Detach | AttachEventAction::Ui(RuntimeAction::Quit) => {
return Ok(true);
}
AttachEventAction::Ignore
| AttachEventAction::Redraw
| AttachEventAction::Ui(_)
| AttachEventAction::Paste(_)
| AttachEventAction::Mouse(_) => {}
}
}
}
Event::Mouse(mouse) => {
let shared = provider_mouse_to_shared(mouse);
let Some((pane_id, content_rect)) =
bmux_attach_pipeline::mouse::pane_and_rect_at(scene, mouse.column, mouse.row)
else {
return Ok(false);
};
let Some(local) =
bmux_attach_pipeline::mouse::translate_event_to_pane_local(shared, content_rect)
else {
return Ok(false);
};
let stream = provider_stream_for_surface(continuity, pane_id).ok_or_else(|| {
anyhow::anyhow!("attach provider mouse target has no current stream")
})?;
let protocol = focused_provider_protocol(pane_buffers, Some(&stream));
if protocol.mouse_mode() == bmux_terminal_grid::MouseProtocolMode::None {
if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left))
&& focused_stream.is_none_or(|focused| focused.surface_id != pane_id)
{
let provider_action = bmux_client::AttachProviderAction {
command_id: format!("provider-action:{command_sequence}:mouse-focus"),
action: "focus".to_string(),
arguments: vec![pane_id.to_string()],
};
execute_native_provider_action(session, controls, provider_action).await?;
}
return Ok(false);
}
let (button, phase) = provider_mouse_event(mouse.kind);
send_native_provider_input(
session,
continuity,
controls,
Some(&stream),
command_sequence,
AttachInputPayload::Mouse(ProviderMouseInput {
x: local.column,
y: local.row,
button,
phase,
modifiers: provider_modifier_bits(mouse.modifiers),
}),
)
.await?;
}
Event::FocusGained | Event::FocusLost | Event::Key(_) => {}
}
Ok(false)
}
fn native_provider_plugin_action(
command_sequence: u64,
focused_stream: Option<&AttachStreamCursor>,
plugin_id: &str,
command_name: &str,
args: &[String],
) -> Result<bmux_client::AttachProviderAction> {
if plugin_id != "bmux.tabs" {
anyhow::bail!(
"attach provider does not support caller-process plugin command '{plugin_id}:{command_name}'"
);
}
let focused = || {
focused_stream
.map(|stream| stream.surface_id.to_string())
.ok_or_else(|| anyhow::anyhow!("attach provider has no focused logical pane"))
};
let option = |name: &str| {
args.windows(2)
.find_map(|pair| (pair[0] == name).then(|| pair[1].clone()))
};
let (action, arguments) =
match command_name {
"focus-pane-in-direction" => (
"focus",
vec![option("--direction").ok_or_else(|| {
anyhow::anyhow!("focus-pane-in-direction requires --direction")
})?],
),
"close-active-pane" => ("close", vec![focused()?]),
"split-pane" => (
"split",
vec![
focused()?,
option("--direction")
.ok_or_else(|| anyhow::anyhow!("split-pane requires --direction"))?,
],
),
"zoom-pane" => ("zoom", vec![focused()?]),
"prev-tab" => ("window-prev", Vec::new()),
"next-tab" => ("window-next", Vec::new()),
"goto-tab" => ("window-goto", args.to_vec()),
"rename-tab" => ("rename-tab", args.to_vec()),
_ => anyhow::bail!("unsupported logical provider command '{command_name}'"),
};
Ok(bmux_client::AttachProviderAction {
command_id: format!("provider-action:{command_sequence}:{plugin_id}:{command_name}"),
action: action.to_string(),
arguments,
})
}
fn focused_provider_protocol(
pane_buffers: &BTreeMap<Uuid, PaneRenderBuffer>,
focused_stream: Option<&AttachStreamCursor>,
) -> bmux_terminal_grid::ProtocolState {
focused_stream
.and_then(|stream| pane_buffers.get(&stream.surface_id))
.map_or_else(bmux_terminal_grid::ProtocolState::default, |buffer| {
buffer.terminal_grid.grid().protocol_state()
})
}
fn provider_stream_for_surface(
continuity: &AttachContinuityValidator,
surface_id: Uuid,
) -> Option<AttachStreamCursor> {
continuity
.resume_state()
.streams
.into_iter()
.find(|stream| stream.surface_id == surface_id)
}
const fn provider_mouse_to_shared(mouse: MouseEvent) -> bmux_attach_pipeline::AttachMouseEvent {
let (button, phase) = provider_mouse_event(mouse.kind);
let button = match button {
ProviderMouseButton::Left
| ProviderMouseButton::WheelUp
| ProviderMouseButton::WheelDown
| ProviderMouseButton::None => bmux_attach_pipeline::AttachMouseButton::Left,
ProviderMouseButton::Middle => bmux_attach_pipeline::AttachMouseButton::Middle,
ProviderMouseButton::Right => bmux_attach_pipeline::AttachMouseButton::Right,
};
let kind = match phase {
ProviderMousePhase::Press => bmux_attach_pipeline::AttachMouseEventKind::Down(button),
ProviderMousePhase::Release => bmux_attach_pipeline::AttachMouseEventKind::Up(button),
ProviderMousePhase::Move => bmux_attach_pipeline::AttachMouseEventKind::Moved,
ProviderMousePhase::Drag => bmux_attach_pipeline::AttachMouseEventKind::Drag(button),
ProviderMousePhase::Scroll => match mouse.kind {
MouseEventKind::ScrollUp => bmux_attach_pipeline::AttachMouseEventKind::ScrollUp,
MouseEventKind::ScrollDown => bmux_attach_pipeline::AttachMouseEventKind::ScrollDown,
MouseEventKind::ScrollLeft => bmux_attach_pipeline::AttachMouseEventKind::ScrollLeft,
MouseEventKind::ScrollRight => bmux_attach_pipeline::AttachMouseEventKind::ScrollRight,
_ => bmux_attach_pipeline::AttachMouseEventKind::Moved,
},
};
bmux_attach_pipeline::AttachMouseEvent {
kind,
column: mouse.column,
row: mouse.row,
modifiers: bmux_attach_pipeline::AttachMouseModifiers {
shift: mouse.modifiers.contains(KeyModifiers::SHIFT),
alt: mouse.modifiers.contains(KeyModifiers::ALT),
control: mouse.modifiers.contains(KeyModifiers::CONTROL),
},
}
}
async fn send_native_provider_input(
session: &mut dyn AttachSession,
continuity: &AttachContinuityValidator,
controls: &mut AttachControlValidator,
focused_stream: Option<&AttachStreamCursor>,
command_sequence: u64,
payload: AttachInputPayload,
) -> Result<()> {
let stream =
focused_stream.ok_or_else(|| anyhow::anyhow!("attach provider has no focused stream"))?;
let input = AttachProviderInput {
command_sequence,
stream_id: stream.stream_id.clone(),
generation: stream.generation,
payload,
};
controls
.validate_input(&input, continuity)
.map_err(anyhow::Error::from)?;
let ack = session
.send_input(input)
.await
.map_err(anyhow::Error::from)?;
ensure_provider_ack("input", &ack)
}
pub async fn execute_native_provider_action(
session: &mut dyn AttachSession,
controls: &mut AttachControlValidator,
action: bmux_client::AttachProviderAction,
) -> Result<()> {
controls
.validate_action(&action)
.map_err(anyhow::Error::from)?;
let ack = session
.execute_action(action)
.await
.map_err(anyhow::Error::from)?;
ensure_provider_ack("action", &ack)
}
fn ensure_provider_ack(operation: &str, ack: &bmux_client::AttachProviderAck) -> Result<()> {
if ack.accepted {
Ok(())
} else {
anyhow::bail!(
"attach provider rejected {operation}: {}",
ack.message.as_deref().unwrap_or("no reason provided")
)
}
}
const fn provider_mouse_event(kind: MouseEventKind) -> (ProviderMouseButton, ProviderMousePhase) {
match kind {
MouseEventKind::Down(button) => (provider_mouse_button(button), ProviderMousePhase::Press),
MouseEventKind::Up(button) => (provider_mouse_button(button), ProviderMousePhase::Release),
MouseEventKind::Drag(button) => (provider_mouse_button(button), ProviderMousePhase::Drag),
MouseEventKind::Moved => (ProviderMouseButton::None, ProviderMousePhase::Move),
MouseEventKind::ScrollUp => (ProviderMouseButton::WheelUp, ProviderMousePhase::Scroll),
MouseEventKind::ScrollDown => (ProviderMouseButton::WheelDown, ProviderMousePhase::Scroll),
MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight => {
(ProviderMouseButton::None, ProviderMousePhase::Scroll)
}
}
}
const fn provider_mouse_button(button: MouseButton) -> ProviderMouseButton {
match button {
MouseButton::Left => ProviderMouseButton::Left,
MouseButton::Middle => ProviderMouseButton::Middle,
MouseButton::Right => ProviderMouseButton::Right,
}
}
const fn provider_modifier_bits(modifiers: KeyModifiers) -> u8 {
(modifiers.contains(KeyModifiers::SHIFT) as u8)
| ((modifiers.contains(KeyModifiers::CONTROL) as u8) << 1)
| ((modifiers.contains(KeyModifiers::ALT) as u8) << 2)
| ((modifiers.contains(KeyModifiers::SUPER) as u8) << 3)
| ((modifiers.contains(KeyModifiers::HYPER) as u8) << 4)
| ((modifiers.contains(KeyModifiers::META) as u8) << 5)
}
pub async fn run_session_attach_with_terminal<T: AttachTerminal + ?Sized>(
client: BmuxClient,
target: Option<&str>,
follow: Option<&str>,
global: bool,
kernel_client_factory: Option<KernelClientFactory>,
terminal: &mut T,
) -> Result<AttachRunOutcome> {
run_session_attach_with_terminal_config(
client,
target,
follow,
global,
kernel_client_factory,
terminal,
None,
)
.await
}
#[allow(clippy::too_many_arguments, clippy::too_many_lines)] pub async fn run_session_attach_with_terminal_config<T: AttachTerminal + ?Sized>(
client: BmuxClient,
target: Option<&str>,
follow: Option<&str>,
global: bool,
kernel_client_factory: Option<KernelClientFactory>,
terminal: &mut T,
config: Option<BmuxConfig>,
) -> Result<AttachRunOutcome> {
run_session_attach_with_terminal_config_observer(
client,
target,
follow,
global,
kernel_client_factory,
terminal,
config,
None,
)
.await
}
#[allow(clippy::too_many_arguments, clippy::too_many_lines)] pub async fn run_session_attach_with_terminal_config_observer<T: AttachTerminal + ?Sized>(
mut client: BmuxClient,
target: Option<&str>,
follow: Option<&str>,
global: bool,
kernel_client_factory: Option<KernelClientFactory>,
terminal: &mut T,
config: Option<BmuxConfig>,
observer: Option<super::observer::AttachObserverPublisher>,
) -> Result<AttachRunOutcome> {
if target.is_none() && follow.is_none() {
anyhow::bail!("attach requires a session target or --follow <client-uuid>");
}
if target.is_some() && follow.is_some() {
anyhow::bail!("attach accepts either a session target or --follow, not both");
}
tracing::info!(
target = target.unwrap_or(""),
follow = follow.unwrap_or(""),
global,
has_kernel_client_factory = kernel_client_factory.is_some(),
"attach.runtime.start"
);
let follow_target_id = match follow {
Some(follow_target) => Some(parse_uuid_value(follow_target, "follow target client id")?),
None => None,
};
let attach_config = config.unwrap_or_else(|| match BmuxConfig::load() {
Ok(config) => config,
Err(error) => {
eprintln!(
"bmux warning: failed loading config for attach keymap, using defaults ({error})"
);
BmuxConfig::default()
}
});
let attach_keymap = attach_keymap_from_config(&attach_config);
let attach_help_lines = build_attach_help_lines(&attach_config);
let mut perf_emitter = perf_telemetry::PerfEventEmitter::new(
perf_telemetry::PerfCaptureSettings::from_config(&attach_config),
);
if let Ok(settings) = performance_state::client::get_settings(&mut client).await {
perf_emitter.update_settings(perf_telemetry::PerfCaptureSettings::from_plugin_settings(
&settings,
));
}
let mut perf_window = AttachPerfWindow::new();
let attach_started_at = Instant::now();
let mut rendered_frame_count = 0_u64;
let mut first_frame_emitted = false;
let mut interactive_ready_emitted = false;
let presentation_events = std::sync::Arc::new(bmux_plugin::EventBus::new());
let (initial_appearance, appearance_rx_raw) = presentation_events
.subscribe_state::<RuntimeAppearance>(&RUNTIME_APPEARANCE_STATE_KIND)
.unwrap_or_else(|_| {
let _ = presentation_events.register_state_channel::<RuntimeAppearance>(
RUNTIME_APPEARANCE_STATE_KIND,
RuntimeAppearance::default(),
);
presentation_events
.subscribe_state::<RuntimeAppearance>(&RUNTIME_APPEARANCE_STATE_KIND)
.expect("runtime appearance state channel was just registered")
});
let mut appearance_rx = Some(appearance_rx_raw);
let mut runtime_appearance = (*initial_appearance).clone();
if let Some(leader_client_id) = follow_target_id {
client
.subscribe_events()
.await
.map_err(map_attach_client_error)?;
clients_state::client::current_client(&mut client)
.await?
.map_err(|err| anyhow::anyhow!("clients-state current-client failed: {err:?}"))?;
bmux_clients_plugin_api::clients_commands::client::set_following(
&mut client,
Some(leader_client_id),
global,
)
.await?
.map_err(|err| anyhow::anyhow!("clients-commands set-following failed: {err:?}"))?;
}
tracing::info!("attach.runtime.whoami_start");
let self_client_id = clients_state::client::current_client(&mut client)
.await?
.map_err(|err| anyhow::anyhow!("clients-state current-client failed: {err:?}"))?
.id;
crate::runtime::set_logging_client_id(self_client_id.to_string());
tracing::info!(client_id = %self_client_id, "attach.client_log.initialized");
let attach_info = if let Some(leader_client_id) = follow_target_id {
let clients = typed_list_clients_bmux(&mut client)
.await
.map_err(map_attach_client_error)?;
let leader = clients
.into_iter()
.find(|entry| entry.id == leader_client_id)
.ok_or_else(|| anyhow::anyhow!("follow target not found"))?;
let context_id = if let Some(cid) = leader.selected_context_id {
cid
} else if let Some(sid) = leader.selected_session_id {
let contexts = typed_list_contexts_bmux(&mut client)
.await
.map_err(map_attach_client_error)?;
contexts
.into_iter()
.find(|ctx| {
ctx.attributes
.get("bmux.session_id")
.is_some_and(|v| v == &sid.to_string())
})
.map(|ctx| ctx.id)
.ok_or_else(|| anyhow::anyhow!("follow target has no selected context"))?
} else {
anyhow::bail!("follow target has no selected context");
};
let grant = client
.attach_context_grant(ContextSelector::ById(context_id))
.await
.map_err(map_attach_client_error)?;
client
.open_attach_stream_info(&grant)
.await
.map_err(map_attach_client_error)?
} else {
let target = target.expect("target is present when not follow");
let grant = client
.attach_grant(parse_session_selector(target))
.await
.map_err(map_attach_client_error)?;
client
.open_attach_stream_info(&grant)
.await
.map_err(map_attach_client_error)?
};
let capture_targets = match recording_state::client::capture_targets(&mut client).await {
Ok(targets) => targets.into_iter().map(Into::into).collect(),
Err(error) => {
tracing::warn!("failed querying recording capture targets on attach: {error}");
Vec::new()
}
};
let mut client =
bmux_client::StreamingBmuxClient::from_client(client).map_err(map_attach_client_error)?;
client
.subscribe_events()
.await
.map_err(map_attach_client_error)?;
client
.enable_event_push()
.await
.map_err(map_attach_client_error)?;
match typed_active_runtime_appearance_attach(&mut client).await {
Ok(appearance) => {
tracing::info!(
background = %appearance.background,
foreground = %appearance.foreground,
border_active = %appearance.border.active,
status_background = %appearance.status.background,
"attach seeded runtime appearance from theme plugin",
);
runtime_appearance = appearance.clone();
let _ = presentation_events.publish_state(&RUNTIME_APPEARANCE_STATE_KIND, appearance);
}
Err(error) => {
tracing::debug!(%error, "theme plugin active-appearance query unavailable on attach startup");
let _ = presentation_events
.publish_state(&RUNTIME_APPEARANCE_STATE_KIND, runtime_appearance.clone());
}
}
if let Ok(cwd) = std::env::current_dir()
&& let Some(cwd_text) = cwd.to_str()
{
match typed_active_runtime_appearance_for_cwd_attach(&mut client, cwd_text).await {
Ok(appearance) => {
runtime_appearance = appearance.clone();
let _ =
presentation_events.publish_state(&RUNTIME_APPEARANCE_STATE_KIND, appearance);
}
Err(error) => {
tracing::debug!(%error, cwd = %cwd.display(), "cwd-scoped runtime appearance unavailable on attach startup");
}
}
}
let mut display_capture = DisplayCaptureFanout::default();
for target in &capture_targets {
display_capture.open_target(target, self_client_id);
}
let mut view_state = AttachViewState::with_private_presentation(attach_info);
view_state.presentation_only = terminal.presentation_only();
view_state.viewport_guard = terminal.viewport_guard();
view_state.allow_retarget = terminal
.render_provider()
.is_none_or(|provider| provider.allows_retarget());
if let Some(publisher) = observer {
view_state.set_observer(publisher);
}
view_state.presentation_events = presentation_events;
install_bundled_render_extensions(&mut view_state);
terminal.set_attached_session_id(view_state.attached_id);
view_state.self_client_id = Some(self_client_id);
view_state.mouse.config = attach_config.attach_mouse_config();
view_state.bracketed_paste_enabled =
bracketed_paste_enabled(attach_config.behavior.bracketed_paste);
let _ = view_state
.presentation_events
.register_state_channel::<AttachLayoutSnapshot>(
ATTACH_LAYOUT_STATE_KIND,
AttachLayoutSnapshot {
surfaces: Vec::new(),
revision: 0,
},
);
let _ = view_state
.presentation_events
.register_state_channel::<AttachLocalPresentationSnapshot>(
ATTACH_LOCAL_PRESENTATION_STATE_KIND,
AttachLocalPresentationSnapshot::initial(),
);
let _ = view_state
.presentation_events
.register_state_channel::<bmux_tabs_plugin_api::tabs_list::TabListSnapshot>(
bmux_tabs_plugin_api::tabs_list::STATE_KIND,
bmux_tabs_plugin_api::tabs_list::TabListSnapshot {
tabs: Vec::new(),
revision: 0,
},
);
let _ = view_state.presentation_events.register_state_channel(
bmux_tabs_plugin_api::tabs_local_view::STATE_KIND,
bmux_tabs_plugin_api::tabs_local_view::TabSelection {
context_id: view_state.attached_context_id,
},
);
let presentation_resources = bmux_plugin::AttachPresentationResources {
layouts: view_state.presentation_layouts.clone(),
allocations: view_state.presentation_allocations.clone(),
surfaces: view_state.presentation_surfaces.clone(),
input: view_state.presentation_input.clone(),
events: view_state.presentation_events.clone(),
};
let mut started_companions = Vec::new();
for companion in bmux_plugin::registered_attach_companions() {
let id = companion.id().to_owned();
match companion.start_with_resources(&presentation_resources) {
Ok(started) => started_companions.push(started),
Err(error) => {
tracing::warn!(companion_id = id, %error, "failed starting attach companion");
}
}
}
let ready_tracker = super::super::plugin_runtime::ready_tracker_snapshot();
for gate in bmux_plugin::registered_startup_ready_gates() {
if ready_tracker
.status(&gate.plugin_id, &gate.signal)
.is_some()
{
let _ = ready_tracker.await_ready(&gate.plugin_id, &gate.signal, gate.timeout);
}
}
let mut layout_revision_rx = view_state.presentation_layouts.subscribe();
let mut surface_revision_rx = view_state.presentation_surfaces.subscribe();
refresh_observed_geometry(terminal, &mut client, view_state.attached_id).await?;
view_state.driver_geometry = Some(terminal.geometry());
update_attach_viewport_with_geometry(&mut client, &view_state, terminal.geometry()).await?;
hydrate_attach_state_from_snapshot(&mut client, &mut view_state).await?;
maybe_sync_remote_clipboard(
&mut client,
&mut view_state,
ClipboardSyncTrigger::Connect,
Instant::now(),
)
.await;
let _ = notify_extensions_of_layout(
&mut client,
&view_state.presentation_events,
None,
view_state.cached_layout_state.as_ref(),
)
.await;
refresh_attach_status_catalog_best_effort(&mut client, &mut view_state).await;
sync_attach_active_mode_from_processor(&mut view_state, &attach_keymap, None);
let keyboard_enhanced = terminal.enter_attach_mode(
attach_config.behavior.kitty_keyboard,
attach_config.attach_mouse_config().enabled,
view_state.bracketed_paste_enabled,
)?;
let mut attach_input_processor = InputProcessor::new(attach_keymap.clone(), keyboard_enhanced);
let async_service_slots = std::sync::Arc::new(tokio::sync::Semaphore::new(16));
let (async_service_tx, mut async_service_rx) = tokio::sync::mpsc::channel(16);
view_state.async_services = Some(
bmux_plugin::AsyncServiceClient::bind(
bmux_plugin::ASYNC_SERVICE_ROUTE_V1,
async_service_tx,
)
.map_err(anyhow::Error::msg)?,
);
let (prompt_host_tx, mut prompt_host_rx) = tokio::sync::mpsc::unbounded_channel();
let _prompt_host_guard = prompt::register_host(prompt_host_tx);
let mut prompt_host_open = true;
let (action_dispatch_tx, mut action_dispatch_rx) = tokio::sync::mpsc::unbounded_channel();
let _action_dispatch_guard = action_dispatch::register_host(action_dispatch_tx);
let mut action_dispatch_open = true;
#[allow(unused_assignments)]
let mut exit_reason = AttachExitReason::Detached;
#[cfg(any(
feature = "image-sixel",
feature = "image-kitty",
feature = "image-iterm2"
))]
{
view_state.host_image_caps = terminal.detect_image_capabilities();
tracing::info!(
target: "attach.image",
kitty_graphics = view_state.host_image_caps.kitty_graphics,
sixel = view_state.host_image_caps.sixel,
iterm2_inline = view_state.host_image_caps.iterm2_inline,
cell_pixel_width = view_state.host_image_caps.cell_pixel_width,
cell_pixel_height = view_state.host_image_caps.cell_pixel_height,
"detected host image capabilities"
);
let img_cfg = attach_config.behavior.images.decode_mode;
view_state.image_decode_mode = match img_cfg {
bmux_config::ImageDecodeMode::Server => bmux_image::config::ImageDecodeMode::Server,
bmux_config::ImageDecodeMode::Client => bmux_image::config::ImageDecodeMode::Client,
bmux_config::ImageDecodeMode::Passthrough => {
bmux_image::config::ImageDecodeMode::Passthrough
}
};
}
let mut pane_output_pending = false;
#[cfg(any(
feature = "image-sixel",
feature = "image-kitty",
feature = "image-iterm2"
))]
let mut image_fetch_pending = view_state.presentation_only || view_state.observer.is_some();
let (mut last_scene_revision, mut scene_event_rx) =
match view_state
.presentation_events
.subscribe_state::<bmux_scene_protocol::scene_protocol::DecorationScene>(
&bmux_scene_protocol::scene_protocol::STATE_KIND,
) {
Ok((initial, rx)) => (initial.revision, Some(rx)),
Err(_) => (0, None),
};
let (mut last_window_list_revision, mut window_list_rx) =
match view_state
.presentation_events
.subscribe_state::<bmux_tabs_plugin_api::tabs_list::TabListSnapshot>(
&bmux_tabs_plugin_api::tabs_list::STATE_KIND,
) {
Ok((initial, rx)) => {
let revision = initial.revision;
view_state.cached_tab_list = Some(initial);
(revision, Some(rx))
}
Err(_) => (0, None),
};
if let Ok(entries) = typed_list_windows_attach(&mut client).await {
let snapshot = authoritative_window_list_snapshot(entries, 0);
last_window_list_revision = snapshot.revision;
view_state.cached_tab_list = Some(std::sync::Arc::new(snapshot.clone()));
let _ = view_state
.presentation_events
.publish_state(&bmux_tabs_plugin_api::tabs_list::STATE_KIND, snapshot);
}
loop {
terminal.set_attached_session_id(view_state.attached_id);
#[cfg(test)]
terminal.observe_scrollback(view_state.scrollback_active());
tokio::select! {
event = client.event_receiver().recv() => {
let Some(server_event) = event else {
exit_reason = AttachExitReason::StreamClosed;
break;
};
perf_window.record_wake(AttachWakeSource::Server);
let handling = handle_attach_stream_server_event(
server_event,
&mut client,
&mut attach_input_processor,
follow_target_id,
self_client_id,
global,
&attach_help_lines,
&mut view_state,
&mut display_capture,
kernel_client_factory.as_ref(),
&attach_config,
&mut perf_emitter,
&mut pane_output_pending,
&mut last_scene_revision,
)
.await?;
if handling.image_fetch_requested {
#[cfg(any(
feature = "image-sixel",
feature = "image-kitty",
feature = "image-iterm2"
))]
{
image_fetch_pending = true;
}
}
match handling.control {
AttachLoopControl::Continue => {}
AttachLoopControl::Break(reason) => {
exit_reason = reason;
break;
}
}
}
terminal_result = terminal.next_event() => {
let Some(result) = terminal_result? else {
exit_reason = if terminal.presentation_only() {
AttachExitReason::Detached
} else {
AttachExitReason::StreamClosed
};
break;
};
let terminal_event = AttachTerminalEvent::from_crossterm(
result,
SystemAttachClock.now(),
terminal.geometry(),
);
perf_window.record_wake(AttachWakeSource::Terminal);
if let Some(TerminalInputEvent::Resize { cols, rows }) = &terminal_event.normalized {
display_capture.record_resize(*cols, *rows);
}
let control = if let Some(action) = terminal.take_owned_action() {
handle_attach_action_dispatch(
&mut client,
bmux_plugin_sdk::ActionDispatchRequest::new(action),
&mut view_state,
kernel_client_factory.as_ref(),
true,
).await?
} else {
handle_attach_loop_event(
AttachLoopEvent::Terminal(terminal_event),
&mut client,
&mut attach_input_processor,
follow_target_id,
Some(self_client_id),
global,
&attach_help_lines,
&mut view_state,
&mut display_capture,
kernel_client_factory.as_ref(),
)
.await?
};
match control {
AttachLoopControl::Continue => {}
AttachLoopControl::Break(reason) => {
exit_reason = reason;
break;
}
}
}
history = async {
match view_state.scrollback_fetch.as_mut() {
Some(fetch) => (&mut fetch.task).await,
None => std::future::pending().await,
}
} => {
let fetch = view_state.scrollback_fetch.take().expect("active history fetch");
let request = fetch.request;
let current = view_state.scrollback_for(request.pane);
let valid = !fetch.prefetch && view_state.scroll_boundary.get(&request.pane).copied() == request.boundary && view_state.attached_id == request.session
&& current.is_some_and(|view| view.pin == request.pin && view_state.requested_scroll_offset(request.pane) == Some(request.offset))
&& attach_pane_inner_size(&view_state, request.pane) == Some((request.width, request.rows));
let succeeded = matches!(&history, Ok(super::scrollback_fetch::Outcome::Ready(_)));
if valid {
match history {
Ok(super::scrollback_fetch::Outcome::Ready(window)) => {
if let Some(total) = request.total {
view_state.scrollback_cache.advance(request.pane, total, window.total_scrolled_rows);
}
view_state.history_prefetch = request.warm_range(&window).into();
publish_scrollback_window(&mut view_state, request.pane, window);
view_state.dirty.mark_pane_dirty(request.pane, AttachDirtySource::PaneOutput);
}
result => {
let error = match result {
Ok(super::scrollback_fetch::Outcome::Failed(error)) => error,
Ok(super::scrollback_fetch::Outcome::Unavailable) => "capture viewport unavailable; previous view retained".into(),
Err(error) => error.to_string(),
Ok(super::scrollback_fetch::Outcome::Ready(_)) => unreachable!(),
};
view_state.scroll_started.remove(&request.pane);
view_state.pending_scroll.remove(&request.pane);
view_state.scroll_boundary.remove(&request.pane);
view_state.set_transient_status(format!("history fetch failed: {error}"), Instant::now(), ATTACH_TRANSIENT_STATUS_TTL);
}
}
} else if request.reusable_capture(
view_state.attached_id,
current.and_then(|view| view.pin),
attach_pane_inner_size(&view_state, request.pane),
) && let Ok(super::scrollback_fetch::Outcome::Ready(window)) = history {
view_state.scrollback_cache.insert(request.pane, request.pin, &window);
}
if !valid || succeeded {
ensure_pane_scrollback_windows(&mut client, &mut view_state, false)?;
}
}
Some((permit, mut request)) = async {
let permit = async_service_slots.clone().acquire_owned().await.expect("service semaphore open");
async_service_rx.recv().await.map(|request| (permit, request))
} => {
if !request.is_cancelled() {
let response = client.start_request(bmux_ipc::Request::InvokeService {
capability: request.capability.clone(), kind: request.kind,
interface_id: request.interface_id.clone(), operation: request.operation.clone(),
payload: std::mem::take(&mut request.payload),
}).await;
tokio::spawn(async move {
let _permit = permit;
let result = match response {
Ok(response) => response.await,
Err(error) => Err(error),
}.map_err(|error| error.to_string()).and_then(|response| {
match response {
bmux_ipc::Response::Ok(bmux_ipc::ResponsePayload::ServiceInvoked { payload }) => Ok(payload),
other => Err(format!("unexpected async service response: {other:?}")),
}
});
let _ = request.respond(result);
});
}
}
prompt_request = async {
if prompt_host_open {
prompt_host_rx.recv().await
} else {
std::future::pending().await
}
} => {
if let Some(prompt_request) = prompt_request {
perf_window.record_wake(AttachWakeSource::Prompt);
view_state.mouse.clear_pointer_gestures();
view_state.prompt.enqueue_external(prompt_request);
view_state
.dirty
.mark_overlay_dirty(AttachDirtySource::PromptOverlay);
} else {
prompt_host_open = false;
}
}
dispatch_request = async {
if action_dispatch_open {
action_dispatch_rx.recv().await
} else {
std::future::pending().await
}
} => {
if let Some(dispatch_request) = dispatch_request {
perf_window.record_wake(AttachWakeSource::ActionDispatch);
match handle_attach_loop_event(
AttachLoopEvent::ActionDispatch(dispatch_request),
&mut client,
&mut attach_input_processor,
follow_target_id,
Some(self_client_id),
global,
&attach_help_lines,
&mut view_state,
&mut display_capture,
kernel_client_factory.as_ref(),
)
.await?
{
AttachLoopControl::Continue => {}
AttachLoopControl::Break(reason) => {
exit_reason = reason;
break;
}
}
} else {
action_dispatch_open = false;
}
}
appearance_result = async {
match &mut appearance_rx {
Some(rx) => rx.changed().await.ok().map(|()| rx.borrow().clone()),
None => std::future::pending().await,
}
} => {
if let Some(appearance) = appearance_result {
perf_window.record_wake(AttachWakeSource::Appearance);
if runtime_appearance != *appearance {
runtime_appearance = (*appearance).clone();
view_state
.dirty
.mark_full_frame(AttachDirtySource::AppearanceChanged);
view_state
.dirty
.mark_overlay_dirty(AttachDirtySource::AppearanceChanged);
}
} else {
appearance_rx = None;
}
},
scene_result = async {
match &mut scene_event_rx {
Some(rx) => rx.changed().await.ok().map(|()| rx.borrow().clone()),
None => std::future::pending().await,
}
} => {
if let Some(scene) = scene_result {
perf_window.record_wake(AttachWakeSource::Scene);
if scene.revision != last_scene_revision {
last_scene_revision = scene.revision;
view_state
.dirty
.mark_extension_dirty(AttachDirtySource::SceneChanged);
}
} else {
scene_event_rx = None;
}
}
surface_revision_result = surface_revision_rx.changed() => {
if surface_revision_result.is_err() {
tracing::warn!("plugin surface revision channel closed");
continue;
}
view_state
.dirty
.mark_retained_surfaces_dirty(AttachDirtySource::SceneChanged);
}
layout_revision_result = layout_revision_rx.changed() => {
if layout_revision_result.is_err() {
tracing::warn!("plugin layout revision channel closed");
continue;
}
update_attach_viewport_with_geometry(
&mut client,
&view_state,
terminal.geometry(),
)
.await?;
view_state
.dirty
.mark_layout_frame_dirty(AttachDirtySource::SceneChanged);
}
window_list_result = async {
match &mut window_list_rx {
Some(rx) => rx.changed().await.ok().map(|()| rx.borrow().clone()),
None => std::future::pending().await,
}
} => {
if let Some(snapshot) = window_list_result {
perf_window.record_wake(AttachWakeSource::TabList);
if snapshot.revision != last_window_list_revision {
last_window_list_revision = snapshot.revision;
view_state.cached_tab_list = Some(snapshot);
}
} else {
window_list_rx = None;
}
}
}
let mut burst_requested_exit = false;
let mut burst_drained_events = 0_usize;
for _ in 0..attach_config.behavior.event_coalescing.max_events_per_frame {
let server_event = match client.event_receiver().try_recv() {
Ok(server_event) => server_event,
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
exit_reason = AttachExitReason::StreamClosed;
burst_requested_exit = true;
break;
}
};
burst_drained_events = burst_drained_events.saturating_add(1);
perf_window.record_wake(AttachWakeSource::Server);
let handling = handle_attach_stream_server_event(
server_event,
&mut client,
&mut attach_input_processor,
follow_target_id,
self_client_id,
global,
&attach_help_lines,
&mut view_state,
&mut display_capture,
kernel_client_factory.as_ref(),
&attach_config,
&mut perf_emitter,
&mut pane_output_pending,
&mut last_scene_revision,
)
.await?;
if handling.image_fetch_requested {
#[cfg(any(
feature = "image-sixel",
feature = "image-kitty",
feature = "image-iterm2"
))]
{
image_fetch_pending = true;
}
}
match handling.control {
AttachLoopControl::Continue => {}
AttachLoopControl::Break(reason) => {
exit_reason = reason;
burst_requested_exit = true;
break;
}
}
}
perf_window.record_event_burst(burst_drained_events);
if burst_requested_exit {
break;
}
let _ = view_state.clear_expired_transient_status(Instant::now());
let geometry = terminal.geometry();
view_state.driver_geometry = Some(geometry);
let selection = bmux_tabs_plugin_api::tabs_local_view::TabSelection {
context_id: view_state.attached_context_id,
};
let bus = &view_state.presentation_events;
if bus
.subscribe_state::<bmux_tabs_plugin_api::tabs_local_view::TabSelection>(
&bmux_tabs_plugin_api::tabs_local_view::STATE_KIND,
)
.is_ok_and(|(current, _)| *current != selection)
{
let _ = bus.publish_state(
&bmux_tabs_plugin_api::tabs_local_view::STATE_KIND,
selection,
);
}
publish_attach_local_presentation(
&mut view_state,
&attach_keymap,
follow_target_id,
global,
geometry,
&runtime_appearance,
);
super::local_presentation::publish_notification(
&view_state.presentation_layouts,
&view_state.presentation_surfaces,
view_state.transient_status.as_deref(),
geometry.cols,
geometry.rows,
);
let scrollback_config = &attach_config.behavior.scrollback;
let now = Instant::now();
for (pane_id, pin) in expired_scrollback_pins(
&view_state,
epoch_secs_now(),
scrollback_config.auto_unfreeze_after,
) {
unpin_scrollback(&mut client, view_state.attached_id, pane_id, pin).await;
view_state.scrollback_replay_pending = true;
view_state.exit_scrollback_for(pane_id);
view_state.set_transient_status(
"frozen scrollback expired; resumed live output",
now,
ATTACH_TRANSIENT_STATUS_TTL,
);
}
let dirty_layout_frame =
view_state.dirty.layout_needs_refresh || view_state.cached_layout_state.is_none();
let mut frame_needs_render = view_state.dirty.needs_render();
let mut scene_hydrated = false;
if dirty_layout_frame {
if refresh_observed_geometry(terminal, &mut client, view_state.attached_id).await? {
view_state.driver_geometry = Some(terminal.geometry());
view_state
.dirty
.mark_full_frame(AttachDirtySource::LayoutChanged);
hydrate_attach_state_from_snapshot_mode(
&mut client,
&mut view_state,
SnapshotHydrationMode::FullResync,
)
.await?;
scene_hydrated = true;
frame_needs_render = true;
}
let previous_layout = view_state.cached_layout_state.clone();
let layout_state = match client.attach_layout(view_state.attached_id).await {
Ok(state) => state,
Err(error)
if is_attach_stream_closed_error(&error)
|| is_attach_not_attached_runtime_error(&error) =>
{
exit_reason = AttachExitReason::StreamClosed;
break;
}
Err(error) => return Err(map_attach_client_error(error)),
};
if view_state.cached_layout_state.as_ref() != Some(&layout_state) {
frame_needs_render = true;
match previous_layout.as_ref() {
None => {
view_state
.dirty
.mark_full_frame(AttachDirtySource::LayoutChanged);
}
Some(previous) => {
if previous.scene != layout_state.scene {
let revealed_pane_ids = attach_scene_revealed_pane_ids(
&previous.scene,
&layout_state.scene,
);
if attach_config.behavior.pane_restore_method
== PaneRestoreMethod::Snapshot
{
if attach_layout_requires_snapshot_hydration(
previous,
&layout_state,
) {
hydrate_attach_state_from_snapshot(
&mut client,
&mut view_state,
)
.await?;
scene_hydrated = true;
} else if !revealed_pane_ids.is_empty() {
let revealed =
revealed_pane_ids.into_iter().collect::<Vec<_>>();
if hydrate_attach_revealed_panes_from_snapshot(
&mut client,
&mut view_state,
&layout_state,
&revealed,
)
.await
.is_err()
{
hydrate_attach_state_from_snapshot(
&mut client,
&mut view_state,
)
.await?;
scene_hydrated = true;
}
}
}
if !scene_hydrated {
let damage_policy = DamageCoalescingPolicy {
max_rects: attach_config.behavior.damage.max_rects,
max_area_percent: attach_config
.behavior
.damage
.max_area_percent,
};
let scene_damage = attach_scene_damage_between(
&previous.scene,
&layout_state.scene,
damage_policy,
);
if scene_damage.is_empty() {
view_state
.dirty
.mark_extension_dirty(AttachDirtySource::LayoutChanged);
} else {
view_state.dirty.merge_precise_damage(
&scene_damage,
AttachDirtySource::LayoutChanged,
);
}
}
} else if previous.focused_pane_id != layout_state.focused_pane_id {
mark_pane_surface_dirty(
&mut view_state.dirty,
&layout_state,
previous.focused_pane_id,
AttachDirtySource::FocusChanged,
);
mark_pane_surface_dirty(
&mut view_state.dirty,
&layout_state,
layout_state.focused_pane_id,
AttachDirtySource::FocusChanged,
);
}
}
}
if !scene_hydrated {
view_state.mouse.last_focused_pane_id = Some(layout_state.focused_pane_id);
view_state.cached_layout_state = Some(layout_state.clone());
}
}
let current_pane_ids = notify_extensions_of_layout(
&mut client,
&view_state.presentation_events,
previous_layout.as_ref(),
view_state.cached_layout_state.as_ref(),
)
.await;
drop(current_pane_ids);
view_state.dirty.layout_needs_refresh = false;
attach_input_processor.set_scroll_mode(view_state.scrollback_active());
if let Some(layout_state) = view_state.cached_layout_state.as_ref() {
let active_pane_ids = layout_state
.panes
.iter()
.map(|pane| pane.id)
.collect::<std::collections::BTreeSet<_>>();
view_state.retain_scrollback_panes(&active_pane_ids);
}
#[cfg(any(
feature = "image-sixel",
feature = "image-kitty",
feature = "image-iterm2"
))]
{
view_state.image_sequences.clear();
view_state.pane_images.clear();
image_fetch_pending = true;
}
}
let Some(layout_state) = view_state.cached_layout_state.clone() else {
continue;
};
if scene_hydrated {
let dirty_reasons =
AttachDirtyReasons::from_view_state(&view_state, dirty_layout_frame, true);
perf_window.record_dirty_reasons(dirty_reasons);
let dirty_events = view_state.dirty.dirty_events().to_vec();
let help_scroll = view_state.help_overlay_scroll;
let render_started_at = SystemAttachClock.now();
let render_geometry = terminal.geometry();
let frame_stats = render_attach_frame_to_writer(
terminal,
&mut view_state,
&layout_state,
&runtime_appearance,
&attach_help_lines,
help_scroll,
&attach_config.behavior.damage,
attach_config.logs.client.slow_terminal_write_ms,
&mut display_capture,
render_geometry,
None,
)?;
flush_visual_projection_updates(&mut client, &mut view_state).await;
let render_ms = duration_millis_u64(render_started_at.elapsed());
if render_ms >= attach_config.logs.client.slow_frame_ms {
tracing::warn!(
frame_index = rendered_frame_count.saturating_add(1),
render_ms,
threshold_ms = attach_config.logs.client.slow_frame_ms,
"attach.render.slow_frame"
);
}
perf_window.record_render_frame(render_ms, &frame_stats);
rendered_frame_count = rendered_frame_count.saturating_add(1);
maybe_emit_attach_frame_perf(
&mut perf_emitter,
&mut client,
view_state.attached_id,
attach_started_at,
rendered_frame_count,
render_ms,
true,
dirty_reasons,
&dirty_events,
&frame_stats,
&mut first_frame_emitted,
&mut interactive_ready_emitted,
)
.await?;
maybe_emit_attach_perf_window(
&mut perf_emitter,
&mut client,
view_state.attached_id,
&mut perf_window,
)
.await?;
pane_output_pending = false;
continue;
}
let geometry = terminal.geometry();
resize_attach_grids_for_scene_with_size(
&mut view_state.pane_buffers,
&layout_state.scene,
geometry.cols,
geometry.rows,
);
if pane_output_pending || view_state.scrollback_replay_pending {
view_state.scrollback_replay_pending = false;
let pane_ids = output_batch_pane_ids(&layout_state.scene, &view_state);
let active_pane_ids = attach_layout_pane_id_set(&layout_state);
view_state
.pane_buffers
.retain(|pane_id, _| active_pane_ids.contains(pane_id));
view_state
.pane_mouse_protocol_hints
.retain(|pane_id, _| active_pane_ids.contains(pane_id));
#[cfg(any(
feature = "image-sixel",
feature = "image-kitty",
feature = "image-iterm2"
))]
view_state
.pane_images
.retain(|pane_id, _| active_pane_ids.contains(pane_id));
let mut last_round_had_data = false;
let mut drained_any_data = false;
let mut recovered_output_desync = false;
let drain_start = Instant::now();
for _round in 0..ATTACH_OUTPUT_DRAIN_MAX_ROUNDS {
perf_window.record_drain_round();
let drain_call_started_at = Instant::now();
let result = match client
.attach_pane_output_batch(
view_state.attached_id,
pane_ids.clone(),
ATTACH_OUTPUT_BATCH_MAX_BYTES,
)
.await
{
Ok(result) => result,
Err(error)
if is_attach_stream_closed_error(&error)
|| is_attach_not_attached_runtime_error(&error) =>
{
#[allow(unused_assignments)] {
exit_reason = AttachExitReason::StreamClosed;
}
last_round_had_data = false;
break;
}
Err(error) => return Err(map_attach_client_error(error)),
};
let batch_bytes: usize = result.chunks.iter().map(|chunk| chunk.data.len()).sum();
perf_window.record_drain_ipc(
duration_millis_u64(drain_call_started_at.elapsed()),
batch_bytes,
);
let mut had_data = false;
let mut any_sync_active = false;
let mut desynced_pane_id = None;
for chunk in result.chunks {
let pane_id = chunk.pane_id;
let sync_update_active = chunk.sync_update_active;
match apply_attach_output_chunk(
&mut view_state,
pane_id,
&chunk.data,
AttachOutputChunkMeta {
stream_start: chunk.stream_start,
stream_end: chunk.stream_end,
stream_gap: chunk.stream_gap,
sync_update_active,
},
&mut frame_needs_render,
) {
AttachChunkApplyOutcome::Applied {
had_data: chunk_had_data,
} => {
had_data |= chunk_had_data;
drained_any_data |= chunk_had_data;
any_sync_active |= sync_update_active;
}
AttachChunkApplyOutcome::Stale => {}
AttachChunkApplyOutcome::Desync => {
desynced_pane_id = Some(pane_id);
break;
}
}
}
if let Some(desynced_pane_id) = desynced_pane_id {
recover_attach_output_desync_for_pane(
&mut client,
&mut view_state,
desynced_pane_id,
)
.await?;
recovered_output_desync = true;
last_round_had_data = false;
break;
}
perf_window.record_drain_result(had_data, any_sync_active);
last_round_had_data = had_data;
if !had_data {
if !result.output_still_pending && !any_sync_active {
break; }
if drain_start.elapsed() >= ATTACH_OUTPUT_DRAIN_TIME_BUDGET {
perf_window.record_drain_budget_hit();
break; }
}
}
if !recovered_output_desync && !drained_any_data {
let structured_delta_applied = apply_attach_structured_grid_deltas(
&mut client,
&mut view_state,
pane_ids.clone(),
)
.await?;
frame_needs_render |= structured_delta_applied;
}
let any_sync_still_active = view_state
.pane_buffers
.values()
.any(|b| b.sync_update_in_progress);
pane_output_pending = last_round_had_data || any_sync_still_active;
if drained_any_data
&& !view_state.pane_scrollback.is_empty()
&& let Err(error) =
ensure_pane_scrollback_windows(&mut client, &mut view_state, true)
{
view_state.set_transient_status(
format!(
"scrollback window fetch failed: {}",
map_attach_client_error(error)
),
SystemAttachClock.now(),
ATTACH_TRANSIENT_STATUS_TTL,
);
}
}
#[cfg(any(
feature = "image-sixel",
feature = "image-kitty",
feature = "image-iterm2"
))]
if image_fetch_pending
&& (view_state.presentation_only
|| view_state.observer.is_some()
|| view_state.host_image_caps.any_supported())
{
image_fetch_pending = false;
let dirty_panes: Vec<Uuid> = if view_state.dirty.pane_dirty_ids.is_empty() {
view_state.pane_buffers.keys().copied().collect()
} else {
view_state.dirty.pane_dirty_ids.iter().copied().collect()
};
let sequences: Vec<u64> = dirty_panes
.iter()
.map(|id| view_state.image_sequences.get(id).copied().unwrap_or(0))
.collect();
if let Ok(deltas) = client
.attach_pane_images(view_state.attached_id, dirty_panes, sequences)
.await
{
for delta in deltas {
if !delta.added.is_empty() || !delta.removed.is_empty() {
view_state
.image_sequences
.insert(delta.pane_id, delta.sequence);
let images = view_state.pane_images.entry(delta.pane_id).or_default();
if !delta.removed.is_empty() {
images.retain(|img| !delta.removed.contains(&img.id));
}
images.extend(delta.added);
frame_needs_render = true;
}
}
}
}
if !frame_needs_render {
maybe_emit_attach_perf_window(
&mut perf_emitter,
&mut client,
view_state.attached_id,
&mut perf_window,
)
.await?;
continue;
}
let dirty_reasons =
AttachDirtyReasons::from_view_state(&view_state, dirty_layout_frame, false);
perf_window.record_dirty_reasons(dirty_reasons);
let dirty_events = view_state.dirty.dirty_events().to_vec();
let help_scroll = view_state.help_overlay_scroll;
let render_started_at = SystemAttachClock.now();
let render_geometry = terminal.geometry();
let frame_stats = render_attach_frame_to_writer(
terminal,
&mut view_state,
&layout_state,
&runtime_appearance,
&attach_help_lines,
help_scroll,
&attach_config.behavior.damage,
attach_config.logs.client.slow_terminal_write_ms,
&mut display_capture,
render_geometry,
None,
)?;
flush_visual_projection_updates(&mut client, &mut view_state).await;
let render_ms = duration_millis_u64(render_started_at.elapsed());
if render_ms >= attach_config.logs.client.slow_frame_ms {
tracing::warn!(
frame_index = rendered_frame_count.saturating_add(1),
render_ms,
threshold_ms = attach_config.logs.client.slow_frame_ms,
"attach.render.slow_frame"
);
}
perf_window.record_render_frame(render_ms, &frame_stats);
rendered_frame_count = rendered_frame_count.saturating_add(1);
maybe_emit_attach_frame_perf(
&mut perf_emitter,
&mut client,
view_state.attached_id,
attach_started_at,
rendered_frame_count,
render_ms,
false,
dirty_reasons,
&dirty_events,
&frame_stats,
&mut first_frame_emitted,
&mut interactive_ready_emitted,
)
.await?;
maybe_emit_attach_perf_window(
&mut perf_emitter,
&mut client,
view_state.attached_id,
&mut perf_window,
)
.await?;
}
if perf_emitter.level_at_least(perf_telemetry::PerfCaptureLevel::Basic) {
let mut payload = serde_json::json!({
"attach_runtime_ms": duration_millis_u64(attach_started_at.elapsed()),
"exit_reason": attach_exit_reason_label(exit_reason),
"rendered_frames": rendered_frame_count,
"first_frame_recorded": first_frame_emitted,
"interactive_ready_recorded": interactive_ready_emitted,
});
if perf_emitter.level_at_least(perf_telemetry::PerfCaptureLevel::Trace)
&& let Some(object) = payload.as_object_mut()
{
object.insert(
"pending_output_on_exit".to_string(),
serde_json::Value::from(pane_output_pending),
);
}
perf_emitter
.emit_with_streaming_client(
&mut client,
Some(view_state.attached_id),
None,
"attach.exit",
payload,
)
.await?;
}
tracing::info!(
exit_reason = attach_exit_reason_label(exit_reason),
attach_runtime_ms = duration_millis_u64(attach_started_at.elapsed()),
rendered_frames = rendered_frame_count,
"attach.runtime.end"
);
#[cfg(any(
feature = "image-sixel",
feature = "image-kitty",
feature = "image-iterm2"
))]
if !terminal.presentation_only() {
let mut candidate = view_state.kitty_host_state.clone();
let cleanup = candidate
.reset(&mut &mut *terminal)
.and_then(|()| terminal.flush());
if let Err(error) = cleanup {
view_state
.kitty_host_state
.invalidate_after_failed_output(&candidate);
terminal.restore_after_attach_ui()?;
return Err(error.into());
}
view_state.kitty_host_state = candidate;
}
terminal.restore_after_attach_ui()?;
drop(started_companions);
super::local_presentation::uninstall(&view_state.presentation_surfaces);
view_state.presentation_surfaces.clear();
view_state.presentation_layouts.clear();
if exit_reason != AttachExitReason::Detached {
let _ = client.detach().await;
}
if follow_target_id.is_some() {
let _ = bmux_clients_plugin_api::clients_commands::client::set_following(
&mut client,
None,
false,
)
.await;
}
if let Some(message) = attach_exit_message(exit_reason) {
writeln!(terminal, "{message}")?;
}
display_capture.close_all();
Ok(AttachRunOutcome {
status_code: 0,
exit_reason,
resume: None,
})
}
fn handle_device_seal_broker_request(payload: &[u8]) -> (bool, Vec<u8>, Option<String>) {
let result = sshenv_vault::device::execute_device_seal_broker_payload(payload);
(result.ok, result.response_payload, result.error)
}
#[cfg(not(target_os = "macos"))]
#[allow(dead_code)] fn store_device_seal_keychain_secret(
_service: &str,
_account: &str,
_secret_hex: &str,
) -> Result<()> {
anyhow::bail!("macOS Keychain device seal broker is only supported on macOS")
}
struct AttachServerEventHandling {
control: AttachLoopControl,
image_fetch_requested: bool,
}
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
async fn handle_attach_stream_server_event(
server_event: bmux_client::ServerEvent,
client: &mut StreamingBmuxClient,
attach_input_processor: &mut InputProcessor,
follow_target_id: Option<Uuid>,
self_client_id: Uuid,
global: bool,
attach_help_lines: &[String],
view_state: &mut AttachViewState,
display_capture: &mut DisplayCaptureFanout,
kernel_client_factory: Option<&KernelClientFactory>,
attach_config: &BmuxConfig,
perf_emitter: &mut perf_telemetry::PerfEventEmitter,
pane_output_pending: &mut bool,
last_scene_revision: &mut u64,
) -> Result<AttachServerEventHandling> {
let mut image_fetch_requested = false;
if let bmux_client::ServerEvent::DeviceSealBrokerRequest {
request_id,
target_client_id,
payload,
} = server_event
{
if target_client_id == self_client_id {
tracing::info!(
%request_id,
payload_len = payload.len(),
"device_seal_broker.request_received"
);
let (ok, response_payload, error) = handle_device_seal_broker_request(&payload);
tracing::info!(
%request_id,
ok,
response_payload_len = response_payload.len(),
has_error = error.is_some(),
"device_seal_broker.responding"
);
client
.respond_device_seal_broker(request_id, ok, response_payload, error)
.await?;
} else {
tracing::debug!(
%request_id,
%target_client_id,
%self_client_id,
"device_seal_broker.request_ignored_for_other_client"
);
}
return Ok(AttachServerEventHandling {
control: AttachLoopControl::Continue,
image_fetch_requested,
});
}
if let bmux_client::ServerEvent::PluginBusEvent {
ref kind,
ref payload,
} = server_event
{
if kind.as_str() == bmux_performance_plugin_api::EVENT_KIND.as_str() {
match serde_json::from_slice::<bmux_performance_plugin_api::PerformanceEvent>(payload) {
Ok(bmux_performance_plugin_api::PerformanceEvent::SettingsUpdated { settings }) => {
perf_emitter.update_settings(
perf_telemetry::PerfCaptureSettings::from_plugin_settings(&settings),
);
}
Err(error) => {
tracing::warn!(
kind = %kind,
error = %error,
"decoding forwarded performance event payload",
);
}
}
} else if kind.as_str() == bmux_recording_plugin_api::recording_events::EVENT_KIND.as_str()
{
match serde_json::from_slice::<
bmux_recording_plugin_api::recording_events::RecordingEvent,
>(payload)
{
Ok(bmux_recording_plugin_api::recording_events::RecordingEvent::Started {
target,
}) => {
let target = RecordingCaptureTarget {
recording_id: target.recording_id,
path: target.path,
rolling_window_secs: target.rolling_window_secs,
};
display_capture.open_target(&target, self_client_id);
}
Ok(bmux_recording_plugin_api::recording_events::RecordingEvent::Stopped {
recording_id,
}) => display_capture.close_recording(recording_id),
Ok(bmux_recording_plugin_api::recording_events::RecordingEvent::CutStarted {
..
}) => {
view_state.set_transient_status(
"recording cut started".to_string(),
Instant::now(),
ATTACH_TRANSIENT_STATUS_TTL,
);
}
Ok(bmux_recording_plugin_api::recording_events::RecordingEvent::CutCompleted {
summary,
}) => {
view_state.set_transient_status(
format!("recording cut created: {}", summary.path),
Instant::now(),
ATTACH_TRANSIENT_STATUS_TTL,
);
}
Ok(bmux_recording_plugin_api::recording_events::RecordingEvent::CutFailed {
reason,
}) => {
view_state.set_transient_status(
format!("recording cut failed: {reason}"),
Instant::now(),
ATTACH_TRANSIENT_STATUS_TTL,
);
}
Ok(
bmux_recording_plugin_api::recording_events::RecordingEvent::ExportStarted {
output_path,
..
},
) => {
view_state.set_transient_status(
format!("recording GIF export started: {output_path}"),
Instant::now(),
ATTACH_TRANSIENT_STATUS_TTL,
);
}
Ok(
bmux_recording_plugin_api::recording_events::RecordingEvent::ExportCompleted {
output_path,
..
},
) => {
view_state.set_transient_status(
format!("recording GIF exported: {output_path}"),
Instant::now(),
ATTACH_TRANSIENT_STATUS_TTL,
);
}
Ok(bmux_recording_plugin_api::recording_events::RecordingEvent::JobUpdated {
job,
}) => {
let status = format!("recording job {:?}: {:?}", job.kind, job.status);
view_state.set_transient_status(
status,
Instant::now(),
ATTACH_TRANSIENT_STATUS_TTL,
);
}
Ok(bmux_recording_plugin_api::recording_events::RecordingEvent::ExportFailed {
reason,
..
}) => {
view_state.set_transient_status(
format!("recording GIF export failed: {reason}"),
Instant::now(),
ATTACH_TRANSIENT_STATUS_TTL,
);
}
Err(error) => {
tracing::warn!(
kind = %kind,
error = %error,
"decoding forwarded recording event payload",
);
}
}
} else if kind.as_str() == pane_events::EVENT_KIND.as_str() {
match serde_json::from_slice::<pane_events::PaneEvent>(payload) {
Ok(event) => {
let outcome =
handle_pane_runtime_plugin_event(event, view_state, pane_output_pending);
image_fetch_requested |= outcome.image_fetch_requested;
}
Err(error) => {
tracing::warn!(
kind = %kind,
error = %error,
"decoding forwarded pane-runtime event payload",
);
}
}
} else if kind.as_str() == bmux_sessions_plugin_api::sessions_events::EVENT_KIND.as_str() {
match serde_json::from_slice::<bmux_sessions_plugin_api::sessions_events::SessionEvent>(
payload,
) {
Ok(bmux_sessions_plugin_api::sessions_events::SessionEvent::Removed {
session_id,
}) => {
if let Some(control) =
handle_attached_session_removed(client, view_state, session_id).await?
{
return Ok(AttachServerEventHandling {
control,
image_fetch_requested,
});
}
}
Ok(bmux_sessions_plugin_api::sessions_events::SessionEvent::Renamed { .. }) => {
refresh_attach_status_catalog_best_effort(client, view_state).await;
}
Ok(_) => {}
Err(error) => {
tracing::warn!(
kind = %kind,
error = %error,
"decoding forwarded sessions event payload",
);
}
}
} else if kind.as_str() == bmux_clients_plugin_api::clients_events::EVENT_KIND.as_str() {
match serde_json::from_slice::<bmux_clients_plugin_api::clients_events::ClientEvent>(
payload,
) {
Ok(event) => {
handle_clients_plugin_event(
client,
follow_target_id,
Some(self_client_id),
view_state,
event,
)
.await?;
}
Err(error) => {
tracing::warn!(
kind = %kind,
error = %error,
"decoding forwarded clients event payload",
);
}
}
} else if kind.as_str()
== bmux_control_catalog_plugin_api::control_catalog_events::EVENT_KIND.as_str()
{
match serde_json::from_slice::<
bmux_control_catalog_plugin_api::control_catalog_events::CatalogEvent,
>(payload)
{
Ok(bmux_control_catalog_plugin_api::control_catalog_events::CatalogEvent::Changed {
revision,
full_resync,
..
}) => {
handle_control_catalog_changed(client, view_state, revision, full_resync)
.await;
}
Err(error) => {
tracing::warn!(
kind = %kind,
error = %error,
"decoding forwarded control-catalog event payload",
);
}
}
} else if kind.as_str() == bmux_scene_protocol::scene_protocol::STATE_KIND.as_str() {
match serde_json::from_slice::<bmux_scene_protocol::scene_protocol::DecorationScene>(
payload,
) {
Ok(scene) => {
let scene_revision = scene.revision;
#[cfg(feature = "bundled-plugin-decoration")]
bmux_decoration_plugin_renderer::publish_relayed_scene_on(
&view_state.presentation_events,
scene,
);
#[cfg(not(feature = "bundled-plugin-decoration"))]
let _ = scene;
if scene_revision != *last_scene_revision {
*last_scene_revision = scene_revision;
view_state
.dirty
.mark_extension_dirty(AttachDirtySource::SceneChanged);
}
}
Err(error) => {
tracing::warn!(
kind = %kind,
error = %error,
"decoding forwarded scene-protocol payload",
);
}
}
} else if kind.as_str() == bmux_contexts_plugin_api::contexts_events::EVENT_KIND.as_str() {
match serde_json::from_slice::<bmux_contexts_plugin_api::contexts_events::ContextEvent>(
payload,
) {
Ok(event) => {
handle_context_event_forwarded(
client,
view_state,
&event,
self_client_id,
attach_config,
)
.await?;
}
Err(error) => {
tracing::warn!(
kind = %kind,
error = %error,
"decoding forwarded contexts-events payload",
);
}
}
} else if kind.as_str() == bmux_tabs_plugin_api::tabs_list::STATE_KIND.as_str() {
match serde_json::from_slice::<bmux_tabs_plugin_api::tabs_list::TabListSnapshot>(
payload,
) {
Ok(snapshot) => {
let current = view_state
.presentation_events
.subscribe_state::<bmux_tabs_plugin_api::tabs_list::TabListSnapshot>(
&bmux_tabs_plugin_api::tabs_list::STATE_KIND,
)
.map_or_else(
|_| bmux_tabs_plugin_api::tabs_list::TabListSnapshot {
tabs: Vec::new(),
revision: 0,
},
|(snapshot, _)| snapshot.as_ref().clone(),
);
let snapshot = prefer_nonempty_window_list_snapshot(¤t, snapshot);
let _ = view_state
.presentation_events
.publish_state(&bmux_tabs_plugin_api::tabs_list::STATE_KIND, snapshot);
}
Err(error) => {
tracing::warn!(
kind = %kind,
error = %error,
"decoding forwarded tabs-list payload",
);
}
}
} else if kind.as_str() == RUNTIME_APPEARANCE_STATE_KIND.as_str() {
match serde_json::from_slice::<RuntimeAppearance>(payload) {
Ok(appearance) => {
tracing::info!(
background = %appearance.background,
foreground = %appearance.foreground,
border_active = %appearance.border.active,
status_background = %appearance.status.background,
"attach received forwarded runtime appearance update",
);
let _ = view_state
.presentation_events
.publish_state(&RUNTIME_APPEARANCE_STATE_KIND, appearance);
view_state
.dirty
.mark_full_frame(AttachDirtySource::AppearanceChanged);
}
Err(error) => {
tracing::warn!(
kind = %kind,
error = %error,
"decoding forwarded runtime appearance payload",
);
}
}
} else if let Err(error) = view_state.presentation_events.emit_from_bytes(
&bmux_plugin::PluginEventKind::from_owned(kind.clone()),
payload,
) {
tracing::debug!(
kind = %kind,
error = %error,
"forwarded plugin-bus payload did not match a local typed channel",
);
}
} else {
let control = handle_attach_loop_event(
AttachLoopEvent::Server(server_event),
client,
attach_input_processor,
follow_target_id,
Some(self_client_id),
global,
attach_help_lines,
view_state,
display_capture,
kernel_client_factory,
)
.await?;
return Ok(AttachServerEventHandling {
control,
image_fetch_requested,
});
}
Ok(AttachServerEventHandling {
control: AttachLoopControl::Continue,
image_fetch_requested,
})
}
fn encode_visual_projection_batch(
updates: &[AttachVisualProjectionUpdate],
) -> Result<Vec<u8>, String> {
const MAGIC: &[u8; 4] = b"BVPB";
const VERSION: u16 = 1;
let entry_count = u16::try_from(updates.len())
.map_err(|_| "too many visual projection updates for one batch".to_string())?;
let mut capacity = MAGIC.len() + 4;
for update in updates {
let request_len = u16::try_from(update.request_id.len()).map_err(|_| {
format!(
"visual projection request id is too long: {}",
update.request_id
)
})?;
let encoding_len = u16::try_from(update.encoding.len()).map_err(|_| {
format!(
"visual projection encoding name is too long: {}",
update.encoding
)
})?;
let _payload_len = u32::try_from(update.payload.len()).map_err(|_| {
format!(
"visual projection payload is too large: {}",
update.request_id
)
})?;
capacity = capacity
.saturating_add(40)
.saturating_add(usize::from(request_len))
.saturating_add(usize::from(encoding_len))
.saturating_add(update.payload.len());
}
let mut payload = Vec::with_capacity(capacity);
payload.extend_from_slice(MAGIC);
payload.extend_from_slice(&VERSION.to_le_bytes());
payload.extend_from_slice(&entry_count.to_le_bytes());
for update in updates {
let request_id = update.request_id.as_bytes();
let encoding = update.encoding.as_bytes();
payload.extend_from_slice(
&u16::try_from(request_id.len())
.map_err(|_| "visual projection request id is too long".to_string())?
.to_le_bytes(),
);
payload.extend_from_slice(
&u16::try_from(encoding.len())
.map_err(|_| "visual projection encoding name is too long".to_string())?
.to_le_bytes(),
);
payload.extend_from_slice(
&u32::try_from(update.payload.len())
.map_err(|_| "visual projection payload is too large".to_string())?
.to_le_bytes(),
);
payload.extend_from_slice(update.surface_id.as_bytes());
payload.extend_from_slice(update.pane_id.as_bytes());
payload.extend_from_slice(request_id);
payload.extend_from_slice(encoding);
payload.extend_from_slice(&update.payload);
}
Ok(payload)
}
async fn flush_visual_projection_updates(
client: &mut StreamingBmuxClient,
view_state: &mut AttachViewState,
) {
let updates = std::mem::take(&mut view_state.visual_projection_updates);
let mut batches: BTreeMap<String, Vec<AttachVisualProjectionUpdate>> = BTreeMap::new();
for update in updates {
batches
.entry(update.event_kind.clone())
.or_default()
.push(update);
}
for (event_kind, updates) in batches {
let payload = match encode_visual_projection_batch(&updates) {
Ok(payload) => payload,
Err(error) => {
tracing::warn!(%event_kind, %error, "visual projection batch encode failed");
continue;
}
};
if let Err(error) = client
.emit_on_plugin_bus(event_kind.as_str(), payload)
.await
{
tracing::debug!(
event_kind = %event_kind,
update_count = updates.len(),
%error,
"emit_on_plugin_bus for visual projection batch failed",
);
}
}
}
#[derive(Debug, Clone, Copy, Default)]
struct PaneRuntimePluginEventOutcome {
image_fetch_requested: bool,
}
fn handle_pane_runtime_plugin_event(
event: pane_events::PaneEvent,
view_state: &mut AttachViewState,
pane_output_pending: &mut bool,
) -> PaneRuntimePluginEventOutcome {
match event {
pane_events::PaneEvent::OutputAvailable { .. } => {
*pane_output_pending = true;
PaneRuntimePluginEventOutcome::default()
}
pane_events::PaneEvent::ImageAvailable {
session_id,
pane_id,
} if session_id == view_state.attached_id => {
view_state
.dirty
.mark_pane_dirty(pane_id, AttachDirtySource::PaneOutput);
PaneRuntimePluginEventOutcome {
image_fetch_requested: true,
}
}
pane_events::PaneEvent::AttachViewChanged {
context_id,
session_id,
revision,
components,
} if attach_view_event_matches_target(view_state, context_id, session_id) => {
if view_state.last_attach_view_revision.is_some_and(
|(tracked_session, tracked_revision)| {
tracked_session == session_id && revision <= tracked_revision
},
) {
return PaneRuntimePluginEventOutcome::default();
}
view_state.last_attach_view_revision = Some((session_id, revision));
let components = components
.iter()
.copied()
.map(plugin_attach_view_component)
.collect::<Vec<_>>();
apply_attach_view_change_components(&components, view_state);
*pane_output_pending = true;
PaneRuntimePluginEventOutcome::default()
}
pane_events::PaneEvent::Exited {
session_id,
pane_id,
reason,
} if session_id == view_state.attached_id => {
let message = reason.map_or_else(
|| format!("pane {} exited", short_uuid(pane_id)),
|reason| format!("pane {} exited: {reason}", short_uuid(pane_id)),
);
view_state.set_transient_status(message, Instant::now(), ATTACH_TRANSIENT_STATUS_TTL);
PaneRuntimePluginEventOutcome::default()
}
pane_events::PaneEvent::Restarted {
session_id,
pane_id,
} if session_id == view_state.attached_id => {
view_state
.image_execution_incarnations
.insert(pane_id, Uuid::new_v4());
#[cfg(any(
feature = "image-sixel",
feature = "image-kitty",
feature = "image-iterm2"
))]
{
view_state.pane_images.remove(&pane_id);
view_state.image_sequences.remove(&pane_id);
}
view_state
.dirty
.mark_pane_dirty(pane_id, AttachDirtySource::PaneOutput);
view_state.set_transient_status(
format!("pane {} restarted", short_uuid(pane_id)),
Instant::now(),
ATTACH_TRANSIENT_STATUS_TTL,
);
PaneRuntimePluginEventOutcome::default()
}
_ => PaneRuntimePluginEventOutcome::default(),
}
}
const fn plugin_attach_view_component(
component: pane_events::AttachViewComponent,
) -> AttachViewComponent {
match component {
pane_events::AttachViewComponent::Scene => AttachViewComponent::Scene,
pane_events::AttachViewComponent::SurfaceContent => AttachViewComponent::SurfaceContent,
pane_events::AttachViewComponent::Layout => AttachViewComponent::Layout,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AttachRunOutcome {
pub status_code: u8,
pub exit_reason: AttachExitReason,
pub resume: Option<bmux_client::AttachResumeState>,
}
pub async fn apply_plugin_command_outcome(
client: &mut StreamingBmuxClient,
view_state: &mut AttachViewState,
outcome: PluginCommandOutcome,
) -> std::result::Result<bool, ClientError> {
let Some(context_id) = selected_context_id_from_command_outcome(&outcome) else {
return Ok(false);
};
if view_state.attached_context_id == Some(context_id) {
return Ok(true);
}
retarget_attach_to_context(client, view_state, context_id).await?;
Ok(true)
}
fn selected_context_id_from_command_outcome(outcome: &PluginCommandOutcome) -> Option<Uuid> {
let value = outcome
.metadata
.get(COMMAND_OUTCOME_SELECTED_CONTEXT_ID_KEY)?;
let context_id = value.as_str().and_then(|value| Uuid::parse_str(value).ok());
if context_id.is_none() {
warn!(
value = ?value,
"attach.plugin_command.invalid_selected_context_outcome"
);
}
context_id
}
fn status_message_from_command_outcome(outcome: &PluginCommandOutcome) -> Option<String> {
let value = outcome.metadata.get(COMMAND_OUTCOME_STATUS_MESSAGE_KEY)?;
let Some(message) = value.as_str() else {
warn!(
value = ?value,
"attach.plugin_command.invalid_status_message_outcome"
);
return None;
};
Some(truncate_attach_status_message(message))
}
fn truncate_attach_status_message(message: &str) -> String {
let mut chars = message.chars();
let truncated = chars
.by_ref()
.take(ATTACH_PLUGIN_STATUS_MESSAGE_MAX_CHARS)
.collect::<String>();
if chars.next().is_some() {
format!("{truncated}…")
} else {
truncated
}
}
async fn handle_context_event_forwarded(
client: &mut StreamingBmuxClient,
view_state: &mut AttachViewState,
event: &bmux_contexts_plugin_api::contexts_events::ContextEvent,
self_client_id: Uuid,
attach_config: &BmuxConfig,
) -> std::result::Result<(), ClientError> {
use bmux_contexts_plugin_api::contexts_events::ContextEvent;
match event {
ContextEvent::SessionActiveContextChanged {
session_id,
context_id,
initiator_client_id,
} => {
if view_state.attached_context_id == Some(*context_id) {
return Ok(());
}
let is_self = *initiator_client_id == Some(self_client_id);
if !is_self {
if !attach_config.multi_client.default_follow_mode {
return Ok(());
}
if view_state.attached_id != *session_id {
return Ok(());
}
}
debug!(
context_id = %context_id,
session_id = %session_id,
is_self,
"attach.context_event.retarget"
);
retarget_attach_to_context(client, view_state, *context_id).await?;
view_state
.dirty
.mark_layout_frame_dirty(AttachDirtySource::SceneChanged);
}
ContextEvent::Selected { context_id } => {
if view_state.attached_context_id == Some(*context_id) {
return Ok(());
}
debug!(
context_id = %context_id,
"attach.context_event.selected_retarget"
);
retarget_attach_to_context(client, view_state, *context_id).await?;
view_state
.dirty
.mark_layout_frame_dirty(AttachDirtySource::SceneChanged);
}
ContextEvent::Renamed { .. } => {
refresh_attach_status_catalog_best_effort(client, view_state).await;
}
ContextEvent::Created { .. } | ContextEvent::Closed { .. } => {
}
}
Ok(())
}
pub async fn retarget_attach_to_context(
client: &mut StreamingBmuxClient,
view_state: &mut AttachViewState,
context_id: Uuid,
) -> std::result::Result<(), ClientError> {
if !view_state.allow_retarget {
return Ok(());
}
let started_at = Instant::now();
let from_context_id = view_state.attached_context_id;
let from_session_id = view_state.attached_id;
debug!(
from_context_id = ?from_context_id,
from_session_id = %from_session_id,
to_context_id = %context_id,
"attach.retarget.start"
);
let retarget_service_started = Instant::now();
let geometry = selected_attach_geometry(view_state);
let insets = resolved_attach_viewport_insets(geometry, &view_state.presentation_layouts);
let attach_info = client
.retarget_attach_context_with_insets(
context_id,
geometry.cols,
geometry.rows,
insets.top,
insets.right,
insets.bottom,
insets.left,
)
.await?;
let retarget_service_us = retarget_service_started.elapsed().as_micros();
let select_us = 0_u128;
let open_us = retarget_service_us;
view_state.attached_id = attach_info.session_id;
view_state.last_attach_view_revision = None;
view_state.attached_context_id = attach_info.context_id.or(Some(context_id));
view_state.can_write = attach_info.can_write;
let viewport_us = 0_u128;
let hydrate_started = Instant::now();
hydrate_attach_state_from_snapshot(client, view_state).await?;
let hydrate_us = hydrate_started.elapsed().as_micros();
let catalog_started = Instant::now();
refresh_attach_status_catalog_best_effort(client, view_state).await;
let catalog_us = catalog_started.elapsed().as_micros();
view_state.ui_mode = AttachUiMode::Normal;
debug!(
to_context_id = ?view_state.attached_context_id,
to_session_id = %view_state.attached_id,
can_write = view_state.can_write,
elapsed_ms = started_at.elapsed().as_millis(),
"attach.retarget.done"
);
emit_attach_phase_timing(&serde_json::json!({
"phase": "attach.retarget_context",
"from_context_id": from_context_id,
"from_session_id": from_session_id,
"to_context_id": context_id,
"selected_context_id": view_state.attached_context_id,
"selected_session_id": view_state.attached_id,
"retarget_service_us": retarget_service_us,
"select_us": select_us,
"open_us": open_us,
"viewport_us": viewport_us,
"hydrate_us": hydrate_us,
"catalog_us": catalog_us,
"total_us": started_at.elapsed().as_micros(),
}));
Ok(())
}
pub fn plugin_fallback_retarget_context_id(
before_context_id: Option<Uuid>,
after_context_id: Option<Uuid>,
attached_context_id: Option<Uuid>,
outcome_applied: bool,
) -> Option<Uuid> {
if outcome_applied {
return None;
}
after_context_id
.filter(|after| Some(*after) != before_context_id && Some(*after) != attached_context_id)
}
pub fn plugin_fallback_new_context_id(
before_context_ids: Option<&std::collections::BTreeSet<Uuid>>,
after_context_ids: Option<&std::collections::BTreeSet<Uuid>>,
attached_context_id: Option<Uuid>,
after_context_id: Option<Uuid>,
outcome_applied: bool,
) -> Option<Uuid> {
if outcome_applied {
return None;
}
let (Some(before), Some(after)) = (before_context_ids, after_context_ids) else {
return None;
};
let mut new_context_ids = after
.difference(before)
.copied()
.filter(|context_id| Some(*context_id) != attached_context_id)
.collect::<Vec<_>>();
if new_context_ids.is_empty() {
return None;
}
if new_context_ids.len() == 1 {
return new_context_ids.pop();
}
after_context_id.filter(|context_id| new_context_ids.contains(context_id))
}
fn build_attach_plugin_command_pipeline(
plugin_id: &str,
command_name: &str,
args: &[String],
) -> std::result::Result<bmux_ipc::ServicePipelineRequest, ClientError> {
let request = bmux_plugin_sdk::PluginCliCommandRequest::new(
plugin_id.to_string(),
command_name.to_string(),
args.to_vec(),
);
let command_payload = bmux_plugin_sdk::encode_service_message(&request).map_err(|error| {
ClientError::ServerError {
code: bmux_ipc::ErrorCode::Internal,
message: format!("encoding plugin command pipeline request: {error}"),
}
})?;
Ok(bmux_ipc::ServicePipelineRequest {
inputs: BTreeMap::new(),
steps: vec![bmux_ipc::ServicePipelineStep {
capability: bmux_plugin_sdk::CORE_CLI_COMMAND_CAPABILITY.to_string(),
kind: InvokeServiceKind::Command,
interface_id: bmux_plugin_sdk::CORE_CLI_COMMAND_INTERFACE_V1.to_string(),
operation: bmux_plugin_sdk::CORE_CLI_COMMAND_RUN_PLUGIN_OPERATION_V1.to_string(),
payload: bmux_ipc::ServicePipelinePayload::Encoded {
payload: command_payload,
},
}],
})
}
struct AttachPluginCommandPipelineExecution {
status: i32,
outcome: PluginCommandOutcome,
}
fn decode_attach_plugin_command_pipeline_results(
plugin_id: &str,
command_name: &str,
results: &[bmux_ipc::ServicePipelineStepResult],
) -> std::result::Result<AttachPluginCommandPipelineExecution, ClientError> {
let command_result = results.first().ok_or(ClientError::UnexpectedResponse(
"missing plugin command pipeline result",
))?;
let command_response: bmux_plugin_sdk::PluginCliCommandResponse =
bmux_plugin_sdk::decode_service_message(&command_result.payload).map_err(|error| {
ClientError::ServerError {
code: bmux_ipc::ErrorCode::Internal,
message: format!("decoding plugin command pipeline response: {error}"),
}
})?;
Ok(AttachPluginCommandPipelineExecution {
status: command_response.exit_code,
outcome: PluginCommandOutcome {
error_message: command_response.error.or_else(|| {
(command_response.exit_code != 0)
.then(|| format!("plugin action failed ({plugin_id}:{command_name})"))
}),
metadata: command_result.metadata.clone(),
},
})
}
async fn run_attach_plugin_command_pipeline(
client: &mut StreamingBmuxClient,
plugin_id: &str,
command_name: &str,
args: &[String],
) -> std::result::Result<AttachPluginCommandPipelineExecution, ClientError> {
let started_at = Instant::now();
let pipeline = build_attach_plugin_command_pipeline(plugin_id, command_name, args)?;
let results = client.invoke_service_pipeline_raw(pipeline).await?;
let execution =
decode_attach_plugin_command_pipeline_results(plugin_id, command_name, &results)?;
emit_attach_phase_timing(&serde_json::json!({
"phase": "attach.plugin_command_pipeline",
"plugin_id": plugin_id,
"command_name": command_name,
"total_us": started_at.elapsed().as_micros(),
}));
Ok(execution)
}
async fn enforce_hot_path_plugin_policy(
client: &mut StreamingBmuxClient,
plugin_id: &str,
command_name: &str,
attached_session_id: Uuid,
attached_context_id: Option<Uuid>,
) -> anyhow::Result<bmux_plugin_sdk::CommandExecutionKind> {
let hints = plugin_command_policy_hints(plugin_id, command_name).map_err(|error| {
ClientError::ServerError {
code: bmux_ipc::ErrorCode::InvalidRequest,
message: error.to_string(),
}
})?;
let execution = hints.execution.clone();
if !matches!(
execution,
bmux_plugin_sdk::CommandExecutionKind::RuntimeHook
) {
return Ok(execution);
}
if matches!(
hints.execution_class,
bmux_plugin::PluginExecutionClass::NativeFast
) {
return Ok(execution);
}
let Some(hot_path_capability) = hints
.required_capabilities
.iter()
.find(|capability| capability.is_hot_path())
else {
return Ok(execution);
};
let client_id = clients_state::client::current_client(client)
.await?
.map_err(|err| anyhow::anyhow!("clients-state current-client failed: {err:?}"))?
.id;
let principal_info = client.whoami_principal().await?;
let execution_class = match hints.execution_class {
bmux_plugin::PluginExecutionClass::NativeFast => "native_fast",
bmux_plugin::PluginExecutionClass::NativeStandard => "native_standard",
bmux_plugin::PluginExecutionClass::Interpreter => "interpreter",
}
.to_string();
let response = session_policy_state::client::check(
client,
attached_session_id,
attached_context_id,
client_id,
principal_info.principal_id,
"hot_path_execution".to_string(),
Some(plugin_id.to_string()),
Some(hot_path_capability.to_string()),
Some(execution_class),
)
.await?;
if response.allowed {
Ok(execution)
} else {
Err(ClientError::ServerError {
code: bmux_ipc::ErrorCode::InvalidRequest,
message: response.reason.unwrap_or_else(|| {
format!(
"hot-path plugin execution denied for {plugin_id}:{command_name}; grant scoped override or use execution_class=native_fast"
)
}),
}
.into())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AttachPluginCommandExecutionRoute {
ProviderPipeline,
CallerProcess,
}
const fn attach_plugin_command_execution_route(
execution: &bmux_plugin_sdk::CommandExecutionKind,
) -> AttachPluginCommandExecutionRoute {
match execution {
bmux_plugin_sdk::CommandExecutionKind::CallerProcess => {
AttachPluginCommandExecutionRoute::CallerProcess
}
bmux_plugin_sdk::CommandExecutionKind::ProviderExec
| bmux_plugin_sdk::CommandExecutionKind::BackgroundTask
| bmux_plugin_sdk::CommandExecutionKind::RuntimeHook => {
AttachPluginCommandExecutionRoute::ProviderPipeline
}
}
}
fn run_attach_plugin_command_local(
plugin_id: &str,
command_name: &str,
args: &[String],
kernel_client_factory: Option<&KernelClientFactory>,
caller_client_id: Option<Uuid>,
active_keybindings: Vec<bmux_plugin_sdk::ActiveKeybinding>,
) -> std::result::Result<AttachPluginCommandPipelineExecution, ClientError> {
let execution = run_plugin_keybinding_command_with_active_bindings(
plugin_id,
command_name,
args,
kernel_client_factory,
caller_client_id,
active_keybindings,
)
.map_err(|error| ClientError::ServerError {
code: bmux_ipc::ErrorCode::Internal,
message: format!("local plugin command failed: {error:#}"),
})?;
Ok(AttachPluginCommandPipelineExecution {
status: execution.status,
outcome: execution.outcome,
})
}
fn active_keybindings_for_context(
keymap: &Keymap,
mode_id: Option<&str>,
scroll_mode: bool,
) -> Vec<bmux_plugin_sdk::ActiveKeybinding> {
keymap
.active_bindings_for_state(mode_id, scroll_mode)
.into_iter()
.map(|binding| bmux_plugin_sdk::ActiveKeybinding {
scope: binding.scope,
chord: binding.chord,
action: binding.action_name,
})
.collect()
}
#[allow(clippy::too_many_lines)]
pub async fn handle_attach_plugin_command_action(
client: &mut StreamingBmuxClient,
plugin_id: &str,
command_name: &str,
args: &[String],
view_state: &mut AttachViewState,
kernel_client_factory: Option<&KernelClientFactory>,
active_keybindings: Vec<bmux_plugin_sdk::ActiveKeybinding>,
) -> std::result::Result<(), ClientError> {
if plugin_id == "bmux.pane_runtime" && command_name == "scrollback-mode-set" {
let Some(pane_id) = view_state.focused_pane_id() else {
view_state.set_transient_status(
"no focused pane",
Instant::now(),
ATTACH_TRANSIENT_STATUS_TTL,
);
return Ok(());
};
let config = BmuxConfig::load().unwrap_or_default();
let paths = ConfigPaths::default();
let current = cached_mode(
&paths,
view_state.attached_id,
pane_id,
config.behavior.scrollback.persist_pane_modes,
)
.unwrap_or(config.behavior.scrollback.mode);
let requested = args.first().map_or("toggle", String::as_str);
let mode = match requested {
"live" => bmux_config::ScrollbackMode::Live,
"frozen" => bmux_config::ScrollbackMode::Frozen,
"toggle" => match current {
bmux_config::ScrollbackMode::Live => bmux_config::ScrollbackMode::Frozen,
bmux_config::ScrollbackMode::Frozen => bmux_config::ScrollbackMode::Live,
},
_ => {
view_state.set_transient_status(
"scrollback-mode-set expects live, frozen, or toggle",
Instant::now(),
ATTACH_TRANSIENT_STATUS_TTL,
);
return Ok(());
}
};
if config.behavior.scrollback.persist_pane_modes {
let active_panes =
view_state
.cached_layout_state
.as_ref()
.map_or_else(BTreeMap::new, |layout| {
BTreeMap::from([(
view_state.attached_id,
layout.panes.iter().map(|pane| pane.id).collect(),
)])
});
if let Err(error) = set_cached_mode_debounced(
paths,
view_state.attached_id,
pane_id,
mode,
&active_panes,
) {
view_state.set_transient_status(
format!("failed persisting scrollback mode: {error}"),
Instant::now(),
ATTACH_TRANSIENT_STATUS_TTL,
);
return Ok(());
}
} else if let Err(error) = set_runtime_mode(&paths, view_state.attached_id, pane_id, mode) {
view_state.set_transient_status(
format!("failed setting scrollback mode: {error}"),
Instant::now(),
ATTACH_TRANSIENT_STATUS_TTL,
);
return Ok(());
}
view_state.set_transient_status(
format!(
"scrollback mode: {}",
match mode {
bmux_config::ScrollbackMode::Live => "live",
bmux_config::ScrollbackMode::Frozen => "frozen",
}
),
Instant::now(),
ATTACH_TRANSIENT_STATUS_TTL,
);
return Ok(());
}
let total_started = Instant::now();
let before_started = Instant::now();
let before_context_id = typed_current_context_attach(client)
.await
.map_or(None, |context| context.map(|entry| entry.id));
let before_context_ids = typed_list_contexts_attach(client)
.await
.ok()
.map(|contexts| {
contexts
.into_iter()
.map(|context| context.id)
.collect::<std::collections::BTreeSet<_>>()
});
let before_us = before_started.elapsed().as_micros();
debug!(
plugin_id = %plugin_id,
command_name = %command_name,
before_context_id = ?before_context_id,
attached_context_id = ?view_state.attached_context_id,
attached_session_id = %view_state.attached_id,
"attach.plugin_command.start"
);
let policy_started = Instant::now();
let execution_kind = match enforce_hot_path_plugin_policy(
client,
plugin_id,
command_name,
view_state.attached_id,
view_state.attached_context_id,
)
.await
{
Ok(execution) => execution,
Err(error) => {
let policy_us = policy_started.elapsed().as_micros();
warn!(
plugin_id = %plugin_id,
command_name = %command_name,
error = %error,
attached_context_id = ?view_state.attached_context_id,
attached_session_id = %view_state.attached_id,
"attach.plugin_command.policy_denied"
);
view_state.set_transient_status(
format!("plugin action denied by policy: {error:#}"),
Instant::now(),
ATTACH_TRANSIENT_STATUS_TTL,
);
emit_attach_plugin_command_timing(
plugin_id,
command_name,
"policy_denied",
before_context_id,
None,
view_state.attached_context_id,
view_state.attached_id,
before_us,
policy_us,
0,
0,
0,
0,
total_started.elapsed().as_micros(),
);
return Ok(());
}
};
let policy_us = policy_started.elapsed().as_micros();
let run_started = Instant::now();
let command_execution = match attach_plugin_command_execution_route(&execution_kind) {
AttachPluginCommandExecutionRoute::ProviderPipeline => {
run_attach_plugin_command_pipeline(client, plugin_id, command_name, args).await
}
AttachPluginCommandExecutionRoute::CallerProcess => {
let (sender, mut requests) = tokio::sync::mpsc::channel::<
super::super::plugin_kernel::InvokingTransportRequest,
>(1);
let plugin_id = plugin_id.to_string();
let command_name = command_name.to_string();
let args = args.to_vec();
let factory = kernel_client_factory.cloned();
let caller = view_state.self_client_id;
let async_services = view_state.async_services.clone();
let command = tokio::task::spawn_blocking(move || {
let _async_route = async_services.map(bmux_plugin::enter_async_command_route);
let _transport = super::super::plugin_kernel::enter_invoking_transport(sender);
run_attach_plugin_command_local(
&plugin_id,
&command_name,
&args,
factory.as_ref(),
caller,
active_keybindings,
)
});
while let Some(request) = requests.recv().await {
let response = client
.request_raw(request.request)
.await
.map_err(anyhow::Error::from);
let _ = request.response.send(response);
}
command.await.map_err(|error| ClientError::ServerError {
code: bmux_ipc::ErrorCode::Internal,
message: format!("caller command task failed: {error}"),
})?
}
};
match command_execution {
Err(error) => {
let run_us = run_started.elapsed().as_micros();
warn!(
plugin_id = %plugin_id,
command_name = %command_name,
error = %error,
"attach.plugin_command.run_failed"
);
view_state.set_transient_status(
format!("plugin command failed: {}", map_attach_client_error(error)),
Instant::now(),
ATTACH_TRANSIENT_STATUS_TTL,
);
emit_attach_plugin_command_timing(
plugin_id,
command_name,
"run_error",
before_context_id,
None,
view_state.attached_context_id,
view_state.attached_id,
before_us,
policy_us,
run_us,
0,
0,
0,
total_started.elapsed().as_micros(),
);
}
Ok(execution) => {
let run_us = run_started.elapsed().as_micros();
let status = execution.status;
let outcome_status_message = status_message_from_command_outcome(&execution.outcome);
if status != 0 {
let error_detail = execution.outcome.error_message.as_deref();
if let Some(detail) = error_detail {
warn!(
plugin_id = %plugin_id,
command_name = %command_name,
status,
error = %detail,
before_context_id = ?before_context_id,
attached_context_id = ?view_state.attached_context_id,
attached_session_id = %view_state.attached_id,
"attach.plugin_command.nonzero_status"
);
} else {
warn!(
plugin_id = %plugin_id,
command_name = %command_name,
status,
before_context_id = ?before_context_id,
attached_context_id = ?view_state.attached_context_id,
attached_session_id = %view_state.attached_id,
"attach.plugin_command.nonzero_status"
);
}
let status_text = outcome_status_message.unwrap_or_else(|| {
error_detail.map_or_else(
|| {
format!(
"plugin action failed ({plugin_id}:{command_name}) exit {status}"
)
},
truncate_attach_status_message,
)
});
view_state.set_transient_status(
status_text,
Instant::now(),
ATTACH_TRANSIENT_STATUS_TTL,
);
emit_attach_plugin_command_timing(
plugin_id,
command_name,
"nonzero",
before_context_id,
None,
view_state.attached_context_id,
view_state.attached_id,
before_us,
policy_us,
run_us,
0,
0,
0,
total_started.elapsed().as_micros(),
);
return Ok(());
}
let outcome_started = Instant::now();
let outcome_applied =
match apply_plugin_command_outcome(client, view_state, execution.outcome).await {
Ok(applied) => applied,
Err(error) => {
let outcome_us = outcome_started.elapsed().as_micros();
view_state.set_transient_status(
format!(
"plugin outcome apply failed: {}",
map_attach_client_error(error)
),
Instant::now(),
ATTACH_TRANSIENT_STATUS_TTL,
);
emit_attach_plugin_command_timing(
plugin_id,
command_name,
"outcome_error",
before_context_id,
None,
view_state.attached_context_id,
view_state.attached_id,
before_us,
policy_us,
run_us,
outcome_us,
0,
0,
total_started.elapsed().as_micros(),
);
return Ok(());
}
};
let outcome_us = outcome_started.elapsed().as_micros();
let after_started = Instant::now();
let (after_context_id, after_context_ids, after_us) = if outcome_applied {
(None, None, 0)
} else {
let after_context_id = typed_current_context_attach(client)
.await
.map_or(None, |context| context.map(|entry| entry.id));
let after_context_ids =
typed_list_contexts_attach(client)
.await
.ok()
.map(|contexts| {
contexts
.into_iter()
.map(|context| context.id)
.collect::<std::collections::BTreeSet<_>>()
});
(
after_context_id,
after_context_ids,
after_started.elapsed().as_micros(),
)
};
debug!(
plugin_id = %plugin_id,
command_name = %command_name,
outcome_applied,
before_context_id = ?before_context_id,
after_context_id = ?after_context_id,
attached_context_id = ?view_state.attached_context_id,
attached_session_id = %view_state.attached_id,
"attach.plugin_command.outcome"
);
if let Some(fallback_context_id) = plugin_fallback_retarget_context_id(
before_context_id,
after_context_id,
view_state.attached_context_id,
outcome_applied,
) {
debug!(
plugin_id = %plugin_id,
command_name = %command_name,
fallback_context_id = %fallback_context_id,
"attach.plugin_command.fallback_retarget"
);
let retarget_started = Instant::now();
if let Err(error) =
retarget_attach_to_context(client, view_state, fallback_context_id).await
{
let retarget_us = retarget_started.elapsed().as_micros();
warn!(
plugin_id = %plugin_id,
command_name = %command_name,
fallback_context_id = %fallback_context_id,
error = %error,
"attach.plugin_command.fallback_retarget_failed"
);
view_state.set_transient_status(
format!(
"plugin fallback retarget failed: {}",
map_attach_client_error(error)
),
Instant::now(),
ATTACH_TRANSIENT_STATUS_TTL,
);
emit_attach_plugin_command_timing(
plugin_id,
command_name,
"fallback_retarget_error",
before_context_id,
after_context_id,
view_state.attached_context_id,
view_state.attached_id,
before_us,
policy_us,
run_us,
outcome_us,
after_us,
retarget_us,
total_started.elapsed().as_micros(),
);
return Ok(());
}
let retarget_us = retarget_started.elapsed().as_micros();
view_state
.dirty
.mark_layout_refresh(AttachDirtySource::PluginCommand);
emit_attach_plugin_command_timing(
plugin_id,
command_name,
"fallback_retarget",
before_context_id,
after_context_id,
view_state.attached_context_id,
view_state.attached_id,
before_us,
policy_us,
run_us,
outcome_us,
after_us,
retarget_us,
total_started.elapsed().as_micros(),
);
return Ok(());
}
if let Some(fallback_context_id) = plugin_fallback_new_context_id(
before_context_ids.as_ref(),
after_context_ids.as_ref(),
view_state.attached_context_id,
after_context_id,
outcome_applied,
) {
debug!(
plugin_id = %plugin_id,
command_name = %command_name,
fallback_context_id = %fallback_context_id,
"attach.plugin_command.new_context_fallback_retarget"
);
let retarget_started = Instant::now();
if let Err(error) =
retarget_attach_to_context(client, view_state, fallback_context_id).await
{
let retarget_us = retarget_started.elapsed().as_micros();
warn!(
plugin_id = %plugin_id,
command_name = %command_name,
fallback_context_id = %fallback_context_id,
error = %error,
"attach.plugin_command.new_context_fallback_retarget_failed"
);
view_state.set_transient_status(
format!(
"plugin new-context fallback failed: {}",
map_attach_client_error(error)
),
Instant::now(),
ATTACH_TRANSIENT_STATUS_TTL,
);
emit_attach_plugin_command_timing(
plugin_id,
command_name,
"fallback_new_retarget_error",
before_context_id,
after_context_id,
view_state.attached_context_id,
view_state.attached_id,
before_us,
policy_us,
run_us,
outcome_us,
after_us,
retarget_us,
total_started.elapsed().as_micros(),
);
return Ok(());
}
let retarget_us = retarget_started.elapsed().as_micros();
view_state
.dirty
.mark_layout_refresh(AttachDirtySource::PluginCommand);
emit_attach_plugin_command_timing(
plugin_id,
command_name,
"fallback_new_retarget",
before_context_id,
after_context_id,
view_state.attached_context_id,
view_state.attached_id,
before_us,
policy_us,
run_us,
outcome_us,
after_us,
retarget_us,
total_started.elapsed().as_micros(),
);
return Ok(());
}
if let Some(message) = outcome_status_message {
view_state.set_transient_status(
message,
Instant::now(),
ATTACH_TRANSIENT_STATUS_TTL,
);
}
view_state
.dirty
.mark_layout_refresh(AttachDirtySource::PluginCommand);
emit_attach_plugin_command_timing(
plugin_id,
command_name,
"ok",
before_context_id,
after_context_id,
view_state.attached_context_id,
view_state.attached_id,
before_us,
policy_us,
run_us,
outcome_us,
after_us,
0,
total_started.elapsed().as_micros(),
);
}
}
Ok(())
}
#[derive(Debug, Clone, Copy)]
struct AttachCloseFallback {
target: AttachCloseFallbackTarget,
label: &'static str,
}
const FINAL_PANE_CHOICE_NEW_PANE: &str = "new-pane";
const FINAL_PANE_CHOICE_NEW_SESSION: &str = "new-session";
const FINAL_PANE_CHOICE_QUIT: &str = "quit";
const FINAL_PANE_CHOICE_CANCEL: &str = "cancel";
fn next_context_fallback(view_state: &AttachViewState) -> Option<AttachCloseFallback> {
let current_context = view_state.attached_context_id;
let windows = view_state.cached_tab_list.as_ref()?;
let candidates = windows
.tabs
.iter()
.filter(|entry| Some(entry.id) != current_context)
.filter(|entry| {
view_state
.cached_contexts
.iter()
.any(|context| context.id == entry.id)
})
.filter(|entry| {
context_session_id(&view_state.cached_context_session_bindings, entry.id)
.is_some_and(|session_id| session_id != view_state.attached_id)
})
.collect::<Vec<_>>();
if candidates.is_empty() {
return None;
}
let current_index = current_context.and_then(|id| windows.tabs.iter().position(|w| w.id == id));
let selected = current_index
.and_then(|index| {
windows.tabs[index.saturating_add(1)..]
.iter()
.find(|entry| candidates.iter().any(|candidate| candidate.id == entry.id))
.or_else(|| {
windows.tabs[..index]
.iter()
.rev()
.find(|entry| candidates.iter().any(|candidate| candidate.id == entry.id))
})
})
.or_else(|| candidates.first().copied())?;
Some(AttachCloseFallback {
target: AttachCloseFallbackTarget::Context {
context_id: selected.id,
},
label: "another tab",
})
}
fn next_session_fallback(view_state: &AttachViewState) -> Option<AttachCloseFallback> {
view_state
.cached_sessions
.iter()
.find(|session| session.id != view_state.attached_id)
.map(|session| AttachCloseFallback {
target: AttachCloseFallbackTarget::Session {
session_id: session.id,
},
label: "another session",
})
}
fn next_close_fallback(view_state: &AttachViewState) -> Option<AttachCloseFallback> {
next_context_fallback(view_state).or_else(|| next_session_fallback(view_state))
}
fn output_batch_pane_ids(
scene: &bmux_attach_layout_protocol::AttachScene,
view_state: &AttachViewState,
) -> Vec<Uuid> {
visible_scene_pane_ids(scene)
.into_iter()
.filter(|pane_id| {
view_state
.scrollback_for(*pane_id)
.is_none_or(|view| view.pin.is_none())
})
.collect()
}
fn enqueue_final_pane_action_prompt(
view_state: &mut AttachViewState,
pane_id: Uuid,
session_id: Uuid,
) {
view_state.mouse.clear_pointer_gestures();
view_state.prompt.enqueue_internal(
PromptRequest::single_select(
"Final pane action",
vec![
PromptOption::new(FINAL_PANE_CHOICE_NEW_PANE, "New pane in this session"),
PromptOption::new(FINAL_PANE_CHOICE_NEW_SESSION, "New session"),
PromptOption::new(FINAL_PANE_CHOICE_QUIT, "Quit bmux"),
PromptOption::new(FINAL_PANE_CHOICE_CANCEL, "Cancel"),
],
)
.message("This is the final available pane. Choose what bmux should do next.")
.policy(prompt::PromptPolicy::RejectIfBusy),
AttachInternalPromptAction::FinalPaneAction {
pane_id,
session_id,
},
);
}
#[allow(clippy::too_many_lines)]
pub fn handle_attach_ui_action_at(
action: &RuntimeAction,
view_state: &mut AttachViewState,
now: Instant,
) {
match action {
RuntimeAction::EnterScrollMode => {
if enter_manual_copy_mode(view_state) {
} else {
view_state.set_transient_status(
ATTACH_SCROLLBACK_UNAVAILABLE_STATUS,
now,
ATTACH_TRANSIENT_STATUS_TTL,
);
}
}
RuntimeAction::ExitScrollMode => {
if view_state.selection_active() {
clear_attach_selection_at(view_state, true, now);
} else {
view_state.exit_focused_scrollback();
}
}
RuntimeAction::ScrollUpLine => {
step_attach_scrollback(view_state, -1);
}
RuntimeAction::ScrollDownLine => {
step_attach_scrollback(view_state, 1);
}
RuntimeAction::ScrollUpPage => {
step_attach_scrollback(
view_state,
-(attach_scrollback_page_size(view_state).cast_signed()),
);
}
RuntimeAction::ScrollDownPage => {
step_attach_scrollback(
view_state,
attach_scrollback_page_size(view_state).cast_signed(),
);
}
RuntimeAction::ScrollTop if view_state.scrollback_active() => {
let max_offset = max_attach_scrollback(view_state);
if let Some(pane) = view_state.focused_pane_id() {
view_state.request_scroll_offset(pane, max_offset);
view_state
.scroll_boundary
.insert(pane, super::scrollback_fetch::Boundary::Oldest);
}
clamp_attach_scrollback_cursor(view_state);
}
RuntimeAction::ScrollBottom if view_state.scrollback_active() => {
if let Some(pane) = view_state.focused_pane_id() {
view_state.request_scroll_offset(pane, 0);
view_state
.scroll_boundary
.insert(pane, super::scrollback_fetch::Boundary::Newest);
}
clamp_attach_scrollback_cursor(view_state);
}
RuntimeAction::MoveCursorLeft => {
move_attach_scrollback_cursor_horizontal(view_state, -1);
}
RuntimeAction::MoveCursorRight => {
move_attach_scrollback_cursor_horizontal(view_state, 1);
}
RuntimeAction::MoveCursorUp => {
move_attach_scrollback_cursor_vertical(view_state, -1);
}
RuntimeAction::MoveCursorDown => {
move_attach_scrollback_cursor_vertical(view_state, 1);
}
RuntimeAction::BeginSelection if begin_attach_selection(view_state) => {
view_state.set_transient_status(
ATTACH_SELECTION_STARTED_STATUS,
now,
ATTACH_TRANSIENT_STATUS_TTL,
);
}
RuntimeAction::CopyScrollback => {
copy_attach_selection_at(view_state, false, now);
}
RuntimeAction::ConfirmScrollback => {
confirm_attach_scrollback_at(view_state, now);
}
RuntimeAction::SwitchProfile(_) => {
view_state.set_transient_status(
"switch_profile is handled by attach input loop",
now,
ATTACH_TRANSIENT_STATUS_TTL,
);
}
RuntimeAction::Quit => {
if view_state.prompt.is_busy() {
view_state.set_transient_status(
"prompt already active",
now,
ATTACH_TRANSIENT_STATUS_TTL,
);
return;
}
view_state.mouse.clear_pointer_gestures();
view_state.prompt.enqueue_internal(
PromptRequest::confirm("Quit session and all panes?")
.message("This will terminate the attached session and every pane.")
.submit_label("Quit")
.cancel_label("Cancel")
.confirm_default(false)
.policy(prompt::PromptPolicy::RejectIfBusy),
AttachInternalPromptAction::QuitSession,
);
}
RuntimeAction::PluginCommand {
plugin_id,
command_name,
..
} if is_windows_close_active_pane_command(plugin_id, command_name) => {
let Some(pane_id) = focused_attach_pane_id(view_state) else {
view_state.set_transient_status(
"no focused pane",
now,
ATTACH_TRANSIENT_STATUS_TTL,
);
return;
};
if view_state.prompt.is_busy() {
view_state.set_transient_status(
"prompt already active",
now,
ATTACH_TRANSIENT_STATUS_TTL,
);
return;
}
let pane_count = view_state
.cached_layout_state
.as_ref()
.map_or(0, |layout| layout.panes.len());
if pane_count > 1 {
view_state.mouse.clear_pointer_gestures();
view_state.prompt.enqueue_internal(
PromptRequest::confirm("Close focused pane?")
.message("This will stop the pane process.")
.submit_label("Close")
.cancel_label("Cancel")
.confirm_default(false)
.policy(prompt::PromptPolicy::RejectIfBusy),
AttachInternalPromptAction::ClosePane { pane_id },
);
} else if let Some(fallback) = next_close_fallback(view_state) {
view_state.mouse.clear_pointer_gestures();
view_state.prompt.enqueue_internal(
PromptRequest::confirm(format!(
"Close this pane and switch to {}?",
fallback.label
))
.message(
"This is the last pane in the current session. bmux will switch before closing it.",
)
.submit_label("Close and switch")
.cancel_label("Cancel")
.confirm_default(false)
.policy(prompt::PromptPolicy::RejectIfBusy),
AttachInternalPromptAction::CloseLastPaneAndSwitch {
old_session_id: view_state.attached_id,
target: fallback.target,
},
);
} else {
enqueue_final_pane_action_prompt(view_state, pane_id, view_state.attached_id);
}
}
_ => {}
}
}
pub fn attach_pane_inner_size(
view_state: &AttachViewState,
pane_id: Uuid,
) -> Option<(usize, usize)> {
let layout_state = view_state.cached_layout_state.as_ref()?;
layout_state
.scene
.surfaces
.iter()
.find(|surface| surface.visible && surface.pane_id == Some(pane_id))
.map(|surface| {
(
usize::from(surface.content_rect.w.max(1)),
usize::from(surface.content_rect.h.max(1)),
)
})
}
pub fn attach_pane_buffer_mut(
view_state: &mut AttachViewState,
pane_id: Uuid,
) -> Option<&mut attach::state::PaneRenderBuffer> {
view_state.pane_buffers.get_mut(&pane_id)
}
pub fn enter_attach_scrollback_for(view_state: &mut AttachViewState, pane_id: Uuid) -> bool {
if view_state.scrollback_active_for(pane_id) {
return true;
}
view_state.pending_scroll.remove(&pane_id);
view_state.scrollback_cache.invalidate(pane_id);
if let Some(buffer) = view_state.pane_buffers.get_mut(&pane_id) {
buffer.scrollback_window = None;
}
let Some((inner_w, inner_h)) = attach_pane_inner_size(view_state, pane_id) else {
return false;
};
let Some(buffer) = attach_pane_buffer_mut(view_state, pane_id) else {
return false;
};
let cursor = buffer.terminal_grid.grid().cursor();
view_state.pane_scrollback.insert(
pane_id,
PaneScrollbackView {
captured_selection: None,
offset: 0,
cursor: AttachScrollbackCursor {
row: cursor.row.min(inner_h.saturating_sub(1)),
col: cursor.col.min(inner_w.saturating_sub(1)),
},
selection_anchor: None,
pin: None,
},
);
true
}
fn enter_manual_copy_mode(view_state: &mut AttachViewState) -> bool {
let Some(pane) = view_state.focused_pane_id() else {
return false;
};
if view_state.scrollback_active_for(pane) {
return true;
}
let alternate = view_state.pane_buffers.get(&pane).is_some_and(|buffer| {
buffer.terminal_grid.grid().mode() == bmux_terminal_grid::GridMode::Alternate
});
if !enter_attach_scrollback_for(view_state, pane) {
return false;
}
if alternate {
#[cfg(any(
feature = "image-sixel",
feature = "image-kitty",
feature = "image-iterm2"
))]
let images = view_state
.pane_images
.get(&pane)
.cloned()
.unwrap_or_default();
#[cfg(not(any(
feature = "image-sixel",
feature = "image-kitty",
feature = "image-iterm2"
)))]
let images = Vec::new();
let buffer = view_state
.pane_buffers
.get_mut(&pane)
.expect("entered pane exists");
let grid = buffer.terminal_grid.grid();
buffer.retained_screen_copy = true;
buffer.scrollback_window = Some(PaneScrollbackWindow {
images,
projection_width: grid.width(),
row_anchors: Vec::new(),
palette: grid.palette().clone(),
scrollback_offset: 0,
max_scrollback_offset: 0,
total_scrolled_rows: grid.total_scrolled_rows(),
rows: grid.viewport_rows(),
});
view_state.captured_alternate.insert(pane);
}
true
}
#[cfg(test)]
pub fn enter_attach_scrollback(view_state: &mut AttachViewState) -> bool {
let Some(pane_id) = view_state.focused_pane_id() else {
return false;
};
enter_attach_scrollback_for(view_state, pane_id)
}
pub fn begin_attach_selection_for(view_state: &mut AttachViewState, pane_id: Uuid) -> bool {
let Some(anchor) = attach_scrollback_cursor_position(view_state, pane_id) else {
return false;
};
if let Some(view) = view_state.scrollback_for_mut(pane_id) {
view.captured_selection = None;
view.selection_anchor = Some(anchor);
return true;
}
false
}
fn attach_scrollback_viewport_base(
view_state: &AttachViewState,
pane_id: Uuid,
) -> Option<ScrollbackViewportBase> {
let view = view_state.scrollback_for(pane_id);
let buffer = view_state.pane_buffers.get(&pane_id)?;
Some(buffer.scrollback_viewport_base(view.as_ref()))
}
fn attach_scrollback_cursor_position(
view_state: &AttachViewState,
pane_id: Uuid,
) -> Option<AttachScrollbackPosition> {
let base = attach_scrollback_viewport_base(view_state, pane_id)?;
let view = view_state.scrollback_for(pane_id)?;
Some(view.cursor_position(base))
}
pub fn begin_attach_selection(view_state: &mut AttachViewState) -> bool {
let Some(pane_id) = view_state.focused_pane_id() else {
return false;
};
begin_attach_selection_for(view_state, pane_id)
}
pub fn clear_attach_selection_at(
view_state: &mut AttachViewState,
show_status: bool,
now: Instant,
) {
if let Some(view) = view_state.focused_scrollback_mut() {
view.captured_selection = None;
view.selection_anchor = None;
}
if show_status {
view_state.set_transient_status(
ATTACH_SELECTION_CLEARED_STATUS,
now,
ATTACH_TRANSIENT_STATUS_TTL,
);
}
}
pub fn attach_selection_bounds(
view_state: &AttachViewState,
) -> Option<(AttachScrollbackPosition, AttachScrollbackPosition)> {
let pane_id = view_state.focused_pane_id()?;
let view = view_state.scrollback_for(pane_id)?;
view_state
.pane_buffers
.get(&pane_id)?
.scrollback_selection_bounds(&view)
}
fn prepare_attach_selections_for_resize(view_state: &mut AttachViewState) {
for (pane_id, view) in &mut view_state.pane_scrollback {
if view.pin.is_some() {
if let Some(window) = view_state
.pane_buffers
.get(pane_id)
.and_then(|buffer| buffer.scrollback_window.as_ref())
{
window.remap_view_from(window, view);
}
} else {
view.captured_selection = None;
view.selection_anchor = None;
}
}
}
pub fn step_attach_scrollback_for(view_state: &mut AttachViewState, pane_id: Uuid, delta: isize) {
if !view_state.scrollback_active_for(pane_id) {
return;
}
let max_offset = max_attach_scrollback_for(view_state, pane_id);
if let Some(offset) = view_state.requested_scroll_offset(pane_id) {
view_state.request_scroll_offset(
pane_id,
adjust_attach_scrollback_offset(offset, delta, max_offset),
);
}
}
pub fn step_attach_scrollback(view_state: &mut AttachViewState, delta: isize) {
let Some(pane_id) = view_state.focused_pane_id() else {
return;
};
step_attach_scrollback_for(view_state, pane_id, delta);
}
pub fn move_attach_scrollback_cursor_horizontal(view_state: &mut AttachViewState, delta: isize) {
let Some(pane_id) = view_state.focused_pane_id() else {
return;
};
if !view_state.scrollback_active_for(pane_id) {
return;
}
let Some((inner_w, _)) = attach_pane_inner_size(view_state, pane_id) else {
return;
};
let Some(view) = view_state.scrollback_for_mut(pane_id) else {
return;
};
view.cursor.col =
adjust_scrollback_cursor_component(view.cursor.col, delta, inner_w.saturating_sub(1));
}
pub fn move_attach_scrollback_cursor_vertical(view_state: &mut AttachViewState, delta: isize) {
let Some(pane_id) = view_state.focused_pane_id() else {
return;
};
if !view_state.scrollback_active_for(pane_id) || delta == 0 {
return;
}
let Some((_, inner_h)) = attach_pane_inner_size(view_state, pane_id) else {
return;
};
let max_offset = max_attach_scrollback_for(view_state, pane_id);
let Some(view) = view_state.scrollback_for_mut(pane_id) else {
return;
};
if delta < 0 {
for _ in 0..delta.unsigned_abs() {
if view.cursor.row > 0 {
view.cursor.row -= 1;
} else if view.offset < max_offset {
view.offset += 1;
}
}
} else {
for _ in 0..(delta.cast_unsigned()) {
if view.cursor.row + 1 < inner_h {
view.cursor.row += 1;
} else if view.offset > 0 {
view.offset -= 1;
}
}
}
clamp_attach_scrollback_cursor_for(view_state, pane_id);
}
pub fn adjust_scrollback_cursor_component(current: usize, delta: isize, max_value: usize) -> usize {
if delta < 0 {
current.saturating_sub(delta.unsigned_abs())
} else {
current.saturating_add(delta.cast_unsigned()).min(max_value)
}
}
pub fn copy_attach_selection_at(
view_state: &mut AttachViewState,
exit_after_copy: bool,
now: Instant,
) {
let Some(text) = selected_attach_text(view_state) else {
if exit_after_copy {
view_state.exit_focused_scrollback();
} else {
view_state.set_transient_status(
ATTACH_SELECTION_EMPTY_STATUS,
now,
ATTACH_TRANSIENT_STATUS_TTL,
);
}
return;
};
match copy_text_with_clipboard_plugin(&text) {
Ok(()) => {
view_state.set_transient_status(
ATTACH_SELECTION_COPIED_STATUS,
now,
ATTACH_TRANSIENT_STATUS_TTL,
);
if exit_after_copy {
view_state.exit_focused_scrollback();
}
}
Err(error) => {
view_state.set_transient_status(
format_clipboard_service_error(&error),
now,
ATTACH_TRANSIENT_STATUS_TTL,
);
}
}
}
pub fn confirm_attach_scrollback_at(view_state: &mut AttachViewState, now: Instant) {
copy_attach_selection_at(view_state, true, now);
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ClipboardWriteRequest {
text: String,
}
pub fn copy_text_with_clipboard_plugin(text: &str) -> Result<()> {
let config = BmuxConfig::load()?;
let paths = ConfigPaths::default();
let registry = scan_available_plugins(&config, &paths)?;
let services = available_service_descriptors(&config, ®istry)?;
let capability = HostScope::new("bmux.clipboard.write")?;
let service = services
.into_iter()
.find(|entry| {
entry.capability == capability
&& entry.kind == ServiceKind::Command
&& entry.interface_id == "clipboard-write/v1"
})
.context("clipboard service unavailable; ensure a provider is enabled and discoverable")?;
let provider_plugin_id = match &service.provider {
bmux_plugin_sdk::ProviderId::Plugin(plugin_id) => plugin_id,
bmux_plugin_sdk::ProviderId::Host => {
anyhow::bail!("clipboard service provider must be plugin-owned")
}
};
let provider = registry.get(provider_plugin_id).with_context(|| {
format!("clipboard service provider '{provider_plugin_id}' was not found")
})?;
let payload = bmux_plugin_sdk::encode_service_message(&ClipboardWriteRequest {
text: text.to_string(),
})?;
let enabled_plugins = effective_enabled_plugins(&config, ®istry);
let available_capabilities = available_capability_providers(&config, ®istry)?
.into_keys()
.map(|entry| entry.to_string())
.collect::<Vec<_>>();
let plugin_search_roots = resolve_plugin_search_paths(&config, &paths)?
.into_iter()
.map(|path| path.to_string_lossy().into_owned())
.collect::<Vec<_>>();
let loaded = load_plugin(
provider,
&plugin_host_metadata(),
&available_capability_providers(&config, ®istry)?,
&config,
)
.with_context(|| format!("failed loading clipboard service provider '{provider_plugin_id}'"))?;
let connection = bmux_plugin_sdk::HostConnectionInfo {
config_dir: paths.config_dir.to_string_lossy().into_owned(),
config_dir_candidates: paths
.config_dir_candidates()
.iter()
.map(|p| p.to_string_lossy().into_owned())
.collect(),
runtime_dir: paths.runtime_dir.to_string_lossy().into_owned(),
data_dir: paths.data_dir.to_string_lossy().into_owned(),
state_dir: paths.state_dir.to_string_lossy().into_owned(),
};
let _host_kernel_connection_guard = enter_host_kernel_connection(connection.clone());
let response = loaded.invoke_service(&bmux_plugin_sdk::NativeServiceContext {
plugin_id: provider_plugin_id.clone(),
request: ServiceRequest {
caller_plugin_id: "bmux.core".to_string(),
service,
operation: "copy_text".to_string(),
payload,
},
required_capabilities: provider
.declaration
.required_capabilities
.iter()
.map(ToString::to_string)
.collect(),
provided_capabilities: provider
.declaration
.provided_capabilities
.iter()
.map(ToString::to_string)
.collect(),
services: available_service_descriptors(&config, ®istry)?,
available_capabilities,
enabled_plugins,
plugin_search_roots,
host: plugin_host_metadata(),
connection,
settings: None,
plugin_settings_map: std::collections::BTreeMap::new(),
caller_client_id: None,
cancellation: bmux_plugin_sdk::CancellationToken::default(),
host_kernel_bridge: Some(bmux_plugin_sdk::HostKernelBridge::from_fn(
host_kernel_bridge,
)),
})?;
if let Some(error) = response.error {
anyhow::bail!(error.message);
}
let _: () = bmux_plugin_sdk::decode_service_message(&response.payload)
.context("failed decoding clipboard service response payload")?;
Ok(())
}
pub fn format_clipboard_service_error(error: &anyhow::Error) -> String {
let message = error.to_string();
if message.contains("clipboard backend unavailable") {
return "clipboard backend unavailable".to_string();
}
if message.starts_with("clipboard copy failed:") {
return message;
}
format!("clipboard copy failed: {message}")
}
pub fn selected_attach_text(view_state: &mut AttachViewState) -> Option<String> {
let (start, end) = attach_selection_bounds(view_state)?;
extract_attach_text(view_state, start, end)
}
pub fn extract_attach_text(
view_state: &mut AttachViewState,
start: AttachScrollbackPosition,
end: AttachScrollbackPosition,
) -> Option<String> {
let pane_id = view_state.focused_pane_id()?;
let (_, inner_h) = attach_pane_inner_size(view_state, pane_id)?;
let view = view_state.scrollback_for(pane_id);
let buffer = focused_attach_pane_buffer(view_state)?;
let (rows, base) = buffer.scrollback_render_window(view.as_ref(), inner_h);
let width = buffer.terminal_grid.grid().width();
if width == 0 || rows.is_empty() {
return Some(String::new());
}
let height = rows.len();
let first_line = start.line.max(base.top_line());
let last_line = end
.line
.min(base.line_for_viewport_row(height.saturating_sub(1)));
if last_line < first_line {
return Some(String::new());
}
let mut lines = Vec::new();
let mut logical_line = String::new();
for line in first_line..=last_line {
let Some(row_index) = base.viewport_row_for_line(line, height) else {
continue;
};
let Some(row) = rows.get(row_index) else {
continue;
};
let start_col = if line == start.line { start.col } else { 0 };
let end_col = if line == end.line {
end.col.saturating_add(1)
} else {
width
};
logical_line.push_str(&grid_row_text_range(row, width, start_col, end_col));
let continues_on_next_row = row.wrapped() && line < last_line;
if !continues_on_next_row {
lines.push(std::mem::take(&mut logical_line).trim_end().to_string());
}
}
Some(lines.join("\n"))
}
fn grid_row_text_range(
row: &bmux_terminal_grid::PhysicalRow,
width: usize,
start_col: usize,
end_col: usize,
) -> String {
let mut text = String::new();
let start = start_col.min(width);
let end = end_col.min(width);
for col in start..end {
let Some(cell) = row.cells().get(col) else {
text.push(' ');
continue;
};
if !cell.is_wide_continuation() {
text.push_str(cell.text());
}
}
text
}
pub fn adjust_attach_scrollback_offset(current: usize, delta: isize, max_offset: usize) -> usize {
if delta < 0 {
current.saturating_add(delta.unsigned_abs()).min(max_offset)
} else {
current.saturating_sub(delta.cast_unsigned())
}
}
pub fn max_attach_scrollback_for(view_state: &mut AttachViewState, pane_id: Uuid) -> usize {
if view_state.captured_alternate.contains(&pane_id) {
return 0;
}
if let Some(max_offset) = view_state
.scrollback_for(pane_id)
.and_then(|view| view.pin.map(|pin| pin.max_scrollback_offset))
{
return max_offset;
}
let Some(buffer) = attach_pane_buffer_mut(view_state, pane_id) else {
return 0;
};
let local = buffer
.terminal_grid
.grid()
.main_row_count()
.saturating_sub(buffer.terminal_grid.grid().height());
buffer
.scrollback_window
.as_ref()
.map_or(local, |window| local.max(window.max_scrollback_offset))
}
pub fn max_attach_scrollback(view_state: &mut AttachViewState) -> usize {
let Some(pane_id) = view_state.focused_pane_id() else {
return 0;
};
max_attach_scrollback_for(view_state, pane_id)
}
pub fn clamp_attach_scrollback_cursor_for(view_state: &mut AttachViewState, pane_id: Uuid) {
let Some((inner_w, inner_h)) = attach_pane_inner_size(view_state, pane_id) else {
return;
};
let Some(view) = view_state.scrollback_for_mut(pane_id) else {
return;
};
view.cursor.row = view.cursor.row.min(inner_h.saturating_sub(1));
view.cursor.col = view.cursor.col.min(inner_w.saturating_sub(1));
}
pub fn clamp_attach_scrollback_cursor(view_state: &mut AttachViewState) {
let Some(pane_id) = view_state.focused_pane_id() else {
return;
};
clamp_attach_scrollback_cursor_for(view_state, pane_id);
}
pub fn attach_scrollback_page_size(view_state: &AttachViewState) -> usize {
focused_attach_pane_inner_size(view_state).map_or(10, |(_, inner_h)| inner_h)
}
pub fn focused_attach_pane_buffer(
view_state: &mut AttachViewState,
) -> Option<&mut attach::state::PaneRenderBuffer> {
let focused_pane_id = focused_attach_pane_id(view_state)?;
view_state.pane_buffers.get_mut(&focused_pane_id)
}
pub fn focused_attach_pane_id(view_state: &AttachViewState) -> Option<Uuid> {
Some(view_state.cached_layout_state.as_ref()?.focused_pane_id)
}
pub fn focused_attach_pane_inner_size(view_state: &AttachViewState) -> Option<(usize, usize)> {
let layout_state = view_state.cached_layout_state.as_ref()?;
layout_state
.scene
.surfaces
.iter()
.find(|surface| surface.visible && surface.pane_id == Some(layout_state.focused_pane_id))
.map(|surface| {
(
usize::from(surface.content_rect.w.max(1)),
usize::from(surface.content_rect.h.max(1)),
)
})
}
fn windows_close_active_pane_action() -> RuntimeAction {
RuntimeAction::PluginCommand {
plugin_id: "bmux.tabs".to_string(),
command_name: "close-active-pane".to_string(),
args: Vec::new(),
}
}
fn is_windows_close_active_pane_command(plugin_id: &str, command_name: &str) -> bool {
plugin_id == "bmux.tabs" && command_name == "close-active-pane"
}
fn is_windows_close_active_pane_action(action: &RuntimeAction) -> bool {
matches!(
action,
RuntimeAction::PluginCommand {
plugin_id,
command_name,
..
} if is_windows_close_active_pane_command(plugin_id, command_name)
)
}
fn authoritative_window_list_snapshot(
entries: Vec<tabs_state::TabEntry>,
revision: u64,
) -> bmux_tabs_plugin_api::tabs_list::TabListSnapshot {
let windows = entries
.into_iter()
.filter_map(|entry| {
Some(bmux_tabs_plugin_api::tabs_list::TabListEntry {
id: Uuid::parse_str(&entry.id).ok()?,
name: entry.name,
active: entry.active,
workspace: entry.workspace,
workspace_id: entry.workspace_id,
})
})
.collect();
bmux_tabs_plugin_api::tabs_list::TabListSnapshot {
tabs: windows,
revision,
}
}
fn prefer_nonempty_window_list_snapshot(
current: &bmux_tabs_plugin_api::tabs_list::TabListSnapshot,
candidate: bmux_tabs_plugin_api::tabs_list::TabListSnapshot,
) -> bmux_tabs_plugin_api::tabs_list::TabListSnapshot {
if candidate.tabs.is_empty() && !current.tabs.is_empty() && current.revision == 0 {
bmux_tabs_plugin_api::tabs_list::TabListSnapshot {
tabs: current.tabs.clone(),
revision: candidate.revision,
}
} else {
candidate
}
}
fn snapshot_mode_id(view_state: &AttachViewState, zoomed: bool) -> String {
if view_state.help_overlay_open {
"help".to_string()
} else if view_state.prompt.is_active() {
"prompt".to_string()
} else if view_state.scrollback_active() {
"scroll".to_string()
} else if zoomed {
"zoom".to_string()
} else {
view_state.active_mode_id.clone()
}
}
fn build_attach_local_presentation_snapshot(
view_state: &AttachViewState,
keymap: &Keymap,
follow_target_id: Option<Uuid>,
follow_global: bool,
geometry: TerminalGeometry,
runtime_appearance: &RuntimeAppearance,
) -> AttachLocalPresentationSnapshot {
let zoomed = view_state
.cached_layout_state
.as_ref()
.is_some_and(|state| state.zoomed);
let scrollback_active = view_state.scrollback_active();
let mode_label = if view_state.help_overlay_open {
"HELP".to_string()
} else if view_state.prompt.is_active() {
"PROMPT".to_string()
} else if scrollback_active {
"SCROLL".to_string()
} else if zoomed {
"ZOOM".to_string()
} else {
view_state.active_mode_id.to_ascii_uppercase()
};
let (session_label, session_count) = resolve_attach_session_label_and_count_from_catalog(
&view_state.cached_sessions,
view_state.attached_id,
);
let context_label = resolve_attach_context_label_from_catalog(
&view_state.cached_contexts,
&view_state.cached_context_session_bindings,
view_state.attached_context_id,
view_state.attached_id,
);
let follow_label = follow_target_id.map(|id| {
if follow_global {
format!("following {} (global)", short_uuid(id))
} else {
format!("following {}", short_uuid(id))
}
});
let mode_modifier = scrollback_active
.then(|| view_state.focused_scrollback())
.flatten()
.and_then(|view| view.pin.map(|_| "FROZEN".to_string()));
let hint = view_state.transient_status.clone().unwrap_or_else(|| {
let help =
key_hint_or_unbound(keymap, &view_state.active_mode_id, &RuntimeAction::ShowHelp);
let detach =
key_hint_or_unbound(keymap, &view_state.active_mode_id, &RuntimeAction::Detach);
format!("{help} help | {detach} detach")
});
let resolved_appearance = runtime_appearance.for_mode(&snapshot_mode_id(view_state, zoomed));
AttachLocalPresentationSnapshot {
revision: 0,
mode_id: view_state.active_mode_id.clone(),
mode_label,
role_label: if view_state.can_write {
"write".to_string()
} else {
"read-only".to_string()
},
follow_label,
mode_modifier,
hint,
session_label: Some(session_label),
session_count: u32::try_from(session_count).unwrap_or(u32::MAX),
context_label: Some(context_label),
foreground: resolved_appearance.foreground,
background: resolved_appearance.background,
status_background: resolved_appearance.status.background,
status_foreground: resolved_appearance.status.foreground,
status_active: resolved_appearance.status.active_window,
status_mode: resolved_appearance.status.mode_indicator,
double_click_ms: view_state.mouse.config.double_click_ms,
viewport_cols: geometry.cols,
viewport_rows: geometry.rows,
}
}
fn publish_attach_local_presentation(
view_state: &mut AttachViewState,
keymap: &Keymap,
follow_target_id: Option<Uuid>,
follow_global: bool,
geometry: TerminalGeometry,
runtime_appearance: &RuntimeAppearance,
) {
let semantic_snapshot = build_attach_local_presentation_snapshot(
view_state,
keymap,
follow_target_id,
follow_global,
geometry,
runtime_appearance,
);
if view_state
.local_presentation
.as_ref()
.is_some_and(|previous| {
let mut previous = previous.clone();
previous.revision = 0;
previous == semantic_snapshot
})
{
return;
}
let mut snapshot = semantic_snapshot;
snapshot.revision = view_state
.local_presentation
.as_ref()
.map_or(1, |previous| previous.revision.saturating_add(1).max(1));
view_state.local_presentation = Some(snapshot.clone());
let _ = view_state
.presentation_events
.publish_state(&ATTACH_LOCAL_PRESENTATION_STATE_KIND, snapshot);
}
pub const fn attach_exit_message(reason: AttachExitReason) -> Option<&'static str> {
match reason {
AttachExitReason::Detached | AttachExitReason::Quit => None,
AttachExitReason::StreamClosed => Some("attach ended unexpectedly: server stream closed"),
}
}
pub const fn attach_exit_reason_label(reason: AttachExitReason) -> &'static str {
match reason {
AttachExitReason::Detached => "detached",
AttachExitReason::StreamClosed => "stream_closed",
AttachExitReason::Quit => "quit",
}
}
pub fn key_hint_or_unbound(keymap: &Keymap, mode_id: &str, action: &RuntimeAction) -> String {
keymap
.primary_binding_for_action_in_mode(mode_id, action)
.unwrap_or_else(|| "unbound".to_string())
}
pub fn sync_attach_active_mode_from_processor(
view_state: &mut AttachViewState,
keymap: &Keymap,
processor_mode_id: Option<&str>,
) {
let mode_id = processor_mode_id
.or_else(|| keymap.initial_mode_id())
.unwrap_or("normal")
.to_string();
let mode_changed = view_state.active_mode_id != mode_id;
view_state.active_mode_id = mode_id;
if mode_changed {
view_state
.dirty
.mark_full_frame(AttachDirtySource::ActionDispatch);
}
}
pub fn apply_attach_profile_switch(
profile_id: &str,
attach_input_processor: &mut InputProcessor,
view_state: &mut AttachViewState,
) -> Result<()> {
let config_path = ConfigPaths::default().config_file();
apply_attach_profile_switch_with_path(
profile_id,
attach_input_processor,
view_state,
&config_path,
)
}
fn apply_attach_profile_switch_with_path(
profile_id: &str,
attach_input_processor: &mut InputProcessor,
view_state: &mut AttachViewState,
config_path: &std::path::Path,
) -> Result<()> {
let previous_config_source = if config_path.exists() {
Some(
std::fs::read_to_string(config_path)
.with_context(|| format!("failed reading {}", config_path.display()))?,
)
} else {
None
};
let previous_keymap = attach_input_processor.keymap().clone();
let previous_mouse_config = view_state.mouse.config.clone();
if let Err(error) =
super::super::run_config_profiles_set_active_at_path(profile_id, config_path)
{
return Err(error.context("failed updating composition.active_profile"));
}
let result = (|| -> Result<()> {
let (resolved_config, resolution) =
bmux_config::BmuxConfig::load_from_path_with_resolution(config_path, Some(profile_id))
.map_err(|error| anyhow::anyhow!("{error}"))?;
let keymap = attach_keymap_from_config(&resolved_config);
attach_input_processor.replace_keymap(keymap);
attach_input_processor.set_scroll_mode(view_state.scrollback_active());
view_state.mouse.config = resolved_config.attach_mouse_config();
view_state.mouse.clear_pointer_gestures();
sync_attach_active_mode_from_processor(
view_state,
attach_input_processor.keymap(),
attach_input_processor.active_mode_id(),
);
view_state.set_transient_status(
format!(
"active profile: {}",
resolution
.selected_profile
.unwrap_or_else(|| profile_id.to_ascii_lowercase())
),
Instant::now(),
ATTACH_TRANSIENT_STATUS_TTL,
);
Ok(())
})();
if let Err(error) = result {
match previous_config_source {
Some(source) => {
let _ = std::fs::write(config_path, source);
}
None => {
let _ = std::fs::remove_file(config_path);
}
}
attach_input_processor.replace_keymap(previous_keymap);
attach_input_processor.set_scroll_mode(view_state.scrollback_active());
view_state.mouse.config = previous_mouse_config;
sync_attach_active_mode_from_processor(
view_state,
attach_input_processor.keymap(),
attach_input_processor.active_mode_id(),
);
return Err(error.context("rolled back profile switch"));
}
Ok(())
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
struct AttachViewportInsets {
top: u16,
right: u16,
bottom: u16,
left: u16,
}
#[cfg(test)]
use bmux_plugin::layout::global_plugin_layout_registry;
fn resolved_attach_viewport_insets(
geometry: TerminalGeometry,
layouts: &bmux_plugin::layout::PluginLayoutRegistry,
) -> AttachViewportInsets {
let requests = layouts.requests();
resolved_attach_viewport_insets_for_requests(geometry, &requests)
}
fn resolved_attach_viewport_insets_for_requests(
geometry: TerminalGeometry,
requests: &[PluginLayoutRequest],
) -> AttachViewportInsets {
let viewport = ExtensionRect::new(0, 0, geometry.cols, geometry.rows);
let remaining = resolve_plugin_layout(viewport, (1, 1), requests)
.map_or(viewport, |resolution| resolution.remaining);
let remaining_right = remaining.x.saturating_add(remaining.w);
let remaining_bottom = remaining.y.saturating_add(remaining.h);
AttachViewportInsets {
top: remaining.y,
right: geometry.cols.saturating_sub(remaining_right),
bottom: geometry.rows.saturating_sub(remaining_bottom),
left: remaining.x,
}
}
pub fn help_overlay_visible_rows(lines: &[String], geometry: TerminalGeometry) -> usize {
let max_content_rows = (geometry.rows as usize).saturating_sub(6);
let content_rows = lines.len().min(max_content_rows);
let height = (content_rows + 4).min((geometry.rows as usize).saturating_sub(2));
height.saturating_sub(4).max(1)
}
pub fn adjust_help_overlay_scroll(
current: usize,
delta: isize,
total_lines: usize,
visible_rows: usize,
) -> usize {
if total_lines == 0 {
return 0;
}
let max_scroll = total_lines.saturating_sub(visible_rows.max(1));
let next = if delta.is_negative() {
current.saturating_sub(delta.unsigned_abs())
} else {
current.saturating_add(delta.cast_unsigned())
};
next.min(max_scroll)
}
pub const fn help_overlay_accepts_key_kind(kind: KeyEventKind) -> bool {
matches!(kind, KeyEventKind::Press | KeyEventKind::Repeat)
}
pub fn handle_help_overlay_terminal_key_event(
key: &TerminalKeyEvent,
help_lines: &[String],
view_state: &mut AttachViewState,
geometry: TerminalGeometry,
) -> bool {
key.to_crossterm()
.is_some_and(|key| handle_help_overlay_key_event(&key, help_lines, view_state, geometry))
}
pub fn handle_help_overlay_key_event(
key: &KeyEvent,
help_lines: &[String],
view_state: &mut AttachViewState,
geometry: TerminalGeometry,
) -> bool {
if !help_overlay_accepts_key_kind(key.kind) {
return false;
}
match key.code {
KeyCode::Esc | KeyCode::Enter => {
view_state.help_overlay_open = false;
view_state.help_overlay_scroll = 0;
view_state
.dirty
.mark_overlay_dirty(AttachDirtySource::HelpOverlay);
true
}
KeyCode::Up | KeyCode::Char('k') => {
view_state.help_overlay_scroll = adjust_help_overlay_scroll(
view_state.help_overlay_scroll,
-1,
help_lines.len(),
help_overlay_visible_rows(help_lines, geometry),
);
view_state
.dirty
.mark_overlay_dirty(AttachDirtySource::HelpOverlay);
true
}
KeyCode::Down | KeyCode::Char('j') => {
view_state.help_overlay_scroll = adjust_help_overlay_scroll(
view_state.help_overlay_scroll,
1,
help_lines.len(),
help_overlay_visible_rows(help_lines, geometry),
);
view_state
.dirty
.mark_overlay_dirty(AttachDirtySource::HelpOverlay);
true
}
KeyCode::PageUp => {
let page = help_overlay_visible_rows(help_lines, geometry).cast_signed();
view_state.help_overlay_scroll = adjust_help_overlay_scroll(
view_state.help_overlay_scroll,
-page,
help_lines.len(),
help_overlay_visible_rows(help_lines, geometry),
);
view_state
.dirty
.mark_overlay_dirty(AttachDirtySource::HelpOverlay);
true
}
KeyCode::PageDown => {
let page = help_overlay_visible_rows(help_lines, geometry).cast_signed();
view_state.help_overlay_scroll = adjust_help_overlay_scroll(
view_state.help_overlay_scroll,
page,
help_lines.len(),
help_overlay_visible_rows(help_lines, geometry),
);
view_state
.dirty
.mark_overlay_dirty(AttachDirtySource::HelpOverlay);
true
}
KeyCode::Home => {
view_state.help_overlay_scroll = 0;
view_state
.dirty
.mark_overlay_dirty(AttachDirtySource::HelpOverlay);
true
}
KeyCode::End => {
let visible = help_overlay_visible_rows(help_lines, geometry);
view_state.help_overlay_scroll = help_lines.len().saturating_sub(visible);
view_state
.dirty
.mark_overlay_dirty(AttachDirtySource::HelpOverlay);
true
}
_ => false,
}
}
#[allow(clippy::cast_possible_truncation)] pub fn help_overlay_surface(lines: &[String], geometry: TerminalGeometry) -> Option<AttachSurface> {
if geometry.cols < 20 || geometry.rows < 6 {
return None;
}
let content_width = lines
.iter()
.map(std::string::String::len)
.max()
.unwrap_or(0)
.min(80);
let width = (content_width + 4)
.max(36)
.min((geometry.cols as usize).saturating_sub(2));
let max_content_rows = (geometry.rows as usize).saturating_sub(6);
let content_rows = lines.len().min(max_content_rows);
let height = (content_rows + 4).min((geometry.rows as usize).saturating_sub(2));
let x = ((geometry.cols as usize).saturating_sub(width)) / 2;
let y = ((geometry.rows as usize).saturating_sub(height)) / 2;
Some(AttachSurface {
id: HELP_OVERLAY_SURFACE_ID,
kind: AttachSurfaceKind::Overlay,
layer: SurfaceLayer::Overlay,
z: i32::MAX,
rect: AttachRect {
x: x as u16,
y: y as u16,
w: width as u16,
h: height as u16,
},
content_rect: AttachRect {
x: x as u16,
y: y as u16,
w: width as u16,
h: height as u16,
},
interactive_regions: Vec::new(),
opaque: true,
visible: true,
accepts_input: true,
cursor_owner: false,
pane_id: None,
})
}
#[allow(clippy::cast_possible_truncation)] fn help_overlay_render_ops(
surface_meta: &AttachSurface,
lines: &[String],
scroll: usize,
appearance: &RuntimeAppearance,
) -> Vec<RenderOp> {
let area = TuiRect::new(
surface_meta.rect.x,
surface_meta.rect.y,
surface_meta.rect.w,
surface_meta.rect.h,
);
let component_theme = component_theme(appearance);
let modal_theme = component_theme.modal_theme();
let modal = ModalFrame::new(
ModalSizing::fixed(
TuiSize::new(surface_meta.rect.w, surface_meta.rect.h),
TuiInsets::new(0, 0, 0, 0),
),
modal_theme,
)
.title(TuiLine::raw("bmux help"))
.padding(TuiInsets::new(0, 1, 0, 1));
let mut buffer = surface_buffer(area);
let mut frame = TuiFrame::new(&mut buffer);
paint_help_overlay(
&mut PaintCx::new(&mut frame),
area,
&modal,
lines,
scroll,
component_theme,
);
buffer_render_ops(&buffer)
}
fn paint_help_overlay(
cx: &mut PaintCx<'_, '_>,
area: TuiRect,
modal: &ModalFrame,
lines: &[String],
scroll: usize,
component_theme: ComponentTheme,
) {
let modal_component =
ModalFrameComponent::new("attach.help", modal.clone(), TextBlock::new(""));
let modal_layout =
modal_component.layout(Constraints::tight(area.size()), &mut LayoutCx::new());
cx.with_child(
i32::from(area.x),
i64::from(area.y),
LocalRect::new(0, 0, area.width, area.height),
|cx| modal_component.paint(&modal_layout, cx),
);
let content = modal.content_area(area);
let hints_height = u16::from(content.height > 2);
let body_area = TuiRect::new(
content.x,
content.y,
content.width,
content.height.saturating_sub(hints_height),
);
let text = bmux_tui::prelude::Text::from_lines(
lines
.iter()
.map(|line| TuiLine::raw(line.clone()))
.collect::<Vec<_>>(),
);
let body = TextBlock::new(text)
.id("attach.help.body")
.style(component_theme.text)
.vertical_scroll(scroll);
let body_layout = body.layout(Constraints::tight(body_area.size()), &mut LayoutCx::new());
cx.with_child(
i32::from(body_area.x),
i64::from(body_area.y),
LocalRect::new(0, 0, body_area.width, body_area.height),
|cx| body.paint(&body_layout, cx),
);
if hints_height > 0 {
let hints = [
KeyHint::new("↑↓", "scroll"),
KeyHint::new("PgUp/PgDn", "page"),
KeyHint::new("Esc", "close"),
];
let hints_area = TuiRect::new(
content.x,
content.bottom().saturating_sub(1),
content.width,
1,
);
let hints =
KeyHintBarComponent::new("attach.help.hints", &hints).styles(KeyHintBarStyles {
key: component_theme.focused,
label: component_theme.muted,
separator: component_theme.muted,
disabled: component_theme.disabled,
background: component_theme.surfaces.overlay,
});
let hints_layout =
hints.layout(Constraints::tight(hints_area.size()), &mut LayoutCx::new());
cx.with_child(
i32::from(hints_area.x),
i64::from(hints_area.y),
LocalRect::new(0, 0, hints_area.width, hints_area.height),
|cx| hints.paint(&hints_layout, cx),
);
}
}
const fn attach_surface_damage_rect(surface: &AttachSurface) -> DamageRect {
DamageRect::new(
surface.rect.x,
surface.rect.y,
surface.rect.w,
surface.rect.h,
)
}
fn retained_surface_from_attach_surface(
surface: &AttachSurface,
opacity: RetainedOpacity,
ops: Vec<RenderOp>,
) -> RetainedSurface {
RetainedSurface::builder(surface.id, attach_surface_damage_rect(surface))
.layer(retained_layer_order(surface.layer))
.z(surface.z)
.opacity(opacity)
.render_ops(ops)
.build()
}
fn retained_help_overlay_surface(
surface: &AttachSurface,
lines: &[String],
scroll: usize,
appearance: &RuntimeAppearance,
) -> RetainedSurface {
retained_surface_from_attach_surface(
surface,
RetainedOpacity::Opaque,
help_overlay_render_ops(surface, lines, scroll, appearance),
)
}
fn retained_prompt_overlay_surface(render: &AttachPromptOverlayRender) -> RetainedSurface {
retained_surface_from_attach_surface(
&render.surface,
RetainedOpacity::Opaque,
render.ops.clone(),
)
}
fn retained_damage_overlay_surface(
scene: &AttachScene,
frame_damage: &bmux_attach_pipeline::FrameDamage,
terminal_size: (u16, u16),
top_inset: u16,
bottom_inset: u16,
) -> Option<RetainedSurface> {
let ops = frame_damage_overlay_render_ops(
scene,
frame_damage,
terminal_size,
top_inset,
bottom_inset,
);
if ops.is_empty() || terminal_size.0 == 0 || terminal_size.1 == 0 {
return None;
}
Some(
RetainedSurface::builder(
DAMAGE_OVERLAY_SURFACE_ID,
DamageRect::new(0, 0, terminal_size.0, terminal_size.1),
)
.layer(retained_layer_order(SurfaceLayer::Tooltip))
.z(i32::MAX)
.transparent()
.render_ops(ops)
.build(),
)
}
fn retained_full_surface_repaint(surface: &RetainedSurface) -> RetainedRepaintSurface {
RetainedRepaintSurface {
surface_id: surface.id,
rect: surface.rect,
layer: surface.layer,
z: surface.z,
opaque: surface.opaque,
modal: surface.modal,
opacity: surface.opacity,
clip_rect: surface.clip_rect,
interactive_regions: surface.interactive_regions.clone(),
damage: vec![surface.rect],
}
}
fn render_damage_from_retained_damage_rects(rects: &[DamageRect]) -> RenderDamage {
RenderDamage::from_rects(
rects
.iter()
.map(|rect| ExtensionRect::new(rect.x, rect.y, rect.w, rect.h)),
)
}
fn queue_retained_background_clear(
stdout: &mut impl Write,
damage: &RetainedDamage,
) -> Result<bool> {
let rects = match damage {
RetainedDamage::None | RetainedDamage::Full { .. } => return Ok(false),
RetainedDamage::Regions(rects) => rects,
};
let mut wrote = false;
for rect in rects {
bmux_attach_pipeline::projection::record(
bmux_attach_pipeline::projection::PaintOperation::Fill {
rect: bmux_tui::geometry::Rect::new(rect.x, rect.y, rect.w, rect.h),
style: bmux_tui::style::Style::new(),
},
);
let blank = " ".repeat(usize::from(rect.w));
for row in rect.y..rect.y.saturating_add(rect.h) {
queue!(stdout, MoveTo(rect.x, row), Print(&blank))
.context("failed clearing retained background damage")?;
wrote = true;
}
}
Ok(wrote)
}
fn queue_retained_render_ops(
stdout: &mut impl Write,
surface: &RetainedSurface,
repaint: &RetainedRepaintSurface,
capabilities: bmux_plugin::TerminalRenderCapabilities,
) -> Result<bool> {
let RetainedSurfacePayload::RenderOps(ops) = &surface.payload else {
return Ok(false);
};
let surface_rect = ExtensionRect::new(
surface.rect.x,
surface.rect.y,
surface.rect.w,
surface.rect.h,
);
let damage = render_damage_from_retained_damage_rects(&repaint.damage);
queue_render_ops_with_capabilities(stdout, surface_rect, &damage, ops, capabilities)
.context("failed queueing retained render ops")
}
fn queue_retained_extension_ops(
stdout: &mut impl Write,
surface: &RetainedSurface,
repaint: &RetainedRepaintSurface,
extensions: &[Arc<dyn bmux_plugin::AttachRenderExtension>],
capabilities: bmux_plugin::TerminalRenderCapabilities,
graphics_cache: &mut TerminalGraphicsCache,
) -> Result<bool> {
let surface_rect = ExtensionRect::new(
surface.rect.x,
surface.rect.y,
surface.rect.w,
surface.rect.h,
);
let damage = render_damage_from_retained_damage_rects(&repaint.damage);
let context = RenderExtensionContext {
capabilities,
surface_role: bmux_plugin::RenderSurfaceRole::Overlay,
opaque_occluders: Vec::new(),
};
let mut wrote = false;
for extension in extensions {
if let Some(scene) = extension.render_layer_scene_with_context(
surface.id,
&surface_rect,
RenderExtensionLayer::AfterPaneContent,
&context,
) {
let items = plugin_scene_items_to_render_items(&scene);
wrote |= queue_render_items_for_surface(
stdout,
surface.id,
surface_rect,
&damage,
&items,
graphics_cache,
capabilities,
None,
)
.with_context(|| {
format!(
"failed queueing retained extension scene {}",
extension.name()
)
})?;
continue;
}
if let Some(items) = extension.render_layer_items_with_context(
surface.id,
&surface_rect,
&damage,
RenderExtensionLayer::AfterPaneContent,
&context,
) {
wrote |= queue_render_items_for_surface(
stdout,
surface.id,
surface_rect,
&damage,
&items,
graphics_cache,
capabilities,
None,
)
.with_context(|| {
format!(
"failed queueing retained extension items {}",
extension.name()
)
})?;
continue;
}
let Some(ops) = extension.render_layer_ops_with_context(
surface.id,
&surface_rect,
&damage,
RenderExtensionLayer::AfterPaneContent,
&context,
) else {
continue;
};
wrote |= queue_render_ops(stdout, surface_rect, &damage, &ops)
.with_context(|| format!("failed queueing retained extension {}", extension.name()))?;
}
Ok(wrote)
}
fn retained_surface_extension_chrome_owned(
surface_id: Uuid,
rect: DamageRect,
extensions: &[Arc<dyn bmux_plugin::AttachRenderExtension>],
capabilities: bmux_plugin::TerminalRenderCapabilities,
) -> bool {
let extension_rect = ExtensionRect::new(rect.x, rect.y, rect.w, rect.h);
let context = RenderExtensionContext {
capabilities,
surface_role: bmux_plugin::RenderSurfaceRole::Overlay,
opaque_occluders: Vec::new(),
};
extensions.iter().any(|extension| {
extension.surface_chrome_with_context(surface_id, &extension_rect, &context)
== bmux_plugin::RenderSurfaceChrome::Extension
})
}
fn frame_uses_synchronized_update(frame_damage: &bmux_attach_pipeline::FrameDamage) -> bool {
frame_damage.scene_damaged() || frame_damage.overlay_damaged()
}
fn terminal_render_capabilities(
view_state: &AttachViewState,
) -> bmux_plugin::TerminalRenderCapabilities {
#[cfg(any(
feature = "image-sixel",
feature = "image-kitty",
feature = "image-iterm2"
))]
{
bmux_plugin::TerminalRenderCapabilities {
kitty_graphics: view_state.host_image_caps.kitty_graphics,
sixel: view_state.host_image_caps.sixel,
iterm2_inline_images: view_state.host_image_caps.iterm2_inline,
graphics_alpha: view_state.host_image_caps.kitty_graphics
|| view_state.host_image_caps.iterm2_inline,
cell_pixel_width: view_state.host_image_caps.cell_pixel_width,
cell_pixel_height: view_state.host_image_caps.cell_pixel_height,
..bmux_plugin::TerminalRenderCapabilities::default()
}
}
#[cfg(not(any(
feature = "image-sixel",
feature = "image-kitty",
feature = "image-iterm2"
)))]
{
let _ = view_state;
bmux_plugin::TerminalRenderCapabilities::default()
}
}
struct RetainedDamagePlan {
frame: RetainedDamage,
explicit_ui: RetainedDamage,
graph: RetainedDamage,
merged: RetainedDamage,
}
impl RetainedDamagePlan {
const fn is_damaged(damage: &RetainedDamage) -> bool {
!matches!(damage, RetainedDamage::None)
}
const fn graph_damaged(&self) -> bool {
Self::is_damaged(&self.graph)
}
const fn merged_damaged(&self) -> bool {
Self::is_damaged(&self.merged)
}
const fn any_source_damaged(&self) -> bool {
Self::is_damaged(&self.frame)
|| Self::is_damaged(&self.explicit_ui)
|| Self::is_damaged(&self.graph)
}
}
struct RenderFramePlan {
retained: RetainedFramePlan,
damage_stats: FrameDamageStats,
render_scene: bool,
use_synchronized_update: bool,
}
impl RenderFramePlan {
fn from_damage(
retained: RetainedFramePlan,
frame_damage: &bmux_attach_pipeline::FrameDamage,
visualize_damage: bool,
) -> Self {
debug_assert!(
!retained.damage.merged_damaged() || retained.damage.any_source_damaged(),
"merged retained damage should preserve at least one damage source"
);
let damage_stats = frame_damage.stats();
let render_scene = retained.scene_repaint || frame_damage.extension_query_requested();
let use_synchronized_update = retained.damage.merged_damaged()
|| frame_uses_synchronized_update(frame_damage)
|| retained.graph_damaged()
|| visualize_damage;
Self {
retained,
damage_stats,
render_scene,
use_synchronized_update,
}
}
}
struct RetainedFramePlan {
help_surface: Option<RetainedSurface>,
prompt_overlay_render: Option<AttachPromptOverlayRender>,
prompt_surface: Option<RetainedSurface>,
scene_repaint: bool,
damage: RetainedDamagePlan,
repaint_plan: Vec<RetainedRepaintSurface>,
}
impl RetainedFramePlan {
const fn graph_damaged(&self) -> bool {
self.damage.graph_damaged()
}
fn repaint_by_id(&self) -> BTreeMap<Uuid, &RetainedRepaintSurface> {
self.repaint_plan
.iter()
.map(|surface| (surface.surface_id, surface))
.collect()
}
}
fn retained_plugin_surfaces(
view_state: &AttachViewState,
viewport: DamageRect,
) -> Vec<RetainedSurface> {
let plugin_layout = resolve_plugin_layout(
ExtensionRect::new(viewport.x, viewport.y, viewport.w, viewport.h),
(1, 1),
&view_state.presentation_layouts.requests(),
);
let Ok(plugin_layout) = plugin_layout else {
return Vec::new();
};
for allocation in &plugin_layout.allocations {
view_state
.presentation_allocations
.notify(&allocation.id, allocation.rect);
}
let allocations = plugin_layout
.allocations
.into_iter()
.map(|allocation| (allocation.id, allocation.rect))
.collect::<BTreeMap<_, _>>();
let surfaces = view_state.presentation_surfaces.surfaces();
retained_surfaces_from_plugin_surfaces(&surfaces, &allocations, viewport)
}
fn replace_retained_surfaces(
view_state: &mut AttachViewState,
retained_surfaces: Vec<RetainedSurface>,
viewport: DamageRect,
damage_policy: DamageCoalescingPolicy,
) -> RetainedDamage {
view_state
.retained_compositor
.replace_surfaces(retained_surfaces, viewport, damage_policy)
}
#[allow(clippy::too_many_arguments)] fn build_retained_frame_plan(
view_state: &mut AttachViewState,
layout_state: &AttachLayoutState,
frame_damage: &mut bmux_attach_pipeline::FrameDamage,
help_surface: Option<RetainedSurface>,
prompt_overlay_render: Option<AttachPromptOverlayRender>,
viewport: DamageRect,
damage_policy: DamageCoalescingPolicy,
) -> RetainedFramePlan {
let prompt_surface = prompt_overlay_render
.as_ref()
.map(retained_prompt_overlay_surface);
let frame_retained_damage = retained_frame_damage_from_frame_damage(
&layout_state.scene,
frame_damage,
viewport,
damage_policy,
);
let mut explicit_ui_damage_rects = Vec::new();
if frame_damage.overlay_damaged() || frame_damage.extension_query_requested() {
if let Some(surface) = help_surface.as_ref() {
explicit_ui_damage_rects.push(surface.rect);
}
if let Some(surface) = prompt_surface.as_ref() {
explicit_ui_damage_rects.push(surface.rect);
}
}
let explicit_ui_damage =
retained_damage_from_absolute_rects(explicit_ui_damage_rects, viewport, damage_policy);
let mut retained_surfaces = retained_surfaces_from_attach_scene(&layout_state.scene);
if view_state.dirty.retained_surfaces_need_reconcile {
let plugin_surfaces = retained_plugin_surfaces(view_state, viewport);
view_state.retained_plugin_surface_ids =
plugin_surfaces.iter().map(|surface| surface.id).collect();
retained_surfaces.extend(plugin_surfaces);
} else {
retained_surfaces.extend(
view_state
.retained_compositor
.surfaces()
.values()
.filter(|surface| view_state.retained_plugin_surface_ids.contains(&surface.id))
.cloned(),
);
}
retained_surfaces.extend(help_surface.iter().cloned());
retained_surfaces.extend(prompt_surface.iter().cloned());
let graph_damage = replace_retained_surfaces(
view_state,
retained_surfaces.clone(),
viewport,
damage_policy,
);
let opaque_overlay_rects = view_state
.retained_compositor
.content_occluders()
.into_iter()
.map(|rect| ExtensionRect::new(rect.x, rect.y, rect.w, rect.h))
.collect::<Vec<_>>();
if opaque_overlay_rects != view_state.opaque_overlay_rects {
frame_damage.mark_extension_query();
view_state.opaque_overlay_rects = opaque_overlay_rects;
}
let merged_damage = merge_retained_damages(
[
frame_retained_damage.clone(),
explicit_ui_damage.clone(),
graph_damage.clone(),
],
viewport,
damage_policy,
);
let repaint_plan = view_state.retained_compositor.repaint_plan(&merged_damage);
let scene_surface_ids = layout_state
.scene
.surfaces
.iter()
.map(|surface| surface.id)
.collect::<BTreeSet<_>>();
let scene_repaint = repaint_plan
.iter()
.any(|repaint| scene_surface_ids.contains(&repaint.surface_id));
frame_damage.merge_from(&frame_damage_from_retained_repaint_plan(
&layout_state.scene,
&repaint_plan,
damage_policy,
));
RetainedFramePlan {
help_surface,
prompt_overlay_render,
prompt_surface,
scene_repaint,
damage: RetainedDamagePlan {
frame: frame_retained_damage,
explicit_ui: explicit_ui_damage,
graph: graph_damage,
merged: merged_damage,
},
repaint_plan,
}
}
struct AttachAppearanceResolver<'a> {
fallback: &'a RuntimeAppearance,
view_state: &'a AttachViewState,
}
impl<'a> AttachAppearanceResolver<'a> {
const fn fallback(&self) -> &'a RuntimeAppearance {
self.fallback
}
fn for_pane(&self, pane_id: &Uuid) -> &'a RuntimeAppearance {
self.view_state
.runtime_appearance_for_pane(pane_id, self.fallback)
}
}
fn refresh_retained_extensions(
view: &mut AttachViewState,
layout: &AttachLayoutState,
geometry: TerminalGeometry,
help: &[String],
) -> Result<()> {
let capabilities = terminal_render_capabilities(view);
let current_overlays = [
help_overlay_surface(help, geometry).filter(|_| view.help_overlay_open),
view.prompt.overlay_surface(geometry),
]
.into_iter()
.flatten()
.collect::<Vec<_>>();
let mut preview_surfaces = retained_surfaces_from_attach_scene(&layout.scene);
preview_surfaces.extend(
view.retained_compositor
.surfaces()
.values()
.filter(|surface| view.retained_plugin_surface_ids.contains(&surface.id))
.cloned(),
);
preview_surfaces.extend(current_overlays.iter().map(|surface| {
retained_surface_from_attach_surface(surface, RetainedOpacity::Opaque, Vec::new())
}));
let mut geometry_preview = view.retained_compositor.clone();
geometry_preview.replace_surfaces(
preview_surfaces,
DamageRect::new(0, 0, geometry.cols, geometry.rows),
DamageCoalescingPolicy::default(),
);
let occluders = geometry_preview
.content_occluders()
.into_iter()
.map(|rect| ExtensionRect::new(rect.x, rect.y, rect.w, rect.h))
.collect::<Vec<_>>();
let mut surfaces = layout
.scene
.surfaces
.iter()
.filter(|surface| surface.visible)
.map(|surface| {
(
surface.id,
ExtensionRect::new(
surface.rect.x,
surface.rect.y,
surface.rect.w,
surface.rect.h,
),
RenderExtensionContext {
capabilities,
surface_role: bmux_plugin::RenderSurfaceRole::Content,
opaque_occluders: occluders.clone(),
},
)
})
.collect::<Vec<_>>();
for surface in geometry_preview.surfaces().values() {
if surfaces.iter().any(|(id, _, _)| *id == surface.id) {
continue;
}
surfaces.push((
surface.id,
ExtensionRect::new(
surface.rect.x,
surface.rect.y,
surface.rect.w,
surface.rect.h,
),
RenderExtensionContext {
capabilities,
surface_role: bmux_plugin::RenderSurfaceRole::Overlay,
opaque_occluders: Vec::new(),
},
));
}
for surface in current_overlays {
surfaces.retain(|(id, _, _)| *id != surface.id);
surfaces.push((
surface.id,
ExtensionRect::new(
surface.rect.x,
surface.rect.y,
surface.rect.w,
surface.rect.h,
),
RenderExtensionContext {
capabilities,
surface_role: bmux_plugin::RenderSurfaceRole::Overlay,
opaque_occluders: Vec::new(),
},
));
}
view.prepared_extensions = Some(super::retained_extensions::refresh(
&view.presentation_extensions.snapshot(),
&surfaces,
)?);
Ok(())
}
fn finish_presented_scroll_tracking(view: &mut AttachViewState) {
let now = Instant::now();
view.scroll_started.retain(|pane, (first, latest)| {
if view.pending_scroll.contains_key(pane) {
return true;
}
tracing::debug!(target: "attach.scrollback", pane_id = %pane,
oldest_input_to_frame_us = now.saturating_duration_since(*first).as_micros(),
latest_input_to_frame_us = now.saturating_duration_since(*latest).as_micros(),
"scrollback.frame_committed");
false
});
}
fn retained_frame_sync_pending(view: &AttachViewState, layout: &AttachLayoutState) -> bool {
(view.observer.is_some() || view.presentation_only)
&& layout.scene.surfaces.iter().any(|surface| {
surface.visible
&& surface.pane_id.is_some_and(|id| {
view.pane_buffers
.get(&id)
.is_some_and(|buffer| buffer.sync_update_in_progress)
})
})
}
fn recover_browser_rejection(view: &mut AttachViewState, error: &anyhow::Error) -> bool {
if view.observer.is_none()
|| error
.downcast_ref::<super::observer::PresentationRejected>()
.is_none()
{
return false;
}
if let Some(observer) = &mut view.observer {
observer.report_rejection(Some(&error.to_string()));
}
view.dirty.clear_frame_damage();
true
}
#[allow(clippy::too_many_arguments)] pub fn render_attach_frame_to_writer<W: Write + ?Sized>(
terminal_writer: &mut W,
view_state: &mut AttachViewState,
layout_state: &AttachLayoutState,
runtime_appearance: &RuntimeAppearance,
help_lines: &[String],
help_scroll: usize,
damage_config: &bmux_config::DamageBehaviorConfig,
slow_terminal_write_ms: u64,
display_capture: &mut DisplayCaptureFanout,
geometry: TerminalGeometry,
render_trace: Option<&mut AttachRenderTrace>,
) -> Result<AttachFrameRenderStats> {
view_state.driver_geometry = Some(geometry);
if retained_frame_sync_pending(view_state, layout_state) {
return Ok(AttachFrameRenderStats::default());
}
refresh_retained_extensions(view_state, layout_state, geometry, help_lines)?;
let previous = view_state.retained_compositor.clone();
let observing = view_state.observer.is_some();
let (result, _operations) = if view_state.presentation_only {
bmux_attach_pipeline::projection::capture_neutral(|| {
render_attach_frame_inner(
terminal_writer,
view_state,
layout_state,
runtime_appearance,
help_lines,
help_scroll,
damage_config,
slow_terminal_write_ms,
display_capture,
geometry,
render_trace,
)
})
} else if observing {
bmux_attach_pipeline::projection::capture(|| {
render_attach_frame_inner(
terminal_writer,
view_state,
layout_state,
runtime_appearance,
help_lines,
help_scroll,
damage_config,
slow_terminal_write_ms,
display_capture,
geometry,
render_trace,
)
})
} else {
(
render_attach_frame_inner(
terminal_writer,
view_state,
layout_state,
runtime_appearance,
help_lines,
help_scroll,
damage_config,
slow_terminal_write_ms,
display_capture,
geometry,
render_trace,
),
Vec::new(),
)
};
if let Err(error) = &result {
view_state.retained_compositor = previous;
view_state
.dirty
.mark_full_frame(AttachDirtySource::PluginCommand);
if recover_browser_rejection(view_state, error) {
return Ok(AttachFrameRenderStats::default());
}
return result;
}
finish_presented_scroll_tracking(view_state);
let old_focus = view_state.plugin_focus.focused().and_then(|target| {
previous.endpoint_for_region(target).map(|endpoint| {
(
endpoint.clone(),
format!(
"{}:{}:{}",
target.owner_plugin_id, target.surface_local_id, target.region_local_id
),
)
})
});
if view_state
.plugin_focus
.reconcile(&view_state.retained_compositor)
&& let Some((endpoint, hook)) = old_focus
&& view_state
.presentation_input
.notify_focus_lost(&endpoint, &hook)
{
view_state
.dirty
.mark_retained_surfaces_dirty(AttachDirtySource::PluginCommand);
}
let _ = view_state
.plugin_pointer_router
.reconcile(&view_state.retained_compositor);
acknowledge_plugin_surface_output(view_state);
result
}
fn acknowledge_plugin_surface_output(view_state: &AttachViewState) {
let mut published = std::collections::BTreeSet::new();
for surface in view_state.retained_compositor.surfaces().values() {
if let Some(revision) = surface.revision {
for region in &surface.interactive_regions {
if let Some(endpoint) = ®ion.endpoint {
published.insert((endpoint.clone(), revision));
}
}
}
}
for (endpoint, revision) in published {
view_state.presentation_input.committed(&endpoint, revision);
}
}
#[allow(clippy::too_many_lines, clippy::too_many_arguments)]
fn render_attach_frame_inner<W: Write + ?Sized>(
terminal_writer: &mut W,
view_state: &mut AttachViewState,
layout_state: &AttachLayoutState,
runtime_appearance: &RuntimeAppearance,
help_lines: &[String],
help_scroll: usize,
damage_config: &bmux_config::DamageBehaviorConfig,
slow_terminal_write_ms: u64,
display_capture: &mut DisplayCaptureFanout,
geometry: TerminalGeometry,
mut render_trace: Option<&mut AttachRenderTrace>,
) -> Result<AttachFrameRenderStats> {
let damage_policy = DamageCoalescingPolicy {
max_rects: damage_config.max_rects,
max_area_percent: damage_config.max_area_percent,
};
let appearance_resolver = AttachAppearanceResolver {
fallback: runtime_appearance,
view_state,
};
let _ = appearance_resolver.fallback();
let _ = layout_state
.scene
.surfaces
.iter()
.find_map(|surface| surface.pane_id.as_ref())
.map(|pane_id| appearance_resolver.for_pane(pane_id));
let current_help_overlay_surface = if view_state.help_overlay_open {
help_overlay_surface(help_lines, geometry)
} else {
None
};
let current_prompt_overlay_surface = view_state.prompt.overlay_surface(geometry);
let mut frame_damage = view_state.dirty.frame_damage(&layout_state.scene);
#[cfg(any(
feature = "image-sixel",
feature = "image-kitty",
feature = "image-iterm2"
))]
let raster_image_protocol = view_state.host_image_caps.preferred_protocol()
!= Some(bmux_image::model::ImageProtocol::KittyGraphics);
#[cfg(any(
feature = "image-sixel",
feature = "image-kitty",
feature = "image-iterm2"
))]
if view_state.pane_images_presented
|| (raster_image_protocol && view_state.has_presented_images())
{
frame_damage.mark_full_frame();
}
let (top_inset, bottom_inset) = (0, 0);
let terminal_size = (geometry.cols, geometry.rows);
let viewport = DamageRect::new(0, 0, terminal_size.0, terminal_size.1);
let help_retained_surface = current_help_overlay_surface.as_ref().map(|surface| {
retained_help_overlay_surface(surface, help_lines, help_scroll, runtime_appearance)
});
let prompt_overlay_render = if view_state.prompt.is_active() {
let extensions = view_state
.prepared_extensions
.as_ref()
.expect("entry prepares retained extension intent");
let prompt_surface = view_state.prompt.overlay_surface(geometry);
let extension_chrome = prompt_surface.as_ref().is_some_and(|surface| {
retained_surface_extension_chrome_owned(
surface.id,
attach_surface_damage_rect(surface),
extensions,
terminal_render_capabilities(view_state),
)
});
view_state.prompt.attach_prompt_overlay_render(
geometry,
runtime_appearance,
extension_chrome,
)
} else {
None
};
let retained_plan = build_retained_frame_plan(
view_state,
layout_state,
&mut frame_damage,
help_retained_surface,
prompt_overlay_render,
viewport,
damage_policy,
);
let frame_plan =
RenderFramePlan::from_damage(retained_plan, &frame_damage, damage_config.visualize);
let retained_repaint_by_id = frame_plan.retained.repaint_by_id();
let mut frame_bytes = Vec::new();
#[cfg(any(
feature = "image-sixel",
feature = "image-kitty",
feature = "image-iterm2"
))]
let mut pending_kitty_state = view_state.kitty_host_state.clone();
let mut pending_graphics_cache = view_state.terminal_graphics_cache.clone();
if frame_plan.use_synchronized_update {
queue!(frame_bytes, BeginSynchronizedUpdate)
.context("failed queuing begin synchronized update")?;
}
#[cfg(any(
feature = "image-sixel",
feature = "image-kitty",
feature = "image-iterm2"
))]
if bmux_attach_pipeline::projection::encoding_terminal() {
pending_kitty_state.select_protocol(
&mut frame_bytes,
view_state.host_image_caps.preferred_protocol(),
)?;
if frame_plan.render_scene {
pending_kitty_state.begin_frame();
pending_kitty_state.clear_placements(&mut frame_bytes)?;
}
}
queue!(frame_bytes, SavePosition).context("failed queuing cursor save for attach frame")?;
queue!(frame_bytes, Hide).context("failed queuing cursor hide for attach frame")?;
#[cfg(any(
feature = "image-sixel",
feature = "image-kitty",
feature = "image-iterm2"
))]
if view_state.pane_images_presented {
bmux_attach_pipeline::projection::record(
bmux_attach_pipeline::projection::PaintOperation::Fill {
rect: bmux_tui::geometry::Rect::new(0, 0, terminal_size.0, terminal_size.1),
style: bmux_tui::style::Style::new(),
},
);
queue!(frame_bytes, Clear(ClearType::All))
.context("failed clearing previous image frame")?;
}
let cursor_state_before_forced_hide = view_state.last_cursor_state;
let mut pending_cursor_state = view_state.last_cursor_state;
if let Some(ref mut cs) = pending_cursor_state {
cs.visible = false;
}
queue_retained_background_clear(&mut frame_bytes, &frame_plan.retained.damage.graph)?;
let retained_capabilities = terminal_render_capabilities(view_state);
let status_rendered = false;
let appearance_mode_id = if view_state.help_overlay_open {
"help"
} else if view_state.prompt.is_active() {
"prompt"
} else if view_state.scrollback_active() {
"scroll"
} else if layout_state.zoomed {
"zoom"
} else {
view_state.active_mode_id.as_str()
};
let opaque_overlay_rects = view_state
.retained_compositor
.content_occluders()
.into_iter()
.map(|rect| ExtensionRect::new(rect.x, rect.y, rect.w, rect.h))
.collect::<Vec<_>>();
let active_runtime_appearance = runtime_appearance.for_mode(appearance_mode_id);
let mut scene_render_stats = AttachSceneRenderStats::default();
let cursor_state = if frame_plan.render_scene {
let extensions = view_state
.prepared_extensions
.clone()
.expect("entry prepares retained extension intent");
view_state
.visual_projection_updates
.extend(collect_visual_projection_updates(
&layout_state.scene,
&view_state.pane_buffers,
&extensions,
));
let terminal_render_capabilities = terminal_render_capabilities(view_state);
let (cursor_state, stats) =
render_attach_scene_with_stats_and_trace_with_capabilities_and_occluders(
&mut frame_bytes,
&layout_state.scene,
&layout_state.panes,
&mut view_state.pane_buffers,
&mut pending_graphics_cache,
&frame_damage,
top_inset,
bottom_inset,
&view_state.pane_scrollback,
layout_state.zoomed,
terminal_size,
&active_runtime_appearance,
damage_policy,
&extensions,
terminal_render_capabilities,
&opaque_overlay_rects,
render_trace.as_deref_mut(),
)?;
scene_render_stats = stats;
cursor_state
} else {
cursor_state_before_forced_hide
};
#[cfg(any(
feature = "image-sixel",
feature = "image-kitty",
feature = "image-iterm2"
))]
if frame_plan.render_scene && view_state.host_image_caps.any_supported() {
for surface in &layout_state.scene.surfaces {
let Some(pane_id) = surface.pane_id else {
continue;
};
let images = view_state.presented_images(pane_id);
if !images.is_empty() {
let pane_images: Vec<bmux_image::PaneImage> =
images.iter().map(bmux_image::PaneImage::from).collect();
let pane_rect = bmux_image::compositor::PaneRect {
x: surface.content_rect.x,
y: surface.content_rect.y,
w: surface.content_rect.w,
h: surface.content_rect.h,
};
let covers = opaque_overlay_rects
.iter()
.map(|rect| bmux_tui::geometry::Rect::new(rect.x, rect.y, rect.w, rect.h))
.collect::<Vec<_>>();
if !view_state.presentation_only {
bmux_image::compositor::render_pane_images_clipped(
&mut frame_bytes,
&pane_images,
pane_rect,
&covers,
&view_state.host_image_caps,
&mut pending_kitty_state,
)
.context("failed composing pane images")?;
}
}
}
}
let previous_cursor_state = cursor_state_before_forced_hide;
let mut overlay_rendered = false;
let retained_extensions = view_state
.prepared_extensions
.clone()
.expect("entry prepares retained extension intent");
let retained_plugin_repaints = if frame_plan.render_scene {
view_state
.retained_compositor
.surfaces()
.values()
.filter(|surface| view_state.retained_plugin_surface_ids.contains(&surface.id))
.cloned()
.map(|surface| {
let repaint = retained_full_surface_repaint(&surface);
(surface, repaint)
})
.collect::<Vec<_>>()
} else {
frame_plan
.retained
.repaint_plan
.iter()
.filter(|repaint| {
view_state
.retained_plugin_surface_ids
.contains(&repaint.surface_id)
})
.filter_map(|repaint| {
view_state
.retained_compositor
.surfaces()
.get(&repaint.surface_id)
.cloned()
.map(|surface| (surface, repaint.clone()))
})
.collect::<Vec<_>>()
};
for (surface, repaint) in retained_plugin_repaints {
overlay_rendered |=
queue_retained_render_ops(&mut frame_bytes, &surface, &repaint, retained_capabilities)?;
}
let mut overlay_cursor_state = None;
if let Some(help_surface) = frame_plan.retained.help_surface.as_ref()
&& let Some(repaint) = retained_repaint_by_id.get(&help_surface.id)
&& queue_retained_render_ops(
&mut frame_bytes,
help_surface,
repaint,
retained_capabilities,
)?
{
if let Some(trace) = render_trace.as_deref_mut() {
trace.push(AttachRenderTraceOp::HelpOverlay {
rows: help_surface.rect.h,
cells: u64::from(help_surface.rect.w)
.saturating_mul(u64::from(help_surface.rect.h)),
});
}
overlay_rendered = true;
overlay_rendered |= queue_retained_extension_ops(
&mut frame_bytes,
help_surface,
repaint,
&retained_extensions,
retained_capabilities,
&mut pending_graphics_cache,
)?;
}
if let Some(prompt_surface) = frame_plan.retained.prompt_surface.as_ref()
&& let Some(repaint) = retained_repaint_by_id.get(&prompt_surface.id)
&& queue_retained_render_ops(
&mut frame_bytes,
prompt_surface,
repaint,
retained_capabilities,
)?
{
overlay_cursor_state = frame_plan
.retained
.prompt_overlay_render
.as_ref()
.and_then(|render| render.cursor_state);
if let Some(trace) = render_trace.as_deref_mut() {
trace.push(AttachRenderTraceOp::PromptOverlay {
rows: prompt_surface.rect.h,
cells: u64::from(prompt_surface.rect.w)
.saturating_mul(u64::from(prompt_surface.rect.h)),
});
}
overlay_rendered = true;
overlay_rendered |= queue_retained_extension_ops(
&mut frame_bytes,
prompt_surface,
repaint,
&retained_extensions,
retained_capabilities,
&mut pending_graphics_cache,
)?;
} else if view_state.prompt.is_active() {
overlay_cursor_state = cursor_state_before_forced_hide.map(|mut cursor| {
cursor.visible = true;
cursor
});
}
if damage_config.visualize
&& let Some(damage_surface) = retained_damage_overlay_surface(
&layout_state.scene,
&frame_damage,
terminal_size,
top_inset,
bottom_inset,
)
{
let repaint = retained_full_surface_repaint(&damage_surface);
if queue_retained_render_ops(
&mut frame_bytes,
&damage_surface,
&repaint,
retained_capabilities,
)? {
let rects = frame_damage_overlay_rects(
&layout_state.scene,
&frame_damage,
terminal_size,
top_inset,
bottom_inset,
);
if let Some(trace) = render_trace.as_mut() {
trace.push(AttachRenderTraceOp::DamageOverlay {
rects: u16::try_from(rects.len()).unwrap_or(u16::MAX),
cells: rects.iter().map(|rect| u64::from(rect.area())).sum::<u64>(),
});
}
overlay_rendered = true;
}
}
if view_state.help_overlay_open || view_state.prompt.is_active() {
if view_state.presentation_only {
pending_cursor_state = overlay_cursor_state;
} else {
apply_attach_cursor_state(
&mut frame_bytes,
overlay_cursor_state,
&mut pending_cursor_state,
false,
)?;
}
} else {
let force_cursor_move = std::mem::take(&mut view_state.force_cursor_move_next_frame);
let cursor_state = cursor_state.map(|mut cursor| {
cursor.visible &= layout_state
.scene
.surfaces
.iter()
.filter(|surface| surface.cursor_owner)
.any(|surface| {
view_state
.retained_compositor
.point_visible_from(surface.id, cursor.x, cursor.y)
});
cursor
});
if view_state.presentation_only {
pending_cursor_state = cursor_state;
} else {
apply_attach_cursor_state(
&mut frame_bytes,
cursor_state,
&mut pending_cursor_state,
force_cursor_move,
)?;
}
}
#[cfg(any(
feature = "image-sixel",
feature = "image-kitty",
feature = "image-iterm2"
))]
if frame_plan.render_scene && bmux_attach_pipeline::projection::encoding_terminal() {
pending_kitty_state.finish_frame(&mut frame_bytes)?;
}
if frame_plan.use_synchronized_update {
queue!(frame_bytes, EndSynchronizedUpdate)
.context("failed queuing end synchronized update")?;
}
let terminal_write_started_at = Instant::now();
let write_result = if view_state.observer.is_some() || view_state.presentation_only {
let cursor = view_state.last_cursor_state;
view_state.last_cursor_state = pending_cursor_state;
let operations = bmux_attach_pipeline::projection::staged_operations();
let headless = view_state.presentation_only;
let delivered = super::observer::deliver_prepared(
view_state,
layout_state,
geometry,
&operations,
|| {
if headless {
Ok(())
} else {
bmux_attach_pipeline::terminal::write_frame(terminal_writer, &frame_bytes)
.context("failed writing or flushing attach frame")
}
},
);
if delivered.is_err() {
view_state.last_cursor_state = cursor;
}
delivered
} else {
bmux_attach_pipeline::terminal::write_frame(terminal_writer, &frame_bytes)
.context("failed writing or flushing attach frame")
};
if let Err(error) = write_result {
#[cfg(any(
feature = "image-sixel",
feature = "image-kitty",
feature = "image-iterm2"
))]
view_state
.kitty_host_state
.invalidate_after_failed_output(&pending_kitty_state);
for buffer in view_state.pane_buffers.values_mut() {
buffer.prev_rows.clear();
}
view_state
.dirty
.mark_full_frame(AttachDirtySource::ManualRedraw);
return Err(error);
}
let terminal_write_ms = duration_millis_u64(terminal_write_started_at.elapsed());
view_state.last_cursor_state = pending_cursor_state;
view_state.terminal_graphics_cache = pending_graphics_cache;
#[cfg(any(
feature = "image-sixel",
feature = "image-kitty",
feature = "image-iterm2"
))]
{
view_state.kitty_host_state = pending_kitty_state;
view_state.pane_images_presented =
raster_image_protocol && view_state.has_presented_images();
}
display_capture.record_frame(
&frame_bytes,
view_state.last_cursor_state,
previous_cursor_state != view_state.last_cursor_state,
);
#[cfg(any(
feature = "image-sixel",
feature = "image-kitty",
feature = "image-iterm2"
))]
{
let mut all_images: Vec<bmux_attach_image_protocol::AttachPaneImage> = Vec::new();
for surface in &layout_state.scene.surfaces {
let Some(pane_id) = surface.pane_id else {
continue;
};
{
for img in view_state.presented_images(pane_id) {
let mut adjusted = img.clone();
adjusted.position_col = adjusted
.position_col
.saturating_add(surface.rect.x.saturating_add(1));
adjusted.position_row = adjusted
.position_row
.saturating_add(surface.rect.y.saturating_add(1));
all_images.push(adjusted);
}
}
}
display_capture.record_images(&all_images);
}
if terminal_write_ms >= slow_terminal_write_ms {
tracing::warn!(
terminal_write_ms,
threshold_ms = slow_terminal_write_ms,
frame_bytes = frame_bytes.len(),
"attach.terminal.slow_write"
);
}
let stats = AttachFrameRenderStats {
frame_bytes: frame_bytes.len(),
terminal_write_ms,
damage_rects: frame_plan.damage_stats.rect_count,
damage_area_cells: frame_plan.damage_stats.rect_area_cells,
full_surface_fallbacks: frame_plan.damage_stats.full_surface_count,
full_frame_fallback: frame_plan.damage_stats.full_frame,
scene_render: scene_render_stats,
status_rendered,
overlay_rendered,
synchronized_update: frame_plan.use_synchronized_update,
dirty_event_count: view_state.dirty.dirty_events().len(),
};
view_state.last_help_overlay_surface = current_help_overlay_surface;
view_state.last_prompt_overlay_surface = current_prompt_overlay_surface;
view_state.dirty.clear_frame_damage();
Ok(stats)
}
pub fn resolve_attach_context_label_from_catalog(
contexts: &[ContextRow],
bindings: &[ContextSessionBinding],
context_id: Option<Uuid>,
session_id: Uuid,
) -> String {
if let Some(context_id) = context_id
&& let Some((index, context)) = contexts
.iter()
.enumerate()
.find(|(_, context)| context.id == context_id)
{
return context_summary_label(context, Some(index.saturating_add(1)));
}
if let Some((index, context)) = contexts
.iter()
.enumerate()
.find(|(_, context)| context_session_id(bindings, context.id) == Some(session_id))
{
return context_summary_label(context, Some(index.saturating_add(1)));
}
"terminal".to_string()
}
pub fn context_summary_label(context: &ContextRow, fallback_index: Option<usize>) -> String {
context
.name
.as_deref()
.filter(|name| !name.trim().is_empty())
.map_or_else(
|| fallback_index.map_or_else(|| "tab".to_string(), |index| format!("tab-{index}")),
ToString::to_string,
)
}
pub fn resolve_attach_session_label_and_count_from_catalog(
sessions: &[SessionRow],
session_id: Uuid,
) -> (String, usize) {
let count = sessions.len();
let label = sessions
.iter()
.find(|session| session.id == session_id)
.map_or_else(
|| format!("session-{}", short_uuid(session_id)),
session_summary_label,
);
(label, count)
}
pub fn session_summary_label(session: &SessionRow) -> String {
session
.name
.clone()
.unwrap_or_else(|| format!("session-{}", short_uuid(session.id)))
}
pub fn short_uuid(id: Uuid) -> String {
id.to_string().chars().take(8).collect()
}
pub async fn open_attach_for_session(
client: &mut StreamingBmuxClient,
session_id: Uuid,
) -> std::result::Result<bmux_client::AttachOpenInfo, ClientError> {
let grant = client
.attach_grant(SessionSelector::ById(session_id))
.await?;
client.open_attach_stream_info(&grant).await
}
pub async fn open_attach_for_context(
client: &mut StreamingBmuxClient,
context_id: Uuid,
) -> std::result::Result<bmux_client::AttachOpenInfo, ClientError> {
let grant = client
.attach_context_grant(ContextSelector::ById(context_id))
.await?;
client.open_attach_stream_info(&grant).await
}
pub const fn attached_session_selector(view_state: &AttachViewState) -> SessionSelector {
SessionSelector::ById(view_state.attached_id)
}
fn context_session_id(bindings: &[ContextSessionBinding], context_id: Uuid) -> Option<Uuid> {
bindings
.iter()
.find(|binding| binding.context_id == context_id)
.map(|binding| binding.session_id)
}
fn apply_control_catalog_snapshot(
view_state: &mut AttachViewState,
snapshot: control_catalog_state::Snapshot,
) {
view_state.cached_contexts = snapshot.contexts;
view_state.cached_sessions = snapshot.sessions;
view_state.cached_context_session_bindings = snapshot.context_session_bindings;
view_state.control_catalog_revision = snapshot.revision;
}
pub async fn reconcile_attached_session_from_catalog(
client: &mut StreamingBmuxClient,
view_state: &mut AttachViewState,
) -> std::result::Result<bool, ClientError> {
let Some(context_id) = view_state.attached_context_id else {
return Ok(false);
};
let Some(mapped_session_id) =
context_session_id(&view_state.cached_context_session_bindings, context_id)
else {
return Ok(false);
};
if mapped_session_id == view_state.attached_id {
return Ok(false);
}
let started_at = Instant::now();
trace!(
context_id = %context_id,
previous_session_id = %view_state.attached_id,
mapped_session_id = %mapped_session_id,
"attach.catalog_reconcile.start"
);
let attach_info = open_attach_for_context(client, context_id).await?;
view_state.attached_id = attach_info.session_id;
view_state.last_attach_view_revision = None;
view_state.attached_context_id = attach_info.context_id.or(Some(context_id));
view_state.can_write = attach_info.can_write;
update_attach_viewport(client, view_state).await?;
hydrate_attach_state_from_snapshot(client, view_state).await?;
view_state.ui_mode = AttachUiMode::Normal;
trace!(
context_id = ?view_state.attached_context_id,
refreshed_session_id = %view_state.attached_id,
elapsed_ms = started_at.elapsed().as_millis(),
"attach.catalog_reconcile.done"
);
Ok(true)
}
pub async fn refresh_attach_status_catalog(
client: &mut StreamingBmuxClient,
view_state: &mut AttachViewState,
) -> anyhow::Result<()> {
let snapshot =
control_catalog_state::client::snapshot(client, Some(view_state.control_catalog_revision))
.await
.context("control-catalog snapshot dispatch failed")?;
apply_control_catalog_snapshot(view_state, snapshot);
Ok(())
}
async fn refresh_attach_status_catalog_best_effort(
client: &mut StreamingBmuxClient,
view_state: &mut AttachViewState,
) {
if let Err(error) = refresh_attach_status_catalog(client, view_state).await {
warn!(
attached_context_id = ?view_state.attached_context_id,
attached_session_id = %view_state.attached_id,
"attach.status_catalog.refresh_failed: {error:#}"
);
}
}
pub fn attach_keymap_from_config(config: &BmuxConfig) -> crate::input::Keymap {
let (_runtime_bindings, global_bindings, scroll_bindings) = filtered_attach_keybindings(config);
let timeout_ms = config
.keybindings
.resolve_timeout()
.map_or(None, |timeout| timeout.timeout_ms());
let modal_modes = config
.keybindings
.modes
.iter()
.map(|(mode_id, mode)| {
(
mode_id.clone(),
crate::input::ModalModeConfig {
label: mode.label.clone(),
passthrough: mode.passthrough,
bindings: mode.bindings.clone(),
},
)
})
.collect();
match crate::input::Keymap::from_modal_parts_with_scroll(
timeout_ms,
&config.keybindings.initial_mode,
&modal_modes,
&global_bindings,
&scroll_bindings,
) {
Ok(keymap) => keymap,
Err(error) => {
eprintln!("bmux warning: invalid attach keymap config, using defaults ({error})");
default_attach_keymap()
}
}
}
pub fn filtered_attach_keybindings(
config: &BmuxConfig,
) -> (
std::collections::BTreeMap<String, String>,
std::collections::BTreeMap<String, String>,
std::collections::BTreeMap<String, String>,
) {
let (runtime, global, scroll) = merged_runtime_keybindings(config);
let runtime = normalize_attach_keybindings(runtime, "runtime");
let mut global = normalize_attach_keybindings(global, "global");
let scroll = normalize_attach_keybindings(scroll, "scroll");
inject_attach_global_defaults(&mut global);
(runtime, global, scroll)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AttachKeybindingScope {
Runtime,
Global,
}
impl AttachKeybindingScope {
pub const fn as_str(self) -> &'static str {
match self {
Self::Runtime => "runtime",
Self::Global => "global",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AttachKeybindingEntry {
pub scope: AttachKeybindingScope,
pub chord: String,
pub action: RuntimeAction,
pub action_name: String,
}
pub fn effective_attach_keybindings(config: &BmuxConfig) -> Vec<AttachKeybindingEntry> {
let (runtime, global, _) = filtered_attach_keybindings(config);
let mut entries = Vec::new();
for (chord, action_name) in runtime {
if let Ok(action) = crate::input::parse_runtime_action_name(&action_name) {
entries.push(AttachKeybindingEntry {
scope: AttachKeybindingScope::Runtime,
chord,
action,
action_name,
});
}
}
for (chord, action_name) in global {
if let Ok(action) = crate::input::parse_runtime_action_name(&action_name) {
entries.push(AttachKeybindingEntry {
scope: AttachKeybindingScope::Global,
chord,
action,
action_name,
});
}
}
entries.sort_by(|left, right| {
left.scope
.as_str()
.cmp(right.scope.as_str())
.then_with(|| left.chord.cmp(&right.chord))
});
entries
}
#[allow(clippy::too_many_lines)]
pub fn build_attach_help_lines(config: &BmuxConfig) -> Vec<String> {
let keymap = attach_keymap_from_config(config);
let active_mode_id = keymap.initial_mode_id().unwrap_or("normal");
let help = key_hint_or_unbound(&keymap, active_mode_id, &RuntimeAction::ShowHelp);
let detach = key_hint_or_unbound(&keymap, active_mode_id, &RuntimeAction::Detach);
let scroll = key_hint_or_unbound(&keymap, active_mode_id, &RuntimeAction::EnterScrollMode);
let close = key_hint_or_unbound(&keymap, active_mode_id, &windows_close_active_pane_action());
let restart = key_hint_or_unbound(
&keymap,
active_mode_id,
&RuntimeAction::PluginCommand {
plugin_id: "bmux.tabs".to_string(),
command_name: "restart-pane".to_string(),
args: Vec::new(),
},
);
let mut groups: Vec<(&str, Vec<AttachKeybindingEntry>)> = vec![
("Session", Vec::new()),
("Pane", Vec::new()),
("Mode", Vec::new()),
("Other", Vec::new()),
];
for entry in effective_attach_keybindings(config) {
let category = match &entry.action {
RuntimeAction::Detach | RuntimeAction::Quit => "Session",
RuntimeAction::PluginCommand {
plugin_id,
command_name,
..
} if is_windows_close_active_pane_command(plugin_id, command_name) => "Pane",
RuntimeAction::PluginCommand {
plugin_id,
command_name,
..
} if plugin_id == "bmux.tabs" && command_name == "focus-pane-in-direction" => "Pane",
RuntimeAction::PluginCommand {
plugin_id,
command_name,
..
} if plugin_id == "bmux.tabs" && command_name == "resize-pane" => "Pane",
RuntimeAction::PluginCommand {
plugin_id,
command_name,
..
} if plugin_id == "bmux.tabs"
&& matches!(
command_name.as_str(),
"split-pane" | "zoom-pane" | "restart-pane"
) =>
{
"Pane"
}
RuntimeAction::ExitMode
| RuntimeAction::EnterScrollMode
| RuntimeAction::ExitScrollMode
| RuntimeAction::ScrollUpLine
| RuntimeAction::ScrollDownLine
| RuntimeAction::ScrollUpPage
| RuntimeAction::ScrollDownPage
| RuntimeAction::ScrollTop
| RuntimeAction::ScrollBottom
| RuntimeAction::BeginSelection
| RuntimeAction::CopyScrollback
| RuntimeAction::ConfirmScrollback
| RuntimeAction::EnterMode(_)
| RuntimeAction::ShowHelp => "Mode",
_ => "Other",
};
if let Some((_, entries)) = groups.iter_mut().find(|(name, _)| *name == category) {
entries.push(entry);
}
}
let mut lines = Vec::new();
lines.push("Attach Help".to_string());
lines.push(format!(
"Modal keybindings are active. Use {scroll} for scrollback, {detach} to detach, and {help} to toggle help."
));
lines.push(format!(
"Pane recovery: use {restart} to restart an exited pane in place; {close} opens a confirmation prompt before closing."
));
lines.push(String::new());
for (category, mut entries) in groups {
if entries.is_empty() {
continue;
}
entries.sort_by(|left, right| {
left.scope
.as_str()
.cmp(right.scope.as_str())
.then_with(|| left.chord.cmp(&right.chord))
});
lines.push(format!("-- {category} --"));
for entry in entries {
lines.push(format!(
"[{:<7}] {:<20} {}",
entry.scope.as_str(),
entry.chord,
entry.action_name
));
}
lines.push(String::new());
}
if lines.last().is_some_and(String::is_empty) {
let _ = lines.pop();
}
lines
}
pub fn normalize_attach_keybindings(
bindings: std::collections::BTreeMap<String, String>,
scope: &str,
) -> std::collections::BTreeMap<String, String> {
bindings
.into_iter()
.filter_map(
|(chord, action_name)| match crate::input::parse_runtime_action_name(&action_name) {
Ok(action) if is_attach_runtime_action(&action) => {
Some((chord, action_to_config_name(&action)))
}
Ok(_) => None,
Err(error) => {
eprintln!(
"bmux warning: dropping invalid {scope} keybinding '{chord}' -> '{action_name}' ({error})"
);
None
}
},
)
.collect()
}
pub const fn inject_attach_global_defaults(
_global: &mut std::collections::BTreeMap<String, String>,
) {
}
pub const fn is_attach_runtime_action(action: &RuntimeAction) -> bool {
matches!(
action,
RuntimeAction::NoOp
| RuntimeAction::Detach
| RuntimeAction::Quit
| RuntimeAction::ExitMode
| RuntimeAction::EnterScrollMode
| RuntimeAction::ExitScrollMode
| RuntimeAction::ScrollUpLine
| RuntimeAction::ScrollDownLine
| RuntimeAction::ScrollUpPage
| RuntimeAction::ScrollDownPage
| RuntimeAction::ScrollTop
| RuntimeAction::ScrollBottom
| RuntimeAction::BeginSelection
| RuntimeAction::CopyScrollback
| RuntimeAction::ConfirmScrollback
| RuntimeAction::PluginCommand { .. }
| RuntimeAction::ShowHelp
| RuntimeAction::EnterMode(_)
)
}
pub fn default_attach_keymap() -> crate::input::Keymap {
let defaults = BmuxConfig::default();
let (_runtime_bindings, global_bindings, scroll_bindings) =
filtered_attach_keybindings(&defaults);
let timeout_ms = defaults
.keybindings
.resolve_timeout()
.expect("default timeout config must be valid")
.timeout_ms();
let modal_modes = defaults
.keybindings
.modes
.iter()
.map(|(mode_id, mode)| {
(
mode_id.clone(),
crate::input::ModalModeConfig {
label: mode.label.clone(),
passthrough: mode.passthrough,
bindings: mode.bindings.clone(),
},
)
})
.collect();
crate::input::Keymap::from_modal_parts_with_scroll(
timeout_ms,
&defaults.keybindings.initial_mode,
&modal_modes,
&global_bindings,
&scroll_bindings,
)
.expect("default attach keymap must be valid")
}
pub fn describe_timeout(timeout: &ResolvedTimeout) -> String {
match timeout {
ResolvedTimeout::Indefinite => "indefinite".to_string(),
ResolvedTimeout::Exact(ms) => format!("exact ({ms}ms)"),
ResolvedTimeout::Profile { name, ms } => format!("profile:{name} ({ms}ms)"),
}
}
#[cfg(test)]
fn queue_attach_input_modes<W: Write>(
writer: &mut W,
mouse_capture_enabled: bool,
bracketed_paste_enabled: bool,
) -> io::Result<()> {
if mouse_capture_enabled {
queue!(writer, EnableMouseCapture)?;
}
if bracketed_paste_enabled {
queue!(writer, EnableBracketedPaste)?;
}
Ok(())
}
#[cfg(test)]
fn queue_active_attach_input_mode_restore<W: Write>(
writer: &mut W,
mouse_capture_enabled: bool,
bracketed_paste_enabled: bool,
) -> io::Result<()> {
if mouse_capture_enabled {
queue!(writer, DisableMouseCapture)?;
}
if bracketed_paste_enabled {
queue!(writer, DisableBracketedPaste)?;
}
Ok(())
}
fn queue_attach_input_mode_restore<W: Write>(writer: &mut W) -> io::Result<()> {
queue!(writer, DisableMouseCapture, DisableBracketedPaste)
}
async fn refresh_observed_geometry<T: AttachTerminal + ?Sized>(
terminal: &mut T,
client: &mut StreamingBmuxClient,
target: Uuid,
) -> Result<bool> {
let Some(provider) = terminal.render_provider() else {
return Ok(false);
};
let old = terminal.geometry();
let viewport = provider
.observe_viewport(
client,
&target.to_string(),
bmux_client::AttachProviderViewport {
command_sequence: 0,
columns: old.cols,
rows: old.rows,
pixel_width: None,
pixel_height: None,
},
)
.await?;
let next = TerminalGeometry {
cols: viewport.columns,
rows: viewport.rows,
};
if old.cols == next.cols && old.rows == next.rows {
return Ok(false);
}
terminal.accept_geometry(next)?;
Ok(true)
}
pub async fn update_attach_viewport(
client: &mut StreamingBmuxClient,
view_state: &AttachViewState,
) -> std::result::Result<(), ClientError> {
update_attach_viewport_with_geometry(
client,
view_state,
view_state
.driver_geometry
.unwrap_or_else(current_attach_terminal_geometry),
)
.await
}
pub async fn update_attach_viewport_with_geometry(
client: &mut StreamingBmuxClient,
view_state: &AttachViewState,
geometry: TerminalGeometry,
) -> std::result::Result<(), ClientError> {
if geometry.cols == 0 || geometry.rows == 0 {
return Ok(());
}
if view_state
.viewport_guard
.as_ref()
.is_some_and(|guard| !guard())
{
return Ok(());
}
let insets = resolved_attach_viewport_insets(geometry, &view_state.presentation_layouts);
client
.attach_set_viewport_with_insets(
view_state.attached_id,
geometry.cols,
geometry.rows,
insets.top,
insets.right,
insets.bottom,
insets.left,
)
.await?;
Ok(())
}
pub async fn hydrate_attach_state_from_snapshot(
client: &mut StreamingBmuxClient,
view_state: &mut AttachViewState,
) -> std::result::Result<(), ClientError> {
hydrate_attach_state_from_snapshot_mode(client, view_state, SnapshotHydrationMode::Incremental)
.await
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SnapshotHydrationMode {
Incremental,
FullResync,
}
async fn apply_attach_structured_grid_deltas(
client: &mut StreamingBmuxClient,
view_state: &mut AttachViewState,
pane_ids: Vec<Uuid>,
) -> std::result::Result<bool, ClientError> {
if pane_ids.is_empty() {
return Ok(false);
}
let base_revisions = pane_ids
.iter()
.map(|pane_id| {
view_state
.pane_buffers
.get(pane_id)
.map_or(0, |buffer| buffer.terminal_grid.grid().revision())
})
.collect::<Vec<_>>();
let deltas = match attach_pane_grid_delta_state_streaming(
client,
view_state.attached_id,
pane_ids,
base_revisions,
ATTACH_GRID_DELTA_MAX_BATCHES_PER_PANE,
)
.await
{
Ok(deltas) => deltas,
Err(error) => {
tracing::debug!(%error, "structured pane grid delta unavailable; structured snapshot recovery will be attempted by caller");
return Ok(false);
}
};
let mut needs_snapshot = Vec::new();
let mut applied_any = false;
for delta_result in deltas {
if delta_result.desynced {
needs_snapshot.push(delta_result.pane_id);
continue;
}
if delta_result.encoded.is_empty() {
continue;
}
let batches = match serde_json::from_slice::<Vec<bmux_terminal_grid::GridDeltaBatch>>(
&delta_result.encoded,
) {
Ok(batches) => batches,
Err(error) => {
tracing::warn!(pane_id = %delta_result.pane_id, %error, "failed decoding structured pane grid deltas");
needs_snapshot.push(delta_result.pane_id);
continue;
}
};
let mut pane_applied = false;
{
let buffer = view_state
.pane_buffers
.entry(delta_result.pane_id)
.or_default();
for batch in &batches {
if let Err(error) = buffer
.terminal_grid
.apply_delta(batch, bmux_terminal_grid::GridLimits::default())
{
tracing::warn!(pane_id = %delta_result.pane_id, %error, "failed applying structured pane grid delta");
needs_snapshot.push(delta_result.pane_id);
break;
}
let updated_rows = batch
.row_updates
.iter()
.map(|update| update.row_index)
.collect::<Vec<_>>();
buffer.visual_row_fingerprints.invalidate_rows(
batch.reset_rows,
batch.content_revision,
&updated_rows,
);
pane_applied = true;
}
if pane_applied {
buffer.prev_rows.clear();
}
}
if pane_applied {
applied_any = true;
view_state
.dirty
.mark_pane_dirty(delta_result.pane_id, AttachDirtySource::PaneOutput);
}
}
if !needs_snapshot.is_empty() {
let _ =
hydrate_attach_structured_grid_snapshots(client, view_state, needs_snapshot).await?;
applied_any = true;
}
Ok(applied_any)
}
fn attach_grid_snapshot_max_rows_for_panes(
view_state: &AttachViewState,
pane_ids: &[Uuid],
) -> usize {
let requested = pane_ids.iter().copied().collect::<BTreeSet<_>>();
view_state
.cached_layout_state
.as_ref()
.and_then(|layout_state| {
layout_state
.scene
.surfaces
.iter()
.filter(|surface| {
surface.visible
&& surface
.pane_id
.is_some_and(|pane_id| requested.contains(&pane_id))
})
.map(|surface| usize::from(surface.content_rect.h.max(1)))
.max()
})
.unwrap_or(ATTACH_GRID_SNAPSHOT_FALLBACK_ROWS_PER_PANE)
}
async fn hydrate_attach_structured_grid_snapshots(
client: &mut StreamingBmuxClient,
view_state: &mut AttachViewState,
pane_ids: Vec<Uuid>,
) -> std::result::Result<BTreeSet<Uuid>, ClientError> {
if pane_ids.is_empty() {
return Ok(BTreeSet::new());
}
let max_rows_per_pane = attach_grid_snapshot_max_rows_for_panes(view_state, &pane_ids);
let snapshots = match attach_pane_grid_snapshot_state_streaming(
client,
view_state.attached_id,
pane_ids,
max_rows_per_pane,
)
.await
{
Ok(snapshots) => snapshots,
Err(error) => {
tracing::debug!(%error, "structured pane grid snapshot unavailable; pane rendering will wait for structured state");
return Ok(BTreeSet::new());
}
};
let mut hydrated = BTreeSet::new();
for snapshot in snapshots {
let decoded = match serde_json::from_slice::<bmux_terminal_grid::GridSnapshot>(
&snapshot.encoded,
) {
Ok(decoded) => decoded,
Err(error) => {
tracing::warn!(pane_id = %snapshot.pane_id, %error, "failed decoding structured pane grid snapshot");
continue;
}
};
let stream = match bmux_terminal_grid::TerminalGridStream::from_snapshot(
&decoded,
bmux_terminal_grid::GridLimits::default(),
) {
Ok(stream) => stream,
Err(error) => {
tracing::warn!(pane_id = %snapshot.pane_id, %error, "failed hydrating structured pane grid snapshot");
continue;
}
};
let protocol = stream.grid().protocol_state();
let alternate_screen = stream.grid().mode() == bmux_terminal_grid::GridMode::Alternate;
let buffer = view_state.pane_buffers.entry(snapshot.pane_id).or_default();
buffer.terminal_grid = stream;
buffer.protocol_tracker.set_protocol_state(protocol);
buffer
.protocol_tracker
.set_alternate_screen(alternate_screen);
buffer.visual_row_fingerprints.clear();
buffer.expected_stream_start = Some(snapshot.stream_end);
if !buffer.retained_screen_copy {
buffer.scrollback_window = None;
}
buffer.prev_rows.clear();
hydrated.insert(snapshot.pane_id);
}
Ok(hydrated)
}
#[allow(
clippy::too_many_lines,
reason = "window selection, fallback and selection-anchor reconciliation form one ordered refresh workflow"
)]
fn ensure_pane_scrollback_windows(
_client: &mut StreamingBmuxClient,
view_state: &mut AttachViewState,
force_refresh: bool,
) -> std::result::Result<(), ClientError> {
if view_state.scrollback_fetch.as_ref().is_some_and(|fetch| {
view_state.scroll_boundary.get(&fetch.request.pane).copied() != fetch.request.boundary
&& !fetch.prefetch
|| view_state
.scrollback_for(fetch.request.pane)
.is_none_or(|view| view.pin != fetch.request.pin)
|| attach_pane_inner_size(view_state, fetch.request.pane)
!= Some((fetch.request.width, fetch.request.rows))
}) {
view_state.scrollback_fetch = None;
}
let mut ready = Vec::new();
let mut next = None;
for (pane, view) in &view_state.pane_scrollback {
if view_state.captured_alternate.contains(pane) {
continue;
}
let offset = view_state
.requested_scroll_offset(*pane)
.unwrap_or(view.offset);
let Some((width, rows)) = attach_pane_inner_size(view_state, *pane) else {
continue;
};
let boundary = view_state.scroll_boundary.get(pane).copied();
let previous = view_state
.pane_buffers
.get(pane)
.and_then(|buffer| buffer.scrollback_window.as_ref());
let mutable_tail = force_refresh && view.pin.is_none() && offset < rows;
if mutable_tail {
view_state.scrollback_cache.invalidate(*pane);
}
if boundary.is_none()
&& !mutable_tail
&& previous.is_some_and(|window| {
window.scrollback_offset == offset
&& window.rows.len() == rows
&& window.projection_width == width
})
{
continue;
}
if previous.is_none() {
view_state.scrollback_cache.invalidate(*pane);
}
if boundary.is_none()
&& let Some(window) = view_state
.scrollback_cache
.get(*pane, view.pin, offset, width, rows)
{
ready.push((*pane, window));
continue;
}
if let Some(pin) = view.pin
&& let Some(shared) = view_state.decoded_history.get(pane)
&& let Ok(mut cache) = shared.try_lock()
{
let anchor = previous.and_then(|window| window.row_anchors.last().copied());
let delta = previous
.filter(|window| !window.row_anchors.is_empty())
.map_or(0, |window| {
let distance = isize::try_from(offset.abs_diff(window.scrollback_offset))
.unwrap_or(isize::MAX);
if offset >= window.scrollback_offset {
distance
} else {
-distance
}
});
let projection = super::scrollback_fetch::Request {
session: view_state.attached_id,
pane: *pane,
pin: Some(pin),
boundary,
offset,
width,
rows,
total: None,
anchor,
delta,
}
.projection();
if let Some(window) = cache.resident_window(
(view_state.attached_id, *pane, pin),
offset,
rows,
projection,
) {
ready.push((*pane, window));
continue;
}
}
if next.is_none() {
next = Some(super::scrollback_fetch::Request {
boundary,
session: view_state.attached_id,
pane: *pane,
pin: view.pin,
offset,
width,
rows,
total: previous
.filter(|_| view.pin.is_none())
.map(|window| window.total_scrolled_rows),
anchor: previous.and_then(|window| window.row_anchors.last().copied()),
delta: previous
.filter(|window| !window.row_anchors.is_empty())
.map_or(0, |window| {
let distance = isize::try_from(offset.abs_diff(window.scrollback_offset))
.unwrap_or(isize::MAX);
if offset >= window.scrollback_offset {
distance
} else {
-distance
}
}),
});
}
}
for (pane, window) in ready {
publish_scrollback_window(view_state, pane, window);
}
if view_state.scrollback_fetch.is_none()
&& next.is_some()
&& view_state.async_services.is_none()
{
return Err(ClientError::ServerError {
code: bmux_ipc::ErrorCode::Internal,
message: "asynchronous history route unavailable".into(),
});
}
if next.is_some()
&& view_state
.scrollback_fetch
.as_ref()
.is_some_and(|fetch| fetch.prefetch)
{
view_state.scrollback_fetch = None;
}
let mut prefetch = false;
if next.is_none() && view_state.scrollback_fetch.is_none() {
while let Some(candidate) = view_state.history_prefetch.pop_front() {
let current = view_state.scrollback_for(candidate.pane);
if !candidate.reusable_capture(
view_state.attached_id,
current.and_then(|view| view.pin),
attach_pane_inner_size(view_state, candidate.pane),
) {
continue;
}
if view_state
.scrollback_cache
.get(
candidate.pane,
candidate.pin,
candidate.offset,
candidate.width,
candidate.rows,
)
.is_some()
{
continue;
}
next = Some(candidate);
prefetch = true;
break;
}
}
if view_state.scrollback_fetch.is_none()
&& let Some(request) = next
&& let Some(client) = view_state.async_services.clone()
{
view_state.scrollback_fetch = Some(super::scrollback_fetch::Fetch {
prefetch,
request,
task: tokio::spawn(super::scrollback_fetch::fetch(
client,
request,
view_state.history_cache_for(request.pane),
)),
});
}
Ok(())
}
fn publish_scrollback_window(
view_state: &mut AttachViewState,
pane_id: Uuid,
mut window: PaneScrollbackWindow,
) {
view_state.scroll_boundary.remove(&pane_id);
view_state.pending_scroll.remove(&pane_id);
let pin = view_state.scrollback_for(pane_id).and_then(|view| view.pin);
view_state.scrollback_cache.insert(pane_id, pin, &window);
if let Some((width, rows)) = attach_pane_inner_size(view_state, pane_id)
&& window.rows.len() > rows
{
let Some(projected) =
view_state
.scrollback_cache
.get(pane_id, pin, window.scrollback_offset, width, rows)
else {
return;
};
window = projected;
}
let previous_base = view_state
.pane_buffers
.get(&pane_id)
.filter(|buffer| buffer.scrollback_window.is_none())
.map(|buffer| buffer.scrollback_viewport_base(view_state.scrollback_for(pane_id).as_ref()));
let next_base = bmux_attach_pipeline::ScrollbackViewportBase::from_scrolled_rows(
window.total_scrolled_rows,
window.scrollback_offset,
);
let remapped_view = view_state.scrollback_for(pane_id).map(|mut view| {
if let Some(previous) = view_state
.pane_buffers
.get(&pane_id)
.and_then(|buffer| buffer.scrollback_window.as_ref())
{
window.remap_view_from(previous, &mut view);
}
view
});
let Some(view) = view_state.scrollback_for_mut(pane_id) else {
return;
};
if let Some(remapped) = remapped_view {
*view = remapped;
}
view.offset = window.scrollback_offset;
if let Some(previous_base) = previous_base {
view.rebase_selection_anchor(previous_base, next_base);
}
if let Some(buffer) = view_state.pane_buffers.get_mut(&pane_id) {
buffer.scrollback_window = Some(window);
}
}
async fn handle_attach_mouse_scrollback_with_window(
client: &mut StreamingBmuxClient,
view_state: &mut AttachViewState,
pane_id: Option<Uuid>,
kind: MouseEventKind,
) -> std::result::Result<bool, ClientError> {
let Some(pane_id) = pane_id.or_else(|| view_state.focused_pane_id()) else {
return Ok(false);
};
let was_active = view_state.scrollback_active_for(pane_id);
if !was_active {
view_state.scrollback_fetch = None;
view_state.scrollback_cache.invalidate(pane_id);
}
let previous_pin = view_state.scrollback_for(pane_id).and_then(|view| view.pin);
let before_offset = view_state.requested_scroll_offset(pane_id).unwrap_or(0);
let scroll_lines =
isize::try_from(view_state.mouse.config.scroll_lines_per_tick.max(1)).unwrap_or(isize::MAX);
let consumed = handle_attach_mouse_scrollback_for(view_state, pane_id, kind);
if !consumed {
return Ok(false);
}
if was_active
&& !view_state.scrollback_active_for(pane_id)
&& let Some(pin) = previous_pin
{
unpin_scrollback(client, view_state.attached_id, pane_id, pin).await;
view_state.scrollback_replay_pending = true;
}
if matches!(kind, MouseEventKind::ScrollUp)
&& !was_active
&& view_state.scrollback_active_for(pane_id)
{
let config = BmuxConfig::load().unwrap_or_default();
if effective_scrollback_mode(client, &config, view_state.attached_id, pane_id).await
== bmux_config::ScrollbackMode::Frozen
{
match attach_pane_scrollback_pin_streaming(client, view_state.attached_id, pane_id)
.await
{
Ok(pin) => {
if let Some(view) = view_state.scrollback_for_mut(pane_id) {
view.pin = Some(ScrollbackPin {
capture: Some(pin.capture),
pin_id: pin.pin_id,
total_scrolled_rows: pin.total_scrolled_rows,
max_scrollback_offset: pin.max_scrollback_offset,
stream_end: pin.stream_end,
created_epoch_secs: epoch_secs_now(),
});
}
}
Err(error) => view_state.set_transient_status(
format!("frozen scrollback unavailable; using live scrollback: {error}"),
Instant::now(),
ATTACH_TRANSIENT_STATUS_TTL,
),
}
}
}
ensure_pane_scrollback_windows(client, view_state, false)?;
if matches!(kind, MouseEventKind::ScrollUp)
&& !was_active
&& view_state.scrollback_active_for(pane_id)
&& view_state
.requested_scroll_offset(pane_id)
.is_some_and(|offset| offset == before_offset)
{
step_attach_scrollback_for(view_state, pane_id, -scroll_lines);
ensure_pane_scrollback_windows(client, view_state, false)?;
}
Ok(true)
}
fn wildcard_matches(pattern: &str, value: &str) -> bool {
let pattern = pattern.as_bytes();
let value = value.as_bytes();
let (mut pattern_index, mut value_index) = (0_usize, 0_usize);
let (mut star_index, mut star_value_index) = (None, 0_usize);
while value_index < value.len() {
if pattern_index < pattern.len()
&& (pattern[pattern_index] == b'?' || pattern[pattern_index] == value[value_index])
{
pattern_index += 1;
value_index += 1;
} else if pattern_index < pattern.len() && pattern[pattern_index] == b'*' {
star_index = Some(pattern_index);
star_value_index = value_index;
pattern_index += 1;
} else if let Some(star) = star_index {
pattern_index = star + 1;
star_value_index += 1;
value_index = star_value_index;
} else {
return false;
}
}
while pattern_index < pattern.len() && pattern[pattern_index] == b'*' {
pattern_index += 1;
}
pattern_index == pattern.len()
}
fn scrollback_rule_mode(
rules: &[bmux_config::ScrollbackPaneRule],
metadata: &crate::pane_runtime_client::PaneScrollbackRuleMetadata,
) -> Option<bmux_config::ScrollbackMode> {
rules.iter().find_map(|rule| {
let command_matches = rule.match_command.as_ref().is_none_or(|pattern| {
metadata
.active_command
.as_deref()
.is_some_and(|value| wildcard_matches(pattern, value))
});
let name_matches = rule.match_name.as_ref().is_none_or(|pattern| {
metadata
.name
.as_deref()
.is_some_and(|value| wildcard_matches(pattern, value))
});
let shell_matches = rule
.match_shell
.as_ref()
.is_none_or(|pattern| wildcard_matches(pattern, &metadata.shell));
(command_matches && name_matches && shell_matches).then_some(rule.mode)
})
}
async fn effective_scrollback_mode(
client: &mut StreamingBmuxClient,
config: &BmuxConfig,
session_id: Uuid,
pane_id: Uuid,
) -> bmux_config::ScrollbackMode {
if let Some(mode) = cached_mode(
&ConfigPaths::default(),
session_id,
pane_id,
config.behavior.scrollback.persist_pane_modes,
) {
return mode;
}
if !config.behavior.scrollback.pane_rules.is_empty()
&& let Ok(metadata) =
pane_scrollback_rule_metadata_streaming(client, session_id, pane_id).await
&& let Some(mode) = scrollback_rule_mode(&config.behavior.scrollback.pane_rules, &metadata)
{
return mode;
}
config.behavior.scrollback.mode
}
async fn pin_focused_scrollback_if_configured(
client: &mut StreamingBmuxClient,
view_state: &mut AttachViewState,
config: &BmuxConfig,
now: Instant,
) {
let Some(pane_id) = view_state.focused_pane_id() else {
return;
};
if effective_scrollback_mode(client, config, view_state.attached_id, pane_id).await
!= bmux_config::ScrollbackMode::Frozen
{
return;
}
if view_state
.scrollback_for(pane_id)
.is_some_and(|view| view.pin.is_some())
{
return;
}
if config.behavior.scrollback.max_frozen_panes != 0
&& view_state
.pane_scrollback
.values()
.filter(|view| view.pin.is_some())
.count()
>= config.behavior.scrollback.max_frozen_panes
{
view_state.set_transient_status(
"frozen scrollback pane limit reached; using live scrollback",
now,
ATTACH_TRANSIENT_STATUS_TTL,
);
return;
}
match attach_pane_scrollback_pin_streaming(client, view_state.attached_id, pane_id).await {
Ok(pin) => {
if let Some(view) = view_state.scrollback_for_mut(pane_id) {
view.pin = Some(ScrollbackPin {
capture: Some(pin.capture),
pin_id: pin.pin_id,
total_scrolled_rows: pin.total_scrolled_rows,
max_scrollback_offset: pin.max_scrollback_offset,
stream_end: pin.stream_end,
created_epoch_secs: epoch_secs_now(),
});
}
}
Err(error) => view_state.set_transient_status(
format!("frozen scrollback unavailable; using live scrollback: {error}"),
now,
ATTACH_TRANSIENT_STATUS_TTL,
),
}
}
fn epoch_secs_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| duration.as_secs())
}
fn expired_scrollback_pins(
view_state: &AttachViewState,
now_epoch_secs: u64,
timeout_secs: u64,
) -> Vec<(Uuid, ScrollbackPin)> {
if timeout_secs == 0 {
return Vec::new();
}
view_state
.pane_scrollback
.iter()
.filter_map(|(pane_id, view)| {
let pin = view.pin?;
(now_epoch_secs.saturating_sub(pin.created_epoch_secs) >= timeout_secs)
.then_some((*pane_id, pin))
})
.collect()
}
async fn unpin_scrollback(
client: &mut StreamingBmuxClient,
session_id: Uuid,
pane_id: Uuid,
pin: ScrollbackPin,
) {
let _ = attach_pane_scrollback_unpin_streaming(client, session_id, pane_id, pin.pin_id).await;
}
async fn handle_attach_ui_action_with_scrollback(
client: &mut StreamingBmuxClient,
action: &RuntimeAction,
view_state: &mut AttachViewState,
now: Instant,
) -> std::result::Result<(), ClientError> {
let active_pin_before = view_state.focused_pane_id().and_then(|pane_id| {
view_state
.scrollback_for(pane_id)
.and_then(|view| view.pin)
.map(|pin| (pane_id, pin))
});
let entering =
matches!(action, RuntimeAction::EnterScrollMode) && !view_state.scrollback_active();
let needs_max_before = matches!(
action,
RuntimeAction::ScrollUpLine
| RuntimeAction::ScrollUpPage
| RuntimeAction::ScrollTop
| RuntimeAction::MoveCursorUp
) && view_state.scrollback_active();
if needs_max_before {
ensure_pane_scrollback_windows(client, view_state, false)?;
}
handle_attach_ui_action_at(action, view_state, now);
if let Some((pane_id, pin)) = active_pin_before
&& !view_state.scrollback_active_for(pane_id)
{
unpin_scrollback(client, view_state.attached_id, pane_id, pin).await;
view_state.scrollback_replay_pending = true;
}
if entering
&& view_state.scrollback_active()
&& !view_state
.focused_pane_id()
.is_some_and(|pane| view_state.captured_alternate.contains(&pane))
{
let config = BmuxConfig::load().unwrap_or_default();
pin_focused_scrollback_if_configured(client, view_state, &config, now).await;
}
if !view_state.pane_scrollback.is_empty() {
ensure_pane_scrollback_windows(client, view_state, false)?;
}
Ok(())
}
#[allow(clippy::too_many_lines)] async fn hydrate_attach_state_from_snapshot_mode(
client: &mut StreamingBmuxClient,
view_state: &mut AttachViewState,
mode: SnapshotHydrationMode,
) -> std::result::Result<(), ClientError> {
let AttachSnapshotState {
context_id: _,
session_id,
focused_pane_id,
panes,
layout_root,
scene,
chunks,
pane_mouse_protocols,
pane_input_modes,
zoomed,
} = client
.attach_snapshot(
view_state.attached_id,
ATTACH_STRUCTURED_SNAPSHOT_MAX_BYTES_PER_PANE,
)
.await?;
let active_pane_ids = panes
.iter()
.map(|pane| pane.id)
.collect::<std::collections::BTreeSet<_>>();
let full_resync = mode == SnapshotHydrationMode::FullResync;
let session_changed = view_state
.cached_layout_state
.as_ref()
.is_none_or(|layout| layout.session_id != session_id);
if session_changed || full_resync {
view_state.pane_buffers.clear();
view_state.pane_mouse_protocol_hints.clear();
view_state.pane_input_mode_hints.clear();
view_state.captured_alternate.clear();
view_state.pane_scrollback.clear();
view_state.decoded_history.clear();
view_state.history_prefetch.clear();
view_state.pending_scroll.clear();
view_state.scroll_boundary.clear();
view_state.scroll_started.clear();
view_state.scrollback_fetch = None;
view_state.scrollback_cache = super::scrollback_cache::ScrollbackCache::default();
} else {
view_state
.pane_buffers
.retain(|pane_id, _| active_pane_ids.contains(pane_id));
view_state
.pane_mouse_protocol_hints
.retain(|pane_id, _| active_pane_ids.contains(pane_id));
view_state
.pane_input_mode_hints
.retain(|pane_id, _| active_pane_ids.contains(pane_id));
view_state.retain_scrollback_panes(&active_pane_ids);
}
let retained_pane_ids = view_state
.pane_buffers
.keys()
.copied()
.collect::<std::collections::BTreeSet<_>>();
view_state.cached_layout_state = Some(AttachLayoutState {
context_id: None,
session_id,
focused_pane_id,
panes,
layout_root,
scene,
zoomed,
});
view_state.mouse.last_focused_pane_id = Some(focused_pane_id);
for pane_protocol in pane_mouse_protocols {
if active_pane_ids.contains(&pane_protocol.pane_id) {
view_state
.pane_mouse_protocol_hints
.insert(pane_protocol.pane_id, pane_protocol.protocol);
}
}
for pane_mode in pane_input_modes {
if active_pane_ids.contains(&pane_mode.pane_id) {
view_state
.pane_input_mode_hints
.insert(pane_mode.pane_id, pane_mode.mode);
}
}
if let Some(layout_state) = view_state.cached_layout_state.as_ref() {
let geometry = view_state
.driver_geometry
.unwrap_or_else(current_attach_terminal_geometry);
resize_attach_grids_for_scene_with_size(
&mut view_state.pane_buffers,
&layout_state.scene,
geometry.cols,
geometry.rows,
);
}
let structured_hydrated = hydrate_attach_structured_grid_snapshots(
client,
view_state,
active_pane_ids.iter().copied().collect(),
)
.await?;
for chunk in chunks {
if structured_hydrated.contains(&chunk.pane_id) {
if let Some(buffer) = view_state.pane_buffers.get_mut(&chunk.pane_id) {
buffer.sync_update_in_progress = chunk.sync_update_active;
buffer.expected_stream_start = Some(chunk.stream_end);
}
continue;
}
if !session_changed && !full_resync && retained_pane_ids.contains(&chunk.pane_id) {
continue;
}
let _ = apply_attach_output_chunk_protocol_only(
view_state,
chunk.pane_id,
&chunk.data,
AttachOutputChunkMeta {
stream_start: chunk.stream_start,
stream_end: chunk.stream_end,
stream_gap: chunk.stream_gap,
sync_update_active: chunk.sync_update_active,
},
);
if let Some(buffer) = view_state.pane_buffers.get_mut(&chunk.pane_id) {
buffer.sync_update_in_progress = chunk.sync_update_active;
buffer.expected_stream_start = Some(chunk.stream_end);
}
}
view_state.dirty.layout_needs_refresh = false;
view_state
.dirty
.mark_full_frame(AttachDirtySource::SnapshotHydration);
Ok(())
}
async fn hydrate_attach_revealed_panes_from_snapshot(
client: &mut StreamingBmuxClient,
view_state: &mut AttachViewState,
layout_state: &AttachLayoutState,
pane_ids: &[Uuid],
) -> std::result::Result<(), ClientError> {
if pane_ids.is_empty() {
return Ok(());
}
let requested_pane_ids = pane_ids.iter().copied().collect::<BTreeSet<_>>();
let AttachPaneSnapshotState {
chunks,
pane_mouse_protocols,
pane_input_modes,
} = client
.attach_pane_snapshot(
view_state.attached_id,
pane_ids.to_vec(),
ATTACH_STRUCTURED_SNAPSHOT_MAX_BYTES_PER_PANE,
)
.await?;
for pane_id in pane_ids {
view_state
.pane_buffers
.insert(*pane_id, PaneRenderBuffer::default());
}
let geometry = view_state
.driver_geometry
.unwrap_or_else(current_attach_terminal_geometry);
resize_attach_grids_for_scene_with_size(
&mut view_state.pane_buffers,
&layout_state.scene,
geometry.cols,
geometry.rows,
);
let structured_hydrated =
hydrate_attach_structured_grid_snapshots(client, view_state, pane_ids.to_vec()).await?;
for chunk in chunks {
if !requested_pane_ids.contains(&chunk.pane_id) {
continue;
}
if structured_hydrated.contains(&chunk.pane_id) {
if let Some(buffer) = view_state.pane_buffers.get_mut(&chunk.pane_id) {
buffer.sync_update_in_progress = chunk.sync_update_active;
buffer.expected_stream_start = Some(chunk.stream_end);
}
continue;
}
let _ = apply_attach_output_chunk_protocol_only(
view_state,
chunk.pane_id,
&chunk.data,
AttachOutputChunkMeta {
stream_start: chunk.stream_start,
stream_end: chunk.stream_end,
stream_gap: chunk.stream_gap,
sync_update_active: chunk.sync_update_active,
},
);
if let Some(buffer) = view_state.pane_buffers.get_mut(&chunk.pane_id) {
buffer.sync_update_in_progress = chunk.sync_update_active;
buffer.expected_stream_start = Some(chunk.stream_end);
}
}
for pane_protocol in pane_mouse_protocols {
if requested_pane_ids.contains(&pane_protocol.pane_id) {
view_state
.pane_mouse_protocol_hints
.insert(pane_protocol.pane_id, pane_protocol.protocol);
}
}
for pane_mode in pane_input_modes {
if requested_pane_ids.contains(&pane_mode.pane_id) {
view_state
.pane_input_mode_hints
.insert(pane_mode.pane_id, pane_mode.mode);
}
}
for pane_id in pane_ids {
view_state
.dirty
.mark_pane_dirty(*pane_id, AttachDirtySource::SnapshotHydration);
}
Ok(())
}
pub fn attach_scene_revealed_pane_ids(
previous: &AttachScene,
next: &AttachScene,
) -> BTreeSet<Uuid> {
bmux_attach_pipeline::reconcile::attach_scene_revealed_pane_ids(previous, next)
}
pub fn attach_layout_pane_id_set(layout_state: &AttachLayoutState) -> BTreeSet<Uuid> {
bmux_attach_pipeline::reconcile::attach_layout_pane_id_set(layout_state)
}
pub fn attach_layout_requires_snapshot_hydration(
previous: &AttachLayoutState,
next: &AttachLayoutState,
) -> bool {
bmux_attach_pipeline::reconcile::attach_layout_requires_snapshot_hydration(previous, next)
}
fn selected_attach_geometry(view: &AttachViewState) -> TerminalGeometry {
view.driver_geometry
.unwrap_or_else(current_attach_terminal_geometry)
}
fn current_attach_terminal_geometry() -> TerminalGeometry {
let (cols, rows) = terminal::size().unwrap_or((0, 0));
TerminalGeometry { cols, rows }
}
pub fn resize_attach_grids_for_scene_with_size(
pane_buffers: &mut std::collections::BTreeMap<Uuid, attach::state::PaneRenderBuffer>,
scene: &AttachScene,
cols: u16,
rows: u16,
) {
bmux_attach_pipeline::reconcile::resize_attach_grids_for_scene_with_size(
pane_buffers,
scene,
cols,
rows,
);
}
#[allow(clippy::too_many_arguments)]
pub async fn handle_attach_loop_event(
event: AttachLoopEvent,
client: &mut StreamingBmuxClient,
attach_input_processor: &mut InputProcessor,
_follow_target_id: Option<Uuid>,
_self_client_id: Option<Uuid>,
_global: bool,
help_lines: &[String],
view_state: &mut AttachViewState,
display_capture: &mut DisplayCaptureFanout,
kernel_client_factory: Option<&KernelClientFactory>,
) -> Result<AttachLoopControl> {
match event {
AttachLoopEvent::Server(_server_event) => Ok(AttachLoopControl::Continue),
AttachLoopEvent::Terminal(terminal_event) => {
handle_attach_terminal_event(
client,
terminal_event,
attach_input_processor,
help_lines,
view_state,
display_capture,
kernel_client_factory,
)
.await
}
AttachLoopEvent::ActionDispatch(dispatch_request) => {
handle_attach_action_dispatch(
client,
dispatch_request,
view_state,
kernel_client_factory,
false,
)
.await
}
}
}
async fn handle_attached_session_removed(
client: &mut StreamingBmuxClient,
view_state: &mut AttachViewState,
session_id: Uuid,
) -> Result<Option<AttachLoopControl>> {
if session_id != view_state.attached_id {
return Ok(None);
}
let removed_session_id = view_state.attached_id;
if recover_attach_after_session_removed(client, view_state).await? {
view_state.set_transient_status(
format!(
"session {} closed; switched to active session",
short_uuid(removed_session_id)
),
Instant::now(),
ATTACH_TRANSIENT_STATUS_TTL,
);
return Ok(Some(AttachLoopControl::Continue));
}
Ok(Some(AttachLoopControl::Break(
AttachExitReason::StreamClosed,
)))
}
async fn handle_control_catalog_changed(
client: &mut StreamingBmuxClient,
view_state: &mut AttachViewState,
revision: u64,
full_resync: bool,
) {
if full_resync || revision > view_state.control_catalog_revision {
if let Err(error) = refresh_attach_status_catalog(client, view_state).await {
view_state.set_transient_status(
format!("catalog refresh failed: {error:#}"),
Instant::now(),
ATTACH_TRANSIENT_STATUS_TTL,
);
} else if let Err(error) = reconcile_attached_session_from_catalog(client, view_state).await
{
view_state.set_transient_status(
format!(
"catalog reconcile failed: {}",
map_attach_client_error(error)
),
Instant::now(),
ATTACH_TRANSIENT_STATUS_TTL,
);
}
}
}
async fn handle_clients_plugin_event(
client: &mut StreamingBmuxClient,
follow_target_id: Option<Uuid>,
self_client_id: Option<Uuid>,
view_state: &mut AttachViewState,
event: bmux_clients_plugin_api::clients_events::ClientEvent,
) -> Result<()> {
match event {
bmux_clients_plugin_api::clients_events::ClientEvent::FollowTargetChanged {
follower_client_id,
leader_client_id,
context_id,
session_id,
} => {
if Some(leader_client_id) != follow_target_id
|| Some(follower_client_id) != self_client_id
{
return Ok(());
}
let attach_info = if let Some(context_id) = context_id {
open_attach_for_context(client, context_id)
.await
.map_err(map_attach_client_error)?
} else if view_state.attached_context_id.is_none() {
open_attach_for_session(client, session_id)
.await
.map_err(map_attach_client_error)?
} else {
return Ok(());
};
view_state.attached_id = attach_info.session_id;
view_state.last_attach_view_revision = None;
view_state.attached_context_id = attach_info.context_id.or(context_id);
view_state.can_write = attach_info.can_write;
update_attach_viewport(client, view_state).await?;
hydrate_attach_state_from_snapshot(client, view_state)
.await
.map_err(map_attach_client_error)?;
refresh_attach_status_catalog_best_effort(client, view_state).await;
view_state.ui_mode = AttachUiMode::Normal;
if !view_state.can_write {
view_state.set_transient_status(
"read-only attach: input disabled".to_string(),
Instant::now(),
ATTACH_TRANSIENT_STATUS_TTL,
);
}
view_state
.dirty
.mark_layout_frame_dirty(AttachDirtySource::FollowTargetChanged);
}
bmux_clients_plugin_api::clients_events::ClientEvent::FollowTargetGone {
former_leader_client_id,
..
} if Some(former_leader_client_id) == follow_target_id => {
view_state.set_transient_status(
"follow target disconnected; staying on current session".to_string(),
Instant::now(),
ATTACH_TRANSIENT_STATUS_TTL,
);
}
_ => {}
}
Ok(())
}
pub fn apply_attach_view_change_components(
components: &[AttachViewComponent],
view_state: &mut AttachViewState,
) {
for component in components {
match component {
AttachViewComponent::Scene | AttachViewComponent::Layout => {
view_state
.dirty
.mark_layout_refresh(AttachDirtySource::SceneChanged);
}
AttachViewComponent::SurfaceContent => {
view_state
.dirty
.mark_layout_refresh(AttachDirtySource::SceneChanged);
}
}
}
}
pub async fn recover_attach_after_session_removed(
client: &mut StreamingBmuxClient,
view_state: &mut AttachViewState,
) -> std::result::Result<bool, ClientError> {
refresh_attach_status_catalog_best_effort(client, view_state).await;
if let Ok(Some(context)) = typed_current_context_attach(client).await
&& retarget_attach_to_context(client, view_state, context.id)
.await
.is_ok()
{
return Ok(true);
}
for context in view_state.cached_contexts.clone() {
if Some(context.id) == view_state.attached_context_id {
continue;
}
if retarget_attach_to_context(client, view_state, context.id)
.await
.is_ok()
{
return Ok(true);
}
}
let previous_session_id = view_state.attached_id;
for session in view_state.cached_sessions.clone() {
if session.id == previous_session_id {
continue;
}
let Ok(attach_info) = open_attach_for_session(client, session.id).await else {
continue;
};
view_state.attached_id = attach_info.session_id;
view_state.last_attach_view_revision = None;
view_state.attached_context_id = attach_info.context_id;
view_state.can_write = attach_info.can_write;
update_attach_viewport(client, view_state).await?;
hydrate_attach_state_from_snapshot(client, view_state).await?;
refresh_attach_status_catalog_best_effort(client, view_state).await;
view_state.ui_mode = AttachUiMode::Normal;
return Ok(true);
}
Ok(false)
}
pub fn attach_view_event_matches_target(
view_state: &AttachViewState,
event_context_id: Option<Uuid>,
event_session_id: Uuid,
) -> bool {
if let Some(attached_context_id) = view_state.attached_context_id {
return event_context_id == Some(attached_context_id);
}
event_session_id == view_state.attached_id
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct ClipboardRemotePayloadV1Request {
mime: String,
bytes: Vec<u8>,
text: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct ClipboardRemotePayloadV2Request {
mime: String,
bytes: Vec<u8>,
text: Option<String>,
source: String,
attempts: String,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct ClipboardRemoteMaterializeV1Response {
mime: String,
bytes_len: usize,
path: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct ClipboardRemoteMaterializeV2Response {
mime: String,
bytes_len: usize,
path: Option<String>,
clipboard_written: bool,
backend: Option<String>,
warning: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ClipboardSyncTrigger {
Connect,
FocusGained,
ActivityAfterIdle,
}
async fn maybe_sync_remote_clipboard(
client: &mut StreamingBmuxClient,
view_state: &mut AttachViewState,
trigger: ClipboardSyncTrigger,
now: Instant,
) {
if let Err(error) = sync_remote_clipboard(client, view_state, trigger, now).await {
tracing::warn!(
error = %error,
"remote clipboard sync failed"
);
}
}
#[tracing::instrument(
skip(client, view_state),
fields(
bmux.component = "clipboard.remote_sync",
bmux.operation = "sync",
trigger = ?trigger,
)
)]
#[allow(clippy::too_many_lines)]
async fn sync_remote_clipboard(
client: &mut StreamingBmuxClient,
view_state: &mut AttachViewState,
trigger: ClipboardSyncTrigger,
now: Instant,
) -> Result<()> {
let config = BmuxConfig::load().unwrap_or_default();
let sync = &config.clipboard.remote_sync;
if !sync.enabled
|| sync.mode == ClipboardRemoteSyncMode::Off
|| sync.mode == ClipboardRemoteSyncMode::Manual
{
tracing::debug!(
enabled = sync.enabled,
mode = ?sync.mode,
"remote clipboard sync disabled by config"
);
return Ok(());
}
if sync.mode != ClipboardRemoteSyncMode::FocusLazy {
tracing::debug!(mode = ?sync.mode, "remote clipboard sync mode not active for focus-lazy runtime");
return Ok(());
}
if let Some(last_attempt) = view_state.clipboard_sync_state.attempt_at {
let min_interval = Duration::from_millis(sync.min_interval_ms);
let debounce = Duration::from_millis(sync.debounce_ms);
let required = match trigger {
ClipboardSyncTrigger::FocusGained => min_interval.max(debounce),
ClipboardSyncTrigger::Connect | ClipboardSyncTrigger::ActivityAfterIdle => min_interval,
};
if now.duration_since(last_attempt) < required {
tracing::debug!("remote clipboard sync rate limited");
return Ok(());
}
}
view_state.clipboard_sync_state.attempt_at = Some(now);
tracing::debug!(
prefer_image = sync.images,
text_enabled = sync.text,
images_enabled = sync.images,
"remote clipboard local payload read starting"
);
let read_result = bmux_clipboard::read_payload_detailed(sync.images)
.map_err(|error| anyhow::anyhow!(error))?;
let source = read_result.source.as_str();
let attempts = read_result.report.attempts_summary();
let payload = read_result.payload;
let mime = payload.mime().to_string();
let bytes_len = payload.bytes_len();
tracing::debug!(
mime,
bytes_len,
source,
attempts = %attempts,
"remote clipboard payload read"
);
if mime == "text/plain" && !sync.text {
tracing::debug!("remote clipboard sync skipped because text is disabled");
return Ok(());
}
if mime == "image/png" && !sync.images {
tracing::debug!("remote clipboard sync skipped because images are disabled");
return Ok(());
}
let max_for_type = if mime == "text/plain" {
sync.max_text_bytes
} else {
sync.max_image_bytes
};
let bytes_len_u64 = u64::try_from(bytes_len).unwrap_or(u64::MAX);
if bytes_len_u64 > max_for_type || bytes_len_u64 > sync.max_total_bytes {
tracing::debug!(
mime,
bytes_len,
"remote clipboard sync skipped oversized payload"
);
return Ok(());
}
let (bytes, text) = match payload {
bmux_clipboard::ClipboardPayload::Text(text) => (text.as_bytes().to_vec(), Some(text)),
bmux_clipboard::ClipboardPayload::ImagePng(bytes) => (bytes, None),
};
let payload_hash = clipboard_payload_hash(&mime, &bytes);
if view_state.clipboard_sync_state.payload_hash.as_deref() == Some(payload_hash.as_str()) {
tracing::debug!(
hash = %payload_hash,
mime,
bytes_len,
source,
attempts = %attempts,
"remote clipboard sync skipped unchanged payload"
);
return Ok(());
}
let request_v2 = ClipboardRemotePayloadV2Request {
mime: mime.clone(),
bytes: bytes.clone(),
text: text.clone(),
source: source.to_string(),
attempts: attempts.clone(),
};
tracing::debug!(
hash = %payload_hash,
mime = request_v2.mime.as_str(),
bytes_len,
source,
attempts = %attempts,
protocol = "v2",
"remote clipboard materialization invoke started"
);
let v2_payload =
bmux_codec::to_positional_vec(&request_v2).context("encoding clipboard sync v2 payload")?;
let v2_future = client.invoke_service_raw(
"bmux.clipboard.remote_sync",
InvokeServiceKind::Command,
"clipboard-remote-sync/v2",
"materialize_payload",
v2_payload,
);
let v2_result = tokio::time::timeout(
Duration::from_millis(sync.request_timeout_ms.max(1)),
v2_future,
)
.await
.context("remote clipboard sync timed out")?;
let (protocol, path, clipboard_written, warning) = match v2_result {
Ok(response_bytes) => {
let response: ClipboardRemoteMaterializeV2Response =
bmux_codec::from_positional_bytes(&response_bytes)
.context("decoding clipboard materialize v2 response")?;
(
"v2",
response.path,
Some(response.clipboard_written),
response.warning,
)
}
Err(
error @ ClientError::ServerError {
code: bmux_ipc::ErrorCode::NotFound,
..
},
) => {
tracing::debug!(
error = %error,
protocol = "v1",
"remote clipboard v2 unavailable; falling back to v1"
);
let request_v1 = ClipboardRemotePayloadV1Request {
mime: mime.clone(),
bytes,
text,
};
let v1_payload = bmux_codec::to_positional_vec(&request_v1)
.context("encoding clipboard sync v1 payload")?;
let v1_future = client.invoke_service_raw(
"bmux.clipboard.remote_sync",
InvokeServiceKind::Command,
"clipboard-remote-sync/v1",
"materialize_payload",
v1_payload,
);
let response_bytes = tokio::time::timeout(
Duration::from_millis(sync.request_timeout_ms.max(1)),
v1_future,
)
.await
.context("remote clipboard sync timed out")?
.map_err(|error| anyhow::anyhow!(error))?;
let response: ClipboardRemoteMaterializeV1Response =
bmux_codec::from_positional_bytes(&response_bytes)
.context("decoding clipboard materialize v1 response")?;
("v1", response.path, None, None)
}
Err(error) => return Err(anyhow::anyhow!(error)),
};
view_state.clipboard_sync_state.payload_hash = Some(payload_hash.clone());
if clipboard_written.unwrap_or(true) {
tracing::info!(
hash = %payload_hash,
mime,
bytes_len,
source,
attempts = %attempts,
protocol,
remote_clipboard_written = clipboard_written.map_or("unknown", |written| if written { "true" } else { "false" }),
fallback_path = path.as_deref().unwrap_or(""),
"remote clipboard sync completed"
);
} else {
tracing::warn!(
hash = %payload_hash,
mime,
bytes_len,
source,
attempts = %attempts,
protocol,
remote_clipboard_written = "false",
fallback_path = path.as_deref().unwrap_or(""),
warning = warning.as_deref().unwrap_or(""),
"remote clipboard sync completed without OS clipboard write"
);
}
Ok(())
}
fn clipboard_payload_hash(mime: &str, bytes: &[u8]) -> String {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
mime.hash(&mut hasher);
bytes.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
#[allow(clippy::too_many_lines)]
#[allow(clippy::too_many_arguments)] pub async fn handle_attach_terminal_event(
client: &mut StreamingBmuxClient,
terminal_event: AttachTerminalEvent,
attach_input_processor: &mut InputProcessor,
help_lines: &[String],
view_state: &mut AttachViewState,
display_capture: &mut DisplayCaptureFanout,
kernel_client_factory: Option<&KernelClientFactory>,
) -> Result<AttachLoopControl> {
let now = terminal_event.received_at;
let geometry = terminal_event.geometry;
view_state.driver_geometry = Some(geometry);
let normalized_event = terminal_event.normalized;
let raw_event = terminal_event.raw;
match &normalized_event {
Some(TerminalInputEvent::FocusGained) => {
maybe_sync_remote_clipboard(client, view_state, ClipboardSyncTrigger::FocusGained, now)
.await;
}
Some(TerminalInputEvent::FocusLost | TerminalInputEvent::Resize { .. }) => {
view_state.mouse.clear_pointer_gestures();
clear_plugin_surface_focus(view_state);
}
Some(
TerminalInputEvent::Key(_)
| TerminalInputEvent::Mouse(_)
| TerminalInputEvent::Paste(_),
) => {
if view_state
.clipboard_sync_state
.activity_at
.is_some_and(|last| {
now.duration_since(last)
>= Duration::from_millis(
BmuxConfig::load()
.unwrap_or_default()
.clipboard
.remote_sync
.idle_after_ms,
)
})
{
maybe_sync_remote_clipboard(
client,
view_state,
ClipboardSyncTrigger::ActivityAfterIdle,
now,
)
.await;
}
view_state.clipboard_sync_state.activity_at = Some(now);
}
_ => {}
}
if matches!(&raw_event, Event::Resize(_, _)) {
prepare_attach_selections_for_resize(view_state);
view_state.driver_geometry = Some(geometry);
update_attach_viewport_with_geometry(client, view_state, geometry).await?;
}
if view_state.prompt.is_active() {
let prompt_disposition = match &normalized_event {
Some(TerminalInputEvent::Key(key))
if prompt_accepts_key_kind(key.kind.to_crossterm()) =>
{
Some(view_state.prompt.handle_terminal_key_event(key))
}
Some(TerminalInputEvent::Paste(text)) => Some(view_state.prompt.handle_paste(text)),
Some(
TerminalInputEvent::Key(_)
| TerminalInputEvent::FocusGained
| TerminalInputEvent::FocusLost,
) => Some(PromptKeyDisposition::NotActive),
Some(TerminalInputEvent::Mouse(mouse)) => Some(
view_state
.prompt
.handle_terminal_mouse_event(*mouse, geometry),
),
_ => match &raw_event {
Event::Key(key) if prompt_accepts_key_kind(key.kind) => {
Some(view_state.prompt.handle_key_event(key))
}
Event::Paste(text) => Some(view_state.prompt.handle_paste(text)),
Event::Key(_) => Some(PromptKeyDisposition::NotActive),
Event::Mouse(mouse) => Some(view_state.prompt.handle_mouse_event(*mouse, geometry)),
_ => None,
},
};
if let Some(disposition) = prompt_disposition {
match disposition {
PromptKeyDisposition::Completed(completion) => {
if let Some(control) =
handle_attach_prompt_completion_at(client, view_state, completion, now)
.await?
{
return Ok(control);
}
}
PromptKeyDisposition::Consumed => {
view_state
.dirty
.mark_overlay_dirty(AttachDirtySource::PromptOverlay);
}
PromptKeyDisposition::NotActive => {}
}
return Ok(AttachLoopControl::Continue);
}
}
if view_state.help_overlay_open {
let handled = match &normalized_event {
Some(TerminalInputEvent::Key(key)) => {
handle_help_overlay_terminal_key_event(key, help_lines, view_state, geometry)
}
_ => {
matches!(&raw_event, Event::Key(key) if handle_help_overlay_key_event(key, help_lines, view_state, geometry))
}
};
if handled {
return Ok(AttachLoopControl::Continue);
}
}
if let Some(TerminalInputEvent::Paste(text)) = &normalized_event
&& try_handle_plugin_surface_paste(view_state, text)
{
return Ok(AttachLoopControl::Continue);
}
if let Some(TerminalInputEvent::Key(key)) = &normalized_event {
match try_handle_plugin_surface_key(client, view_state, key).await {
Ok(true) => return Ok(AttachLoopControl::Continue),
Ok(false) => {}
Err(error) => {
view_state.set_transient_status(
format!(
"plugin surface input failed: {}",
map_attach_client_error(error)
),
now,
ATTACH_TRANSIENT_STATUS_TTL,
);
}
}
match try_handle_attach_input_hook_key(client, view_state, key).await {
Ok(true) => return Ok(AttachLoopControl::Continue),
Ok(false) => {}
Err(error) => {
view_state.set_transient_status(
format!("input hook failed: {}", map_attach_client_error(error)),
now,
ATTACH_TRANSIENT_STATUS_TTL,
);
}
}
}
if matches!(&raw_event, Event::Key(_)) {
let focused_input_mode = focused_attach_pane_input_mode(view_state);
attach_input_processor.set_pane_input_mode(
focused_input_mode.application_cursor,
focused_input_mode.application_keypad,
);
}
let attach_actions = if let Some(normalized_event) = &normalized_event {
attach_terminal_input_event_actions(
normalized_event,
attach_input_processor,
view_state.ui_mode,
)?
} else {
attach_event_actions(&raw_event, attach_input_processor, view_state.ui_mode)?
};
for attach_action in attach_actions {
match attach_action {
AttachEventAction::Detach => {
return try_detach_or_continue_at(client, view_state, now).await;
}
AttachEventAction::Paste(text) => {
if view_state.help_overlay_open
|| view_state.prompt.is_active()
|| !view_state.bracketed_paste_enabled
|| !view_state.can_write
{
continue;
}
let mode = focused_attach_pane_input_mode(view_state);
let bytes = encode_bracketed_paste(&text, mode.bracketed_paste);
if let Err(error) = send_attach_bytes_to_focused(client, view_state, bytes).await {
return Err(map_attach_client_error(error));
}
display_capture.record_activity(DisplayActivityKind::Input);
}
AttachEventAction::Send(bytes) => {
if view_state.help_overlay_open || view_state.prompt.is_active() {
continue;
}
if view_state.can_write {
if let Err(error) =
send_attach_bytes_to_focused(client, view_state, bytes).await
{
return Err(map_attach_client_error(error));
}
display_capture.record_activity(DisplayActivityKind::Input);
}
}
AttachEventAction::PluginCommand {
plugin_id,
command_name,
args,
} => {
if view_state.help_overlay_open || view_state.prompt.is_active() {
continue;
}
if let Err(error) = handle_attach_plugin_command_action(
client,
&plugin_id,
&command_name,
&args,
view_state,
kernel_client_factory,
active_keybindings_for_context(
attach_input_processor.keymap(),
attach_input_processor.active_mode_id(),
view_state.scrollback_active(),
),
)
.await
{
view_state.set_transient_status(
format!("plugin action failed: {}", map_attach_client_error(error)),
now,
ATTACH_TRANSIENT_STATUS_TTL,
);
}
attach_input_processor.set_scroll_mode(view_state.scrollback_active());
}
AttachEventAction::Mouse(mouse_event) => {
if let Err(error) = handle_attach_mouse_event_at(
client,
mouse_event,
view_state,
kernel_client_factory,
now,
geometry,
)
.await
{
view_state.set_transient_status(
format!("mouse action failed: {}", map_attach_client_error(error)),
now,
ATTACH_TRANSIENT_STATUS_TTL,
);
}
if let Err(error) = ensure_pane_scrollback_windows(client, view_state, false) {
view_state.set_transient_status(
format!(
"scrollback window fetch failed: {}",
map_attach_client_error(error)
),
now,
ATTACH_TRANSIENT_STATUS_TTL,
);
}
attach_input_processor.set_scroll_mode(view_state.scrollback_active());
}
AttachEventAction::Ui(action) => {
if let RuntimeAction::SwitchProfile(profile_id) = &action {
match apply_attach_profile_switch(
profile_id,
attach_input_processor,
view_state,
) {
Ok(()) => {
view_state
.dirty
.mark_layout_frame_dirty(AttachDirtySource::ProfileChanged);
}
Err(error) => {
view_state.set_transient_status(
format!("profile switch failed: {error}"),
now,
ATTACH_TRANSIENT_STATUS_TTL,
);
}
}
continue;
}
if matches!(action, RuntimeAction::ShowHelp) {
view_state.help_overlay_open = !view_state.help_overlay_open;
if view_state.help_overlay_open {
view_state.mouse.clear_pointer_gestures();
} else {
view_state.help_overlay_scroll = 0;
}
view_state
.dirty
.mark_overlay_dirty(AttachDirtySource::HelpOverlay);
continue;
}
if view_state.help_overlay_open {
if matches!(action, RuntimeAction::ExitMode)
|| matches!(action, RuntimeAction::ForwardToPane(_))
{
view_state.help_overlay_open = false;
view_state.help_overlay_scroll = 0;
view_state
.dirty
.mark_overlay_dirty(AttachDirtySource::HelpOverlay);
}
continue;
}
let prompt_only_action = matches!(action, RuntimeAction::Quit)
|| is_windows_close_active_pane_action(&action);
if let Err(error) =
handle_attach_ui_action_with_scrollback(client, &action, view_state, now).await
{
view_state.set_transient_status(
format!(
"scrollback window fetch failed: {}",
map_attach_client_error(error)
),
now,
ATTACH_TRANSIENT_STATUS_TTL,
);
}
if prompt_only_action && view_state.prompt.is_active() {
view_state
.dirty
.mark_overlay_dirty(AttachDirtySource::PromptOverlay);
} else {
view_state
.dirty
.mark_layout_frame_dirty(AttachDirtySource::UserAction);
}
attach_input_processor.set_scroll_mode(view_state.scrollback_active());
}
AttachEventAction::Redraw => {
view_state
.dirty
.mark_layout_refresh(AttachDirtySource::ManualRedraw);
view_state
.dirty
.mark_full_frame(AttachDirtySource::ManualRedraw);
}
AttachEventAction::Ignore => {}
}
sync_attach_active_mode_from_processor(
view_state,
attach_input_processor.keymap(),
attach_input_processor.active_mode_id(),
);
}
Ok(AttachLoopControl::Continue)
}
pub const fn prompt_response_is_confirmed(response: &PromptResponse) -> bool {
matches!(
response,
PromptResponse::Submitted(PromptValue::Confirm(true))
)
}
const fn prompt_response_single_value(response: &PromptResponse) -> Option<&str> {
match response {
PromptResponse::Submitted(PromptValue::Single(value)) => Some(value.as_str()),
_ => None,
}
}
async fn retarget_attach_to_session(
client: &mut StreamingBmuxClient,
view_state: &mut AttachViewState,
session_id: Uuid,
) -> std::result::Result<(), ClientError> {
let attach_info = open_attach_for_session(client, session_id).await?;
view_state.attached_id = attach_info.session_id;
view_state.last_attach_view_revision = None;
view_state.attached_context_id = attach_info.context_id;
view_state.can_write = attach_info.can_write;
update_attach_viewport(client, view_state).await?;
hydrate_attach_state_from_snapshot(client, view_state).await?;
refresh_attach_status_catalog_best_effort(client, view_state).await;
view_state.ui_mode = AttachUiMode::Normal;
Ok(())
}
async fn kill_session_for_safe_close(
client: &mut StreamingBmuxClient,
session_id: Uuid,
) -> std::result::Result<(), ClientError> {
match typed_kill_session_attach(client, SessionSelector::ById(session_id), false).await {
Ok(Ok(_)) => Ok(()),
Ok(Err(err)) => Err(ClientError::ServerError {
code: bmux_ipc::ErrorCode::Internal,
message: format!("kill-session failed: {err:?}"),
}),
Err(error) => Err(error),
}
}
async fn close_pane_by_id_for_prompt(
client: &mut StreamingBmuxClient,
pane_id: Uuid,
) -> std::result::Result<(), ClientError> {
let _ack: bmux_tabs_plugin_api::tabs_commands::PaneAck = invoke_windows_command(
client,
"focus-pane",
&tabs_commands::client::FocusPaneRequest { id: pane_id },
)
.await?;
let _ack: bmux_tabs_plugin_api::tabs_commands::PaneAck = invoke_windows_command(
client,
"close-pane",
&tabs_commands::client::ClosePaneRequest { id: pane_id },
)
.await?;
Ok(())
}
async fn retarget_attach_to_close_fallback(
client: &mut StreamingBmuxClient,
view_state: &mut AttachViewState,
target: AttachCloseFallbackTarget,
) -> std::result::Result<(), ClientError> {
refresh_attach_status_catalog(client, view_state)
.await
.map_err(|error| ClientError::ServerError {
code: bmux_ipc::ErrorCode::Internal,
message: format!("refreshing close fallback catalog failed: {error:#}"),
})?;
match target {
AttachCloseFallbackTarget::Context { context_id }
if view_state
.cached_contexts
.iter()
.any(|context| context.id == context_id)
&& context_session_id(&view_state.cached_context_session_bindings, context_id)
.is_some_and(|session_id| session_id != view_state.attached_id) =>
{
retarget_attach_to_context(client, view_state, context_id).await
}
AttachCloseFallbackTarget::Session { session_id }
if session_id != view_state.attached_id
&& view_state
.cached_sessions
.iter()
.any(|session| session.id == session_id) =>
{
retarget_attach_to_session(client, view_state, session_id).await
}
_ => {
let Some(fallback) = next_close_fallback(view_state) else {
return Err(ClientError::ServerError {
code: bmux_ipc::ErrorCode::Internal,
message: "close fallback is no longer available".to_string(),
});
};
match fallback.target {
AttachCloseFallbackTarget::Context { context_id } => {
retarget_attach_to_context(client, view_state, context_id).await
}
AttachCloseFallbackTarget::Session { session_id } => {
retarget_attach_to_session(client, view_state, session_id).await
}
}
}
}
}
async fn close_last_pane_after_retarget(
client: &mut StreamingBmuxClient,
view_state: &mut AttachViewState,
old_session_id: Uuid,
target: AttachCloseFallbackTarget,
) -> std::result::Result<(), ClientError> {
retarget_attach_to_close_fallback(client, view_state, target).await?;
kill_session_for_safe_close(client, old_session_id).await
}
async fn create_new_window_and_retarget(
client: &mut StreamingBmuxClient,
view_state: &mut AttachViewState,
) -> std::result::Result<(), ClientError> {
let ack = tabs_commands::client::new_tab(client, None)
.await
.map_err(|error| ClientError::ServerError {
code: bmux_ipc::ErrorCode::Internal,
message: format!("new-tab transport failed: {error}"),
})?
.map_err(|error| ClientError::ServerError {
code: bmux_ipc::ErrorCode::Internal,
message: format!("new-tab failed: {error:?}"),
})?;
let context_id = ack
.id
.as_deref()
.and_then(|id| Uuid::parse_str(id).ok())
.ok_or_else(|| ClientError::ServerError {
code: bmux_ipc::ErrorCode::Internal,
message: "new-tab did not return a context id".to_string(),
})?;
retarget_attach_to_context(client, view_state, context_id).await
}
async fn split_new_pane_then_close_old(
client: &mut StreamingBmuxClient,
pane_id: Uuid,
session_id: Uuid,
) -> std::result::Result<(), ClientError> {
tabs_commands::client::split_pane(
client,
Some(ipc_to_windows_selector(SessionSelector::ById(session_id))),
Some(pane_id_windows_selector(pane_id)),
tabs_commands::PaneDirection::Horizontal,
None,
)
.await
.map_err(|error| ClientError::ServerError {
code: bmux_ipc::ErrorCode::Internal,
message: format!("split-pane transport failed: {error}"),
})?
.map_err(|error| ClientError::ServerError {
code: bmux_ipc::ErrorCode::Internal,
message: format!("split-pane failed: {error:?}"),
})?;
close_pane_by_id_for_prompt(client, pane_id).await
}
fn set_close_prompt_error_at(view_state: &mut AttachViewState, error: ClientError, now: Instant) {
view_state.set_transient_status(
format!("close pane failed: {}", map_attach_client_error(error)),
now,
ATTACH_TRANSIENT_STATUS_TTL,
);
}
#[allow(clippy::too_many_lines)] pub async fn handle_attach_prompt_completion_at(
client: &mut StreamingBmuxClient,
view_state: &mut AttachViewState,
completion: AttachPromptCompletion,
now: Instant,
) -> std::result::Result<Option<AttachLoopControl>, ClientError> {
let mut requires_layout_refresh = false;
match completion.origin {
AttachPromptOrigin::External { response_tx, .. } => {
let _ = response_tx.send(completion.response);
}
AttachPromptOrigin::Internal(action) => match action {
AttachInternalPromptAction::QuitSession => {
if prompt_response_is_confirmed(&completion.response) {
let selector = attached_session_selector(view_state);
match typed_kill_session_attach(client, selector, false).await {
Ok(Ok(_)) => {
return Ok(Some(AttachLoopControl::Break(AttachExitReason::Quit)));
}
Ok(Err(err)) => {
let error = ClientError::ServerError {
code: bmux_ipc::ErrorCode::Internal,
message: format!("kill-session failed: {err:?}"),
};
let status = attach_quit_failure_status(&error);
view_state.set_transient_status(
status,
now,
ATTACH_TRANSIENT_STATUS_TTL,
);
}
Err(error) => {
let status = attach_quit_failure_status(&error);
view_state.set_transient_status(
status,
now,
ATTACH_TRANSIENT_STATUS_TTL,
);
}
}
} else {
view_state.set_transient_status(
"quit canceled",
now,
ATTACH_TRANSIENT_STATUS_TTL,
);
}
}
AttachInternalPromptAction::ClosePane { pane_id } => {
if prompt_response_is_confirmed(&completion.response) {
match close_pane_by_id_for_prompt(client, pane_id).await {
Ok(()) => {
view_state.set_transient_status(
"pane closed",
now,
ATTACH_TRANSIENT_STATUS_TTL,
);
requires_layout_refresh = true;
}
Err(error) => set_close_prompt_error_at(view_state, error, now),
}
} else {
view_state.set_transient_status(
"close pane canceled",
now,
ATTACH_TRANSIENT_STATUS_TTL,
);
}
}
AttachInternalPromptAction::CloseLastPaneAndSwitch {
old_session_id,
target,
} => {
if prompt_response_is_confirmed(&completion.response) {
match close_last_pane_after_retarget(client, view_state, old_session_id, target)
.await
{
Ok(()) => {
view_state.set_transient_status(
"pane closed; switched target",
now,
ATTACH_TRANSIENT_STATUS_TTL,
);
requires_layout_refresh = true;
}
Err(error) => set_close_prompt_error_at(view_state, error, now),
}
} else {
view_state.set_transient_status(
"close pane canceled",
now,
ATTACH_TRANSIENT_STATUS_TTL,
);
}
}
AttachInternalPromptAction::FinalPaneAction {
pane_id,
session_id,
} => match prompt_response_single_value(&completion.response) {
Some(FINAL_PANE_CHOICE_NEW_PANE) => {
match split_new_pane_then_close_old(client, pane_id, session_id).await {
Ok(()) => {
view_state.set_transient_status(
"new pane opened; old pane closed",
now,
ATTACH_TRANSIENT_STATUS_TTL,
);
requires_layout_refresh = true;
}
Err(error) => set_close_prompt_error_at(view_state, error, now),
}
}
Some(FINAL_PANE_CHOICE_NEW_SESSION) => {
let result = match create_new_window_and_retarget(client, view_state).await {
Ok(()) => kill_session_for_safe_close(client, session_id).await,
Err(error) => Err(error),
};
match result {
Ok(()) => {
view_state.set_transient_status(
"new session opened; old pane closed",
now,
ATTACH_TRANSIENT_STATUS_TTL,
);
requires_layout_refresh = true;
}
Err(error) => set_close_prompt_error_at(view_state, error, now),
}
}
Some(FINAL_PANE_CHOICE_QUIT) => {
match typed_kill_session_attach(
client,
SessionSelector::ById(session_id),
false,
)
.await
{
Ok(Ok(_)) => {
return Ok(Some(AttachLoopControl::Break(AttachExitReason::Quit)));
}
Ok(Err(err)) => set_close_prompt_error_at(
view_state,
ClientError::ServerError {
code: bmux_ipc::ErrorCode::Internal,
message: format!("kill-session failed: {err:?}"),
},
now,
),
Err(error) => set_close_prompt_error_at(view_state, error, now),
}
}
_ => {
view_state.set_transient_status(
"close pane canceled",
now,
ATTACH_TRANSIENT_STATUS_TTL,
);
}
},
},
}
view_state
.dirty
.mark_overlay_dirty(AttachDirtySource::PromptOverlay);
if requires_layout_refresh {
view_state
.dirty
.mark_layout_frame_dirty(AttachDirtySource::PromptOverlay);
}
Ok(None)
}
async fn handle_attach_action_dispatch(
client: &mut StreamingBmuxClient,
dispatch_request: bmux_plugin_sdk::ActionDispatchRequest,
view_state: &mut AttachViewState,
kernel_client_factory: Option<&KernelClientFactory>,
strict: bool,
) -> Result<AttachLoopControl> {
let now = SystemAttachClock.now();
let action_str = &dispatch_request.action;
debug!(action = %action_str, "attach action dispatch received");
let action = match parse_action(action_str) {
Ok(action) => action,
Err(error) => {
if strict {
return Err(error);
}
warn!(action = %action_str, %error, "invalid dispatched attach action");
view_state.set_transient_status(
format!("invalid dispatched action: {error}"),
now,
ATTACH_TRANSIENT_STATUS_TTL,
);
return Ok(AttachLoopControl::Continue);
}
};
let event_action = runtime_action_to_attach_event_action(action);
match event_action {
AttachEventAction::Detach => {
return try_detach_or_continue_at(client, view_state, now).await;
}
AttachEventAction::Send(bytes) => {
if view_state.can_write
&& let Err(error) = send_attach_bytes_to_focused(client, view_state, bytes).await
{
return Err(map_attach_client_error(error));
}
}
AttachEventAction::PluginCommand {
plugin_id,
command_name,
args,
} => {
if let Err(error) = handle_attach_plugin_command_action(
client,
&plugin_id,
&command_name,
&args,
view_state,
kernel_client_factory,
Vec::new(),
)
.await
{
if strict {
return Err(map_attach_client_error(error));
}
view_state.set_transient_status(
format!(
"dispatched plugin action failed: {}",
map_attach_client_error(error)
),
now,
ATTACH_TRANSIENT_STATUS_TTL,
);
}
}
AttachEventAction::Ui(ui_action) => {
if let Err(error) =
handle_attach_ui_action_with_scrollback(client, &ui_action, view_state, now).await
{
view_state.set_transient_status(
format!(
"scrollback window fetch failed: {}",
map_attach_client_error(error)
),
now,
ATTACH_TRANSIENT_STATUS_TTL,
);
}
view_state
.dirty
.mark_layout_frame_dirty(AttachDirtySource::ActionDispatch);
}
AttachEventAction::Redraw => {
view_state
.dirty
.mark_layout_frame_dirty(AttachDirtySource::ManualRedraw);
}
AttachEventAction::Paste(_) | AttachEventAction::Mouse(_) | AttachEventAction::Ignore => {}
}
Ok(AttachLoopControl::Continue)
}
async fn try_detach_or_continue_at(
client: &mut StreamingBmuxClient,
view_state: &mut AttachViewState,
now: Instant,
) -> Result<AttachLoopControl> {
match client.detach().await {
Ok(()) => Ok(AttachLoopControl::Break(AttachExitReason::Detached)),
Err(error) => {
view_state.set_transient_status(
format!("detach blocked: {}", map_attach_client_error(error)),
now,
ATTACH_TRANSIENT_STATUS_TTL,
);
Ok(AttachLoopControl::Continue)
}
}
}
pub const fn record_attach_mouse_event(
mouse_event: MouseEvent,
view_state: &mut AttachViewState,
now: Instant,
) {
view_state.mouse.last_position = Some((mouse_event.column, mouse_event.row));
view_state.mouse.last_event_at = Some(now);
}
#[allow(clippy::too_many_lines)] async fn invoke_plugin_surface_pointer_event(
client: &mut StreamingBmuxClient,
view_state: &mut AttachViewState,
event: bmux_attach_pipeline::RetainedPointerEvent,
modifiers: KeyModifiers,
) -> std::result::Result<bool, ClientError> {
let Some(endpoint) = view_state
.retained_compositor
.endpoint_for_hit(&event.hit)
.cloned()
else {
return Ok(false);
};
let (phase, button) = match event.phase {
bmux_attach_pipeline::RetainedPointerPhase::Enter => ("enter", None),
bmux_attach_pipeline::RetainedPointerPhase::Move => ("move", None),
bmux_attach_pipeline::RetainedPointerPhase::Leave => ("leave", None),
bmux_attach_pipeline::RetainedPointerPhase::Down => ("down", event.button),
bmux_attach_pipeline::RetainedPointerPhase::Up => ("up", event.button),
bmux_attach_pipeline::RetainedPointerPhase::Wheel => ("wheel", None),
bmux_attach_pipeline::RetainedPointerPhase::Drag => ("drag", event.button),
};
let button = button.map(|button| match button {
bmux_attach_pipeline::RetainedPointerButton::Primary => "left",
bmux_attach_pipeline::RetainedPointerButton::Secondary => "right",
bmux_attach_pipeline::RetainedPointerButton::Middle => "middle",
bmux_attach_pipeline::RetainedPointerButton::Other(_) => "other",
});
let hook_id = event.hit.region_id.as_ref().map_or_else(String::new, |id| {
format!(
"{}:{}:{}",
id.owner_plugin_id, id.surface_local_id, id.region_local_id
)
});
let input = AttachInputEvent {
hook_id,
event_kind: "pointer".to_owned(),
phase: phase.to_owned(),
button: button.map(str::to_owned),
key: None,
col: Some(event.hit.surface_x),
row: Some(event.hit.surface_y),
wheel_delta: event.wheel_delta,
modifiers: AttachInputModifiers {
control: modifiers.contains(KeyModifiers::CONTROL),
shift: modifiers.contains(KeyModifiers::SHIFT),
alt: modifiers.contains(KeyModifiers::ALT),
..Default::default()
},
focused_pane: None,
hovered_pane: None,
};
let result = if let Some(result) = view_state.presentation_input.invoke(&endpoint, &input) {
result
} else {
let payload = bmux_plugin_sdk::encode_service_message(&input).map_err(|error| {
ClientError::ServerError {
code: bmux_ipc::ErrorCode::Internal,
message: format!("encoding plugin surface pointer event: {error}"),
}
})?;
let response = client
.invoke_service_raw(
endpoint.capability,
InvokeServiceKind::Command,
endpoint.interface_id,
endpoint.operation,
payload,
)
.await?;
bmux_plugin_sdk::decode_service_message::<AttachInputResult>(&response).map_err(
|error| ClientError::ServerError {
code: bmux_ipc::ErrorCode::Internal,
message: format!("decoding plugin surface pointer result: {error}"),
},
)?
};
if result.consumed && !result.preserve_focus {
update_plugin_surface_focus(
&mut view_state.plugin_focus,
&view_state.retained_compositor,
&event,
true,
);
}
if result.capture_pointer {
view_state.plugin_pointer_router.capture(event.hit.clone());
view_state
.plugin_pointer_router
.preserve_capture_across_revisions(result.preserve_capture_across_revisions);
}
if result.release_capture {
let _ = view_state.plugin_pointer_router.release_capture();
if result.capture_keyboard.is_empty() {
let _ = view_state.plugin_focus.clear();
}
}
if result.dirty {
view_state
.dirty
.mark_extension_dirty(AttachDirtySource::PluginCommand);
}
if let Some(message) = result.status_message.as_deref() {
view_state.set_transient_status(
truncate_attach_status_message(message),
Instant::now(),
ATTACH_TRANSIENT_STATUS_TTL,
);
}
if let Some(invocation) = result.service_invocation.as_ref() {
return Ok(execute_surface_action(client, view_state, result.consumed, invocation).await);
}
Ok(result.consumed)
}
fn update_plugin_surface_focus(
focus: &mut bmux_attach_pipeline::RetainedFocusState,
compositor: &bmux_attach_pipeline::RetainedCompositor,
event: &bmux_attach_pipeline::RetainedPointerEvent,
consumed: bool,
) {
if consumed && event.phase == bmux_attach_pipeline::RetainedPointerPhase::Down {
let _ = focus.focus_hit(compositor, &event.hit);
}
}
fn clear_plugin_surface_focus(view_state: &mut AttachViewState) -> bool {
let Some(target) = view_state.plugin_focus.clear() else {
return false;
};
let Some(endpoint) = view_state.retained_compositor.endpoint_for_region(&target) else {
return false;
};
let hook_id = format!(
"{}:{}:{}",
target.owner_plugin_id, target.surface_local_id, target.region_local_id
);
if view_state
.presentation_input
.notify_focus_lost(endpoint, &hook_id)
{
view_state
.dirty
.mark_extension_dirty(AttachDirtySource::PluginCommand);
let _ = view_state.plugin_pointer_router.release_capture();
return true;
}
false
}
fn dismiss_plugin_focus_on_pointer_down(
view_state: &mut AttachViewState,
mouse_event: MouseEvent,
) -> bool {
if matches!(mouse_event.kind, MouseEventKind::Down(_)) {
let hit = view_state
.retained_compositor
.hit_test(mouse_event.column, mouse_event.row);
let target = hit.as_ref().and_then(|hit| hit.region_id.as_ref());
let observes_loss = view_state
.plugin_focus
.focused()
.and_then(|id| view_state.retained_compositor.endpoint_for_region(id))
.is_some_and(|endpoint| view_state.presentation_input.observes_focus_loss(endpoint));
let within_owned_modal = hit.as_ref().is_some_and(|hit| {
view_state.plugin_focus.focused().is_some_and(|target| {
view_state
.retained_compositor
.hit_in_focus_scope(target, hit)
})
});
if observes_loss && !within_owned_modal && view_state.plugin_focus.focused() != target {
return clear_plugin_surface_focus(view_state);
}
}
false
}
async fn try_handle_plugin_surface_mouse(
client: &mut StreamingBmuxClient,
view_state: &mut AttachViewState,
mouse_event: MouseEvent,
) -> std::result::Result<bool, ClientError> {
if dismiss_plugin_focus_on_pointer_down(view_state, mouse_event) {
return Ok(true);
}
let events = view_state
.plugin_pointer_router
.route_terminal_mouse(&view_state.retained_compositor, mouse_event);
let mut consumed = false;
for event in events {
let event_consumed =
invoke_plugin_surface_pointer_event(client, view_state, event, mouse_event.modifiers)
.await?;
consumed |= event_consumed;
}
if matches!(mouse_event.kind, MouseEventKind::Up(_)) {
let _ = view_state.plugin_pointer_router.release_capture();
}
if !consumed && matches!(mouse_event.kind, MouseEventKind::Down(_)) {
clear_plugin_surface_focus(view_state);
}
Ok(consumed)
}
fn try_handle_plugin_surface_paste(view_state: &mut AttachViewState, text: &str) -> bool {
let Some(target) = view_state.plugin_focus.focused() else {
return view_state.retained_compositor.has_modal_scope();
};
if !view_state.retained_compositor.focus_allowed(target) {
return true;
}
let Some(endpoint) = view_state.retained_compositor.endpoint_for_region(target) else {
return false;
};
let hook = format!(
"{}:{}:{}",
target.owner_plugin_id, target.surface_local_id, target.region_local_id
);
let Some(result) = view_state.presentation_input.paste(endpoint, &hook, text) else {
return false;
};
if result.dirty {
view_state
.dirty
.mark_extension_dirty(AttachDirtySource::PluginCommand);
}
if let Some(message) = result.status_message {
view_state.set_transient_status(
truncate_attach_status_message(&message),
Instant::now(),
ATTACH_TRANSIENT_STATUS_TTL,
);
}
result.consumed
}
fn dispatch_presentation_escape(view_state: &mut AttachViewState, key_name: &str) -> bool {
if key_name == "esc"
&& !view_state.retained_compositor.has_modal_scope()
&& view_state.plugin_focus.focused().is_none()
{
let input = AttachInputEvent {
hook_id: String::new(),
event_kind: "shortcut".into(),
phase: "press".into(),
key: Some(key_name.to_string()),
button: None,
col: None,
row: None,
wheel_delta: 0,
modifiers: AttachInputModifiers::default(),
focused_pane: None,
hovered_pane: None,
};
if let Some(result) = view_state.presentation_input.shortcut(&input) {
if result.dirty {
view_state
.dirty
.mark_retained_surfaces_dirty(AttachDirtySource::PluginCommand);
}
return true;
}
}
false
}
async fn try_handle_plugin_surface_key(
client: &mut StreamingBmuxClient,
view_state: &mut AttachViewState,
key: &TerminalKeyEvent,
) -> std::result::Result<bool, ClientError> {
let Some(key_name) = attach_input_key_name(key) else {
return Ok(false);
};
if dispatch_presentation_escape(view_state, &key_name) {
return Ok(true);
}
let Some(target) = view_state.plugin_focus.focused().cloned() else {
return Ok(view_state.retained_compositor.has_modal_scope());
};
if !view_state.retained_compositor.focus_allowed(&target) {
return Ok(true);
}
let Some(endpoint) = view_state
.retained_compositor
.endpoint_for_region(&target)
.cloned()
else {
let _ = view_state.plugin_focus.clear();
return Ok(false);
};
let input = AttachInputEvent {
hook_id: format!(
"{}:{}:{}",
target.owner_plugin_id, target.surface_local_id, target.region_local_id
),
event_kind: "key".to_owned(),
phase: match key.kind {
super::input::TerminalKeyPhase::Press => "press".to_owned(),
super::input::TerminalKeyPhase::Repeat => "repeat".to_owned(),
super::input::TerminalKeyPhase::Release => "release".to_owned(),
},
button: None,
key: Some(key_name),
col: None,
row: None,
wheel_delta: 0,
modifiers: AttachInputModifiers {
shift: key.modifiers.shift,
alt: key.modifiers.alt,
control: key.modifiers.control,
super_key: key.modifiers.super_key,
hyper: key.modifiers.hyper,
meta: key.modifiers.meta,
},
focused_pane: attach_input_focused_context(view_state),
hovered_pane: None,
};
let result = if let Some(result) = view_state.presentation_input.invoke(&endpoint, &input) {
result
} else {
let payload = bmux_plugin_sdk::encode_service_message(&input).map_err(|error| {
ClientError::ServerError {
code: bmux_ipc::ErrorCode::Internal,
message: format!("encoding plugin surface keyboard event: {error}"),
}
})?;
let response = client
.invoke_service_raw(
endpoint.capability,
InvokeServiceKind::Command,
endpoint.interface_id,
endpoint.operation,
payload,
)
.await?;
bmux_plugin_sdk::decode_service_message::<AttachInputResult>(&response).map_err(
|error| ClientError::ServerError {
code: bmux_ipc::ErrorCode::Internal,
message: format!("decoding plugin surface keyboard result: {error}"),
},
)?
};
if result.release_capture {
let _ = view_state.plugin_focus.clear();
}
if result.dirty {
view_state
.dirty
.mark_extension_dirty(AttachDirtySource::PluginCommand);
}
if let Some(message) = result.status_message.as_deref() {
view_state.set_transient_status(
truncate_attach_status_message(message),
Instant::now(),
ATTACH_TRANSIENT_STATUS_TTL,
);
}
if let Some(invocation) = result.service_invocation.as_ref() {
return Ok(execute_surface_action(client, view_state, result.consumed, invocation).await);
}
Ok(result.consumed)
}
async fn execute_surface_action(
client: &mut StreamingBmuxClient,
view_state: &mut AttachViewState,
consumed: bool,
invocation: &bmux_plugin::AttachInputServiceInvocation,
) -> bool {
let outcome = client
.invoke_service_raw(
invocation.endpoint.capability.clone(),
InvokeServiceKind::Command,
invocation.endpoint.interface_id.clone(),
invocation.endpoint.operation.clone(),
invocation.payload.clone(),
)
.await;
if let (Ok(payload), Some(endpoint)) = (&outcome, &invocation.response_endpoint)
&& let Some(result) = view_state.presentation_input.response(endpoint, payload)
{
if result.dirty {
view_state
.dirty
.mark_retained_surfaces_dirty(AttachDirtySource::PluginCommand);
}
if let Some(message) = result.status_message {
view_state.set_transient_status(message, Instant::now(), ATTACH_TRANSIENT_STATUS_TTL);
}
}
finish_surface_action(view_state, consumed, outcome)
}
fn finish_surface_action<T>(
view_state: &mut AttachViewState,
consumed: bool,
outcome: std::result::Result<T, ClientError>,
) -> bool {
if let Err(error) = outcome {
view_state.set_transient_status(
truncate_attach_status_message(&format!("plugin surface action failed: {error}")),
Instant::now(),
ATTACH_TRANSIENT_STATUS_TTL,
);
view_state
.dirty
.mark_extension_dirty(AttachDirtySource::PluginCommand);
}
consumed
}
fn attach_input_hooks(view_state: &AttachViewState) -> Vec<AttachInputHook> {
let mut hooks = Vec::new();
for extension in view_state.presentation_extensions.snapshot() {
extension.refresh_state();
hooks.extend(extension.input_hooks());
}
hooks.sort_by_key(|hook| std::cmp::Reverse(hook.priority));
hooks
}
async fn invoke_attach_input_hook(
client: &mut StreamingBmuxClient,
hook: &AttachInputHook,
event: AttachInputEvent,
) -> std::result::Result<AttachInputResult, ClientError> {
let payload = bmux_plugin_sdk::encode_service_message(&event).map_err(|error| {
ClientError::ServerError {
code: bmux_ipc::ErrorCode::Internal,
message: format!("encoding attach input hook event: {error}"),
}
})?;
let response = client
.invoke_service_raw(
hook.endpoint.capability.clone(),
InvokeServiceKind::Command,
hook.endpoint.interface_id.clone(),
hook.endpoint.operation.clone(),
payload,
)
.await?;
bmux_plugin_sdk::decode_service_message::<AttachInputResult>(&response).map_err(|error| {
ClientError::ServerError {
code: bmux_ipc::ErrorCode::Internal,
message: format!("decoding attach input hook result: {error}"),
}
})
}
fn apply_attach_input_result(
view_state: &mut AttachViewState,
hook: AttachInputHook,
result: &AttachInputResult,
) {
if let Some(message) = result.status_message.as_deref() {
view_state.set_transient_status(
truncate_attach_status_message(message),
Instant::now(),
ATTACH_TRANSIENT_STATUS_TTL,
);
}
if result.dirty {
view_state
.dirty
.mark_layout_frame_dirty(AttachDirtySource::PluginCommand);
}
if result.release_capture {
view_state.mouse.input_capture = None;
return;
}
if result.capture_pointer || !result.capture_keyboard.is_empty() {
let existing = view_state
.mouse
.input_capture
.as_ref()
.filter(|capture| capture.hook.id == hook.id);
let pointer = result.capture_pointer || existing.is_some_and(|capture| capture.pointer);
let keyboard_keys = if result.capture_keyboard.is_empty() {
existing.map_or_else(Vec::new, |capture| capture.keyboard_keys.clone())
} else {
result.capture_keyboard.clone()
};
view_state.mouse.input_capture = Some(AttachInputHookCapture {
hook,
pointer,
keyboard_keys,
});
}
}
const fn attach_input_event_modifiers(modifiers: KeyModifiers) -> AttachInputModifiers {
AttachInputModifiers {
shift: modifiers.contains(KeyModifiers::SHIFT),
alt: modifiers.contains(KeyModifiers::ALT),
control: modifiers.contains(KeyModifiers::CONTROL),
super_key: modifiers.contains(KeyModifiers::SUPER),
hyper: modifiers.contains(KeyModifiers::HYPER),
meta: modifiers.contains(KeyModifiers::META),
}
}
const fn attach_input_mouse_phase(kind: MouseEventKind) -> (&'static str, Option<&'static str>) {
match kind {
MouseEventKind::Down(button) => ("down", Some(mouse_button_name(button))),
MouseEventKind::Up(button) => ("up", Some(mouse_button_name(button))),
MouseEventKind::Drag(button) => ("drag", Some(mouse_button_name(button))),
MouseEventKind::Moved => ("move", None),
MouseEventKind::ScrollUp => ("scroll-up", None),
MouseEventKind::ScrollDown => ("scroll-down", None),
MouseEventKind::ScrollLeft => ("scroll-left", None),
MouseEventKind::ScrollRight => ("scroll-right", None),
}
}
const fn mouse_button_name(button: MouseButton) -> &'static str {
match button {
MouseButton::Left => "left",
MouseButton::Right => "right",
MouseButton::Middle => "middle",
}
}
fn attach_input_key_name(key: &TerminalKeyEvent) -> Option<String> {
if key.kind == super::input::TerminalKeyPhase::Release {
return None;
}
let name = match &key.code {
super::input::TerminalKeyCode::Up => "up".to_string(),
super::input::TerminalKeyCode::Down => "down".to_string(),
super::input::TerminalKeyCode::Left => "left".to_string(),
super::input::TerminalKeyCode::Right => "right".to_string(),
super::input::TerminalKeyCode::Esc => "esc".to_string(),
super::input::TerminalKeyCode::Enter => "enter".to_string(),
super::input::TerminalKeyCode::Tab => "tab".to_string(),
super::input::TerminalKeyCode::BackTab => "backtab".to_string(),
super::input::TerminalKeyCode::Delete => "delete".to_string(),
super::input::TerminalKeyCode::Home => "home".to_string(),
super::input::TerminalKeyCode::End => "end".to_string(),
super::input::TerminalKeyCode::Backspace => "backspace".to_string(),
super::input::TerminalKeyCode::Char(ch) => ch.to_string(),
_ => return None,
};
Some(name)
}
fn attach_input_pane_context_for_id(
view_state: &AttachViewState,
pane_id: Uuid,
) -> Option<AttachInputPaneContext> {
let layout_state = view_state.cached_layout_state.as_ref()?;
let focused = layout_state.focused_pane_id == pane_id;
layout_state
.scene
.surfaces
.iter()
.enumerate()
.filter(|(_, surface)| surface.visible && surface.pane_id == Some(pane_id))
.max_by_key(|(index, surface)| (surface.layer, surface.z, *index))
.map(|(_, surface)| AttachInputPaneContext {
pane_id,
surface_id: surface.id,
rect: extension_rect_from_attach_rect(surface.rect),
content_rect: extension_rect_from_attach_rect(surface.content_rect),
focused,
})
}
fn attach_input_focused_context(view_state: &AttachViewState) -> Option<AttachInputPaneContext> {
let pane_id = view_state.cached_layout_state.as_ref()?.focused_pane_id;
attach_input_pane_context_for_id(view_state, pane_id)
}
fn attach_input_hovered_pane_context(
view_state: &AttachViewState,
col: u16,
row: u16,
) -> Option<AttachInputPaneContext> {
attach_scene_pane_at(view_state, col, row)
.and_then(|pane_id| attach_input_pane_context_for_id(view_state, pane_id))
}
const fn extension_rect_from_attach_rect(rect: AttachRect) -> ExtensionRect {
ExtensionRect {
x: rect.x,
y: rect.y,
w: rect.w,
h: rect.h,
}
}
fn hook_matches_mouse(
hook: &AttachInputHook,
phase: &str,
focused: Option<&AttachInputPaneContext>,
hovered: Option<&AttachInputPaneContext>,
) -> bool {
if !hook.filter.mouse_phases.iter().any(|item| item == phase) {
return false;
}
match hook.filter.scope.as_str() {
"global" => true,
"hovered-pane" => hovered.is_some(),
_ => focused
.zip(hovered)
.is_some_and(|(focused, hovered)| focused.pane_id == hovered.pane_id),
}
}
fn hook_matches_key(hook: &AttachInputHook, key: &str) -> bool {
hook.filter.keys.iter().any(|item| item == key)
}
fn hook_dispatch_allowed(last: Option<Instant>, min_interval: Duration, now: Instant) -> bool {
min_interval.is_zero()
|| last.is_none_or(|last| now.saturating_duration_since(last) >= min_interval)
}
fn hook_throttled(view_state: &mut AttachViewState, hook: &AttachInputHook, now: Instant) -> bool {
let min_interval = Duration::from_millis(u64::from(hook.filter.min_interval_ms));
let last = view_state
.mouse
.input_hook_last_dispatched_at
.get(&hook.id)
.copied();
if !hook_dispatch_allowed(last, min_interval, now) {
return true;
}
view_state
.mouse
.input_hook_last_dispatched_at
.insert(hook.id.clone(), now);
false
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AttachHookThrottleDisposition {
Dispatch,
ConsumeOwned,
SkipUnowned,
}
const fn attach_hook_throttle_disposition(
throttled: bool,
captured: bool,
) -> AttachHookThrottleDisposition {
match (throttled, captured) {
(false, _) => AttachHookThrottleDisposition::Dispatch,
(true, true) => AttachHookThrottleDisposition::ConsumeOwned,
(true, false) => AttachHookThrottleDisposition::SkipUnowned,
}
}
async fn try_handle_captured_attach_input_hook_mouse(
client: &mut StreamingBmuxClient,
view_state: &mut AttachViewState,
mouse_event: MouseEvent,
now: Instant,
) -> std::result::Result<bool, ClientError> {
let Some(capture) = view_state
.mouse
.input_capture
.clone()
.filter(|capture| capture.pointer)
else {
return Ok(false);
};
let (phase, button) = attach_input_mouse_phase(mouse_event.kind);
let throttled =
matches!(phase, "drag" | "move") && hook_throttled(view_state, &capture.hook, now);
if matches!(
attach_hook_throttle_disposition(throttled, true),
AttachHookThrottleDisposition::ConsumeOwned
) {
return Ok(true);
}
let event = AttachInputEvent {
hook_id: capture.hook.id.clone(),
event_kind: "mouse".to_string(),
phase: phase.to_string(),
button: button.map(str::to_string),
key: None,
col: Some(mouse_event.column),
row: Some(mouse_event.row),
wheel_delta: 0,
modifiers: attach_input_event_modifiers(mouse_event.modifiers),
focused_pane: attach_input_focused_context(view_state),
hovered_pane: attach_input_hovered_pane_context(
view_state,
mouse_event.column,
mouse_event.row,
),
};
let result = match invoke_attach_input_hook(client, &capture.hook, event).await {
Ok(result) => result,
Err(error) => {
if phase == "up" {
view_state.mouse.clear_plugin_pointer_capture();
}
return Err(error);
}
};
apply_attach_input_result(view_state, capture.hook, &result);
if phase == "up" {
view_state.mouse.clear_plugin_pointer_capture();
}
Ok(true)
}
async fn try_handle_uncaptured_attach_input_hook_mouse(
client: &mut StreamingBmuxClient,
view_state: &mut AttachViewState,
mouse_event: MouseEvent,
now: Instant,
) -> std::result::Result<bool, ClientError> {
let (phase, button) = attach_input_mouse_phase(mouse_event.kind);
let focused = attach_input_focused_context(view_state);
let hovered =
attach_input_hovered_pane_context(view_state, mouse_event.column, mouse_event.row);
for hook in attach_input_hooks(view_state) {
if !hook_matches_mouse(&hook, phase, focused.as_ref(), hovered.as_ref()) {
continue;
}
let throttled = matches!(phase, "drag" | "move") && hook_throttled(view_state, &hook, now);
if matches!(
attach_hook_throttle_disposition(throttled, false),
AttachHookThrottleDisposition::SkipUnowned
) {
continue;
}
let event = AttachInputEvent {
hook_id: hook.id.clone(),
event_kind: "mouse".to_string(),
phase: phase.to_string(),
button: button.map(str::to_string),
key: None,
col: Some(mouse_event.column),
row: Some(mouse_event.row),
wheel_delta: 0,
modifiers: attach_input_event_modifiers(mouse_event.modifiers),
focused_pane: focused.clone(),
hovered_pane: hovered.clone(),
};
let result = invoke_attach_input_hook(client, &hook, event).await?;
if result.capture_pointer {
view_state.mouse.prepare_pointer_owner_acquisition();
}
apply_attach_input_result(view_state, hook, &result);
view_state.mouse.debug_assert_single_pointer_owner();
if result.consumed || result.capture_pointer {
return Ok(true);
}
}
Ok(false)
}
async fn try_handle_attach_input_hook_key(
client: &mut StreamingBmuxClient,
view_state: &mut AttachViewState,
key: &TerminalKeyEvent,
) -> std::result::Result<bool, ClientError> {
let Some(key_name) = attach_input_key_name(key) else {
return Ok(false);
};
let hooks = if let Some(capture) = view_state.mouse.input_capture.clone() {
if capture
.keyboard_keys
.iter()
.any(|item| item == "*" || item == &key_name)
|| key_name == "esc"
{
vec![capture.hook]
} else {
attach_input_hooks(view_state)
}
} else {
attach_input_hooks(view_state)
};
let focused = attach_input_focused_context(view_state);
for hook in hooks {
if !hook_matches_key(&hook, &key_name) {
continue;
}
let event = AttachInputEvent {
hook_id: hook.id.clone(),
event_kind: "key".to_string(),
phase: match key.kind {
super::input::TerminalKeyPhase::Press => "press".to_string(),
super::input::TerminalKeyPhase::Repeat => "repeat".to_string(),
super::input::TerminalKeyPhase::Release => "release".to_string(),
},
button: None,
key: Some(key_name.clone()),
col: None,
row: None,
wheel_delta: 0,
modifiers: AttachInputModifiers {
shift: key.modifiers.shift,
alt: key.modifiers.alt,
control: key.modifiers.control,
super_key: key.modifiers.super_key,
hyper: key.modifiers.hyper,
meta: key.modifiers.meta,
},
focused_pane: focused.clone(),
hovered_pane: None,
};
let result = invoke_attach_input_hook(client, &hook, event).await?;
apply_attach_input_result(view_state, hook, &result);
if result.consumed || !result.capture_keyboard.is_empty() || result.release_capture {
return Ok(true);
}
}
Ok(false)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AttachPointerFocusEventKind {
Move,
Wheel,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AttachPointerFocusDecision {
None,
Focus(Uuid),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct AttachMouseTargetContext {
focus_target: Option<Uuid>,
focused: Option<Uuid>,
in_focused: bool,
}
fn attach_mouse_target_context(
view_state: &AttachViewState,
column: u16,
row: u16,
) -> AttachMouseTargetContext {
let focus_target = attach_scene_pane_at(view_state, column, row);
let focused = view_state
.mouse
.last_focused_pane_id
.or_else(|| focused_attach_pane_id(view_state));
AttachMouseTargetContext {
focus_target,
focused,
in_focused: focus_target.is_some() && focus_target == focused,
}
}
fn reduce_attach_pointer_focus(
view_state: &mut AttachViewState,
focus_target: Option<Uuid>,
focused: Option<Uuid>,
event_kind: AttachPointerFocusEventKind,
now: Instant,
) -> AttachPointerFocusDecision {
if !view_state.mouse.config.focus_on_hover {
return AttachPointerFocusDecision::None;
}
if focus_target != view_state.mouse.hovered_pane_id {
view_state.mouse.hovered_pane_id = focus_target;
view_state.mouse.hover_started_at = focus_target.map(|_| now);
}
let Some(pane_id) = focus_target else {
view_state.mouse.hover_started_at = None;
return AttachPointerFocusDecision::None;
};
if focused == Some(pane_id) || view_state.mouse.last_focused_pane_id == Some(pane_id) {
return AttachPointerFocusDecision::None;
}
if view_state.mouse.config.hover_delay_ms == 0 {
view_state.mouse.hover_started_at = Some(now);
return AttachPointerFocusDecision::Focus(pane_id);
}
if event_kind != AttachPointerFocusEventKind::Move {
return AttachPointerFocusDecision::None;
}
let Some(hover_started_at) = view_state.mouse.hover_started_at else {
view_state.mouse.hover_started_at = Some(now);
return AttachPointerFocusDecision::None;
};
if now.duration_since(hover_started_at)
>= Duration::from_millis(view_state.mouse.config.hover_delay_ms)
{
view_state.mouse.hover_started_at = Some(now);
return AttachPointerFocusDecision::Focus(pane_id);
}
AttachPointerFocusDecision::None
}
async fn execute_attach_pointer_focus_decision(
client: &mut StreamingBmuxClient,
view_state: &mut AttachViewState,
decision: AttachPointerFocusDecision,
kernel_client_factory: Option<&KernelClientFactory>,
now: Instant,
) -> std::result::Result<bool, ClientError> {
let AttachPointerFocusDecision::Focus(pane_id) = decision else {
return Ok(false);
};
if !handle_attach_mouse_gesture_action(
client,
view_state,
"hover_focus",
kernel_client_factory,
now,
)
.await?
{
focus_attach_pane(client, view_state, pane_id).await?;
}
Ok(true)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AttachPointerContinuation {
Unowned,
Owned(AttachUiReduction),
}
pub fn continue_attach_builtin_pointer_owner(
view_state: &mut AttachViewState,
mouse_event: TerminalMouseEvent,
now: Instant,
_geometry: TerminalGeometry,
) -> AttachPointerContinuation {
let Some(owner) = view_state.mouse.normalize_pointer_owner() else {
return AttachPointerContinuation::Unowned;
};
let reduction = match owner {
AttachPointerOwner::Plugin => return AttachPointerContinuation::Unowned,
AttachPointerOwner::Resize => {
if matches!(
(mouse_event.phase, mouse_event.button),
(
TerminalMousePhase::Drag | TerminalMousePhase::Up,
Some(TerminalMouseButton::Left)
)
) {
reduce_attach_mouse_resize_event(view_state, mouse_event, now)
} else {
AttachUiReduction::consumed()
}
}
AttachPointerOwner::Floating => {
if matches!(
(mouse_event.phase, mouse_event.button),
(
TerminalMousePhase::Drag | TerminalMousePhase::Up,
Some(TerminalMouseButton::Left)
)
) {
reduce_attach_mouse_floating_drag_event(view_state, mouse_event)
} else {
AttachUiReduction::consumed()
}
}
AttachPointerOwner::Selection => {
if matches!(
(mouse_event.phase, mouse_event.button),
(
TerminalMousePhase::Drag | TerminalMousePhase::Up,
Some(TerminalMouseButton::Left)
)
) && let Some(mouse_event) = mouse_event.to_crossterm()
{
let _ = handle_attach_mouse_selection_drag_at(view_state, mouse_event, now);
}
AttachUiReduction::consumed()
}
};
view_state.mouse.debug_assert_single_pointer_owner();
AttachPointerContinuation::Owned(AttachUiReduction {
consumed: true,
effects: reduction.effects,
})
}
#[allow(clippy::too_many_lines)]
async fn handle_attach_mouse_event_at(
client: &mut StreamingBmuxClient,
mouse_event: MouseEvent,
view_state: &mut AttachViewState,
kernel_client_factory: Option<&KernelClientFactory>,
now: Instant,
geometry: TerminalGeometry,
) -> std::result::Result<(), ClientError> {
record_attach_mouse_event(mouse_event, view_state, now);
if !view_state.mouse.config.enabled {
view_state.mouse.clear_pointer_gestures();
return Ok(());
}
if view_state.help_overlay_open || view_state.prompt.is_active() {
view_state.mouse.clear_pointer_gestures();
return Ok(());
}
if !view_state.can_write {
view_state.mouse.clear_mutation_pointer_gestures();
}
match view_state.mouse.normalize_pointer_owner() {
Some(AttachPointerOwner::Plugin) => {
let _ =
try_handle_captured_attach_input_hook_mouse(client, view_state, mouse_event, now)
.await?;
return Ok(());
}
Some(_) => {
let focused_pane_before = view_state.focused_pane_id();
let scrollback_active_before = focused_pane_before
.is_some_and(|pane_id| view_state.scrollback_active_for(pane_id));
let pinned_before = focused_pane_before.and_then(|pane_id| {
view_state
.scrollback_for(pane_id)
.and_then(|view| view.pin)
.map(|pin| (pane_id, pin))
});
if let AttachPointerContinuation::Owned(reduction) =
continue_attach_builtin_pointer_owner(
view_state,
TerminalMouseEvent::from(mouse_event),
now,
geometry,
)
{
execute_attach_ui_effects(
client,
view_state,
reduction.effects,
kernel_client_factory,
now,
)
.await?;
}
if let Some((pane_id, pin)) = pinned_before
&& !view_state.scrollback_active_for(pane_id)
{
unpin_scrollback(client, view_state.attached_id, pane_id, pin).await;
view_state.scrollback_replay_pending = true;
}
if !scrollback_active_before
&& let Some(pane_id) = focused_pane_before
&& view_state.scrollback_active_for(pane_id)
{
let config = BmuxConfig::load().unwrap_or_default();
pin_focused_scrollback_if_configured(client, view_state, &config, now).await;
ensure_pane_scrollback_windows(client, view_state, false)?;
}
return Ok(());
}
None => {}
}
if dismiss_plugin_focus_on_pointer_down(view_state, mouse_event) {
return Ok(());
}
let retained_owns_input = view_state
.retained_compositor
.blocks_background_input(mouse_event.column, mouse_event.row);
if try_handle_plugin_surface_mouse(client, view_state, mouse_event).await?
|| retained_owns_input
{
return Ok(());
}
if view_state.can_write {
let target_context =
attach_mouse_target_context(view_state, mouse_event.column, mouse_event.row);
if !target_context
.focus_target
.is_some_and(|pane_id| view_state.scrollback_active_for(pane_id))
&& pane_mouse_protocol_reports_event(
view_state,
target_context.focus_target,
mouse_event.kind,
)
&& maybe_forward_attach_mouse_event(
client,
view_state,
mouse_event,
target_context.focus_target,
target_context.in_focused,
true,
)
.await?
{
return Ok(());
}
}
if view_state.can_write {
let resize_reduction = reduce_attach_mouse_resize_acquisition_before_uncaptured_hook(
view_state,
TerminalMouseEvent::from(mouse_event),
now,
);
if resize_reduction.consumed {
view_state.mouse.debug_assert_single_pointer_owner();
execute_attach_ui_effects(
client,
view_state,
resize_reduction.effects,
kernel_client_factory,
now,
)
.await?;
return Ok(());
}
}
if try_handle_uncaptured_attach_input_hook_mouse(client, view_state, mouse_event, now).await? {
return Ok(());
}
if !view_state.can_write {
return Ok(());
}
let floating_reduction =
reduce_attach_mouse_floating_drag_event(view_state, TerminalMouseEvent::from(mouse_event));
if floating_reduction.consumed {
view_state.mouse.debug_assert_single_pointer_owner();
execute_attach_ui_effects(
client,
view_state,
floating_reduction.effects,
kernel_client_factory,
now,
)
.await?;
return Ok(());
}
let mut target_context =
attach_mouse_target_context(view_state, mouse_event.column, mouse_event.row);
if matches!(mouse_event.kind, MouseEventKind::ScrollUp)
&& handle_attach_mouse_gesture_action(
client,
view_state,
"scroll_up",
kernel_client_factory,
now,
)
.await?
{
return Ok(());
}
if matches!(mouse_event.kind, MouseEventKind::ScrollDown)
&& handle_attach_mouse_gesture_action(
client,
view_state,
"scroll_down",
kernel_client_factory,
now,
)
.await?
{
return Ok(());
}
if matches!(
mouse_event.kind,
MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
) {
let decision = reduce_attach_pointer_focus(
view_state,
target_context.focus_target,
target_context.focused,
AttachPointerFocusEventKind::Wheel,
now,
);
if execute_attach_pointer_focus_decision(
client,
view_state,
decision,
kernel_client_factory,
now,
)
.await?
{
target_context =
attach_mouse_target_context(view_state, mouse_event.column, mouse_event.row);
}
match view_state.mouse.config.effective_wheel_propagation() {
bmux_config::MouseWheelPropagation::Auto => {
let _ = handle_attach_mouse_wheel_auto(
client,
view_state,
mouse_event,
target_context.focus_target,
target_context.in_focused,
)
.await?;
return Ok(());
}
bmux_config::MouseWheelPropagation::ForwardOnly => {
let _ = maybe_forward_attach_mouse_event(
client,
view_state,
mouse_event,
target_context.focus_target,
target_context.in_focused,
false,
)
.await?;
return Ok(());
}
bmux_config::MouseWheelPropagation::ScrollbackOnly => {
let _ = handle_attach_mouse_scrollback_with_window(
client,
view_state,
target_context.focus_target,
mouse_event.kind,
)
.await?;
return Ok(());
}
bmux_config::MouseWheelPropagation::ForwardAndScrollback => {
let _ = maybe_forward_attach_mouse_event(
client,
view_state,
mouse_event,
target_context.focus_target,
target_context.in_focused,
false,
)
.await?;
let _ = handle_attach_mouse_scrollback_with_window(
client,
view_state,
target_context.focus_target,
mouse_event.kind,
)
.await?;
return Ok(());
}
}
}
match mouse_event.kind {
MouseEventKind::Down(MouseButton::Left) => {
let target = target_context.focus_target;
view_state.mouse.hovered_pane_id = target;
view_state.mouse.hover_started_at = Some(now);
if !handle_attach_mouse_gesture_action(
client,
view_state,
"click_left",
kernel_client_factory,
now,
)
.await?
{
if maybe_begin_attach_mouse_selection_drag(view_state, target, mouse_event) {
return Ok(());
}
match view_state.mouse.config.effective_click_propagation() {
bmux_config::MouseClickPropagation::FocusOnly => {
if let Some(pane_id) = target {
focus_attach_pane(client, view_state, pane_id).await?;
}
}
bmux_config::MouseClickPropagation::ForwardOnly => {
let _ = maybe_forward_attach_mouse_event(
client,
view_state,
mouse_event,
target,
target_context.in_focused,
false,
)
.await?;
}
bmux_config::MouseClickPropagation::FocusAndForward => {
let _ = maybe_forward_attach_mouse_event(
client,
view_state,
mouse_event,
target,
target_context.in_focused,
true,
)
.await?;
}
}
}
}
MouseEventKind::Down(_) | MouseEventKind::Up(_) | MouseEventKind::Drag(_) => {
if should_forward_click_like_mouse(view_state) {
let _ = maybe_forward_attach_mouse_event(
client,
view_state,
mouse_event,
target_context.focus_target,
target_context.in_focused,
false,
)
.await?;
}
}
MouseEventKind::Moved => {
let _ = maybe_forward_attach_mouse_event(
client,
view_state,
mouse_event,
target_context.focus_target,
target_context.in_focused,
false,
)
.await?;
let decision = reduce_attach_pointer_focus(
view_state,
target_context.focus_target,
target_context.focused,
AttachPointerFocusEventKind::Move,
now,
);
let _ = execute_attach_pointer_focus_decision(
client,
view_state,
decision,
kernel_client_factory,
now,
)
.await?;
}
MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight => {
let _ = maybe_forward_attach_mouse_event(
client,
view_state,
mouse_event,
target_context.focus_target,
target_context.in_focused,
false,
)
.await?;
}
MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => {}
}
Ok(())
}
pub fn reduce_attach_mouse_floating_drag_event(
view_state: &mut AttachViewState,
mouse_event: TerminalMouseEvent,
) -> AttachUiReduction {
match (mouse_event.phase, mouse_event.button) {
(TerminalMousePhase::Down, Some(TerminalMouseButton::Left)) => {
let Some(surface) =
attach_scene_floating_drag_surface_at(view_state, mouse_event.col, mouse_event.row)
else {
return AttachUiReduction::ignored();
};
let Some(pane_id) = surface.pane_id else {
return AttachUiReduction::ignored();
};
view_state.mouse.prepare_pointer_owner_acquisition();
let (scene_max_x, scene_max_y) =
attach_scene_bounds(view_state).unwrap_or((u16::MAX, u16::MAX));
view_state.mouse.floating_drag = Some(AttachMouseFloatingDrag {
pane_id,
start_x: surface.rect.x,
start_y: surface.rect.y,
width: surface.rect.w,
height: surface.rect.h,
scene_max_x,
scene_max_y,
last_x: surface.rect.x,
last_y: surface.rect.y,
start_column: mouse_event.col,
start_row: mouse_event.row,
});
AttachUiReduction::with_effect(AttachUiEffect::Focus { pane_id })
}
(TerminalMousePhase::Drag, Some(TerminalMouseButton::Left)) => {
let Some(mut drag) = view_state.mouse.floating_drag.take() else {
return AttachUiReduction::ignored();
};
let effect = floating_drag_move_effect(&mut drag, mouse_event.col, mouse_event.row);
view_state.mouse.floating_drag = Some(drag);
AttachUiReduction {
consumed: true,
effects: effect.into_iter().collect(),
}
}
(TerminalMousePhase::Up, Some(TerminalMouseButton::Left)) => {
let Some(mut drag) = view_state.mouse.floating_drag.take() else {
return AttachUiReduction::ignored();
};
AttachUiReduction {
consumed: true,
effects: floating_drag_move_effect(&mut drag, mouse_event.col, mouse_event.row)
.into_iter()
.collect(),
}
}
_ => {
if view_state.mouse.floating_drag.is_some() {
AttachUiReduction::consumed()
} else {
AttachUiReduction::ignored()
}
}
}
}
fn floating_drag_move_effect(
drag: &mut AttachMouseFloatingDrag,
column: u16,
row: u16,
) -> Option<AttachUiEffect> {
let (x, y) = floating_drag_position(*drag, column, row);
if x == drag.last_x && y == drag.last_y {
return None;
}
drag.last_x = x;
drag.last_y = y;
Some(AttachUiEffect::MoveFloating {
pane_id: drag.pane_id,
x,
y,
})
}
fn reduce_attach_mouse_resize_acquisition_before_uncaptured_hook(
view_state: &mut AttachViewState,
mouse_event: TerminalMouseEvent,
now: Instant,
) -> AttachUiReduction {
if matches!(
(mouse_event.phase, mouse_event.button),
(TerminalMousePhase::Down, Some(TerminalMouseButton::Left))
) {
reduce_attach_mouse_resize_event(view_state, mouse_event, now)
} else {
AttachUiReduction::ignored()
}
}
pub fn reduce_attach_mouse_resize_event(
view_state: &mut AttachViewState,
mouse_event: TerminalMouseEvent,
now: Instant,
) -> AttachUiReduction {
if !view_state.mouse.config.resize_borders {
view_state.mouse.resize_drag = None;
return AttachUiReduction::ignored();
}
match (mouse_event.phase, mouse_event.button) {
(TerminalMousePhase::Down, Some(TerminalMouseButton::Left)) => {
let Some(mut drag) =
attach_scene_resize_separator_at(view_state, mouse_event.col, mouse_event.row, now)
else {
return AttachUiReduction::ignored();
};
view_state.mouse.prepare_pointer_owner_acquisition();
let throttle = Duration::from_millis(view_state.mouse.config.resize_drag_throttle_ms);
drag.last_applied_at = now.checked_sub(throttle).unwrap_or(now);
view_state.mouse.resize_drag = Some(drag);
AttachUiReduction::consumed()
}
(TerminalMousePhase::Drag, Some(TerminalMouseButton::Left)) => {
let Some(mut drag) = view_state.mouse.resize_drag else {
return AttachUiReduction::ignored();
};
drag.latest_column = mouse_event.col;
drag.latest_row = mouse_event.row;
if !attach_mouse_resize_drag_has_pending_delta(&drag) {
view_state.mouse.resize_drag = Some(drag);
return AttachUiReduction::consumed();
}
let throttle = Duration::from_millis(view_state.mouse.config.resize_drag_throttle_ms);
if !throttle.is_zero() && now.duration_since(drag.last_applied_at) < throttle {
view_state.mouse.resize_drag = Some(drag);
return AttachUiReduction::consumed();
}
let effects = resize_effects_for_drag_delta(&mut drag);
if !effects.is_empty() {
drag.last_applied_at = now;
}
view_state.mouse.resize_drag = Some(drag);
AttachUiReduction {
consumed: true,
effects,
}
}
(TerminalMousePhase::Up, Some(TerminalMouseButton::Left)) => {
let Some(mut drag) = view_state.mouse.resize_drag.take() else {
return AttachUiReduction::ignored();
};
drag.latest_column = mouse_event.col;
drag.latest_row = mouse_event.row;
AttachUiReduction {
consumed: true,
effects: resize_effects_for_drag_delta(&mut drag),
}
}
_ => {
if view_state.mouse.resize_drag.is_some() {
AttachUiReduction::consumed()
} else {
AttachUiReduction::ignored()
}
}
}
}
fn attach_mouse_resize_drag_has_pending_delta(drag: &AttachMouseResizeDrag) -> bool {
resize_drag_axis_delta(
drag.horizontal,
i32::from(drag.latest_column) - i32::from(drag.last_column),
)
.is_some()
|| resize_drag_axis_delta(
drag.vertical,
i32::from(drag.latest_row) - i32::from(drag.last_row),
)
.is_some()
}
fn resize_effects_for_drag_delta(drag: &mut AttachMouseResizeDrag) -> Vec<AttachUiEffect> {
let mut effects = Vec::new();
if let Some((pane_id, direction, cells)) = resize_drag_axis_delta(
drag.horizontal,
i32::from(drag.latest_column) - i32::from(drag.last_column),
) {
effects.push(AttachUiEffect::Resize {
pane_id,
direction,
cells,
});
drag.last_column = drag.latest_column;
}
if let Some((pane_id, direction, cells)) = resize_drag_axis_delta(
drag.vertical,
i32::from(drag.latest_row) - i32::from(drag.last_row),
) {
effects.push(AttachUiEffect::Resize {
pane_id,
direction,
cells,
});
drag.last_row = drag.latest_row;
}
effects
}
fn resize_drag_axis_delta(
axis_drag: Option<AttachMouseResizeAxisDrag>,
delta: i32,
) -> Option<(
Uuid,
bmux_tabs_plugin_api::tabs_commands::PaneResizeDirection,
u16,
)> {
let axis_drag = axis_drag?;
if delta == 0 {
return None;
}
let cells = u16::try_from(delta.unsigned_abs()).unwrap_or(u16::MAX);
if delta > 0 {
Some((
axis_drag.positive_target_pane_id,
axis_drag.positive_direction,
cells,
))
} else {
Some((
axis_drag.negative_target_pane_id,
axis_drag.negative_direction,
cells,
))
}
}
pub const fn should_forward_click_like_mouse(view_state: &AttachViewState) -> bool {
!matches!(
view_state.mouse.config.effective_click_propagation(),
bmux_config::MouseClickPropagation::FocusOnly
)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct AttachPaneMouseProtocol {
pub mode: AttachMouseProtocolMode,
pub encoding: AttachMouseProtocolEncoding,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub struct AttachPaneInputMode {
pub application_cursor: bool,
pub application_keypad: bool,
pub bracketed_paste: bool,
}
pub fn attach_pane_mouse_protocol(
view_state: &AttachViewState,
pane_id: Uuid,
) -> Option<AttachPaneMouseProtocol> {
let protocol = attach_mouse::pane_protocol(
&view_state.pane_buffers,
&view_state.pane_mouse_protocol_hints,
pane_id,
)?;
Some(AttachPaneMouseProtocol {
mode: protocol.mode,
encoding: protocol.encoding,
})
}
pub fn attach_pane_input_mode(
view_state: &AttachViewState,
pane_id: Uuid,
) -> Option<AttachPaneInputMode> {
let structured_mode = view_state.pane_buffers.get(&pane_id).map(|buffer| {
let protocol = buffer.protocol_tracker.protocol_state();
AttachPaneInputMode {
application_cursor: protocol.application_cursor,
application_keypad: protocol.application_keypad,
bracketed_paste: protocol.bracketed_paste,
}
});
let hint_mode =
view_state
.pane_input_mode_hints
.get(&pane_id)
.map(|hint| AttachPaneInputMode {
application_cursor: hint.application_cursor,
application_keypad: hint.application_keypad,
bracketed_paste: hint.bracketed_paste,
});
match (structured_mode, hint_mode) {
(Some(structured), Some(hint)) => Some(AttachPaneInputMode {
application_cursor: structured.application_cursor || hint.application_cursor,
application_keypad: structured.application_keypad || hint.application_keypad,
bracketed_paste: hint.bracketed_paste,
}),
(Some(structured), None) => Some(structured),
(None, Some(hint)) => Some(hint),
(None, None) => None,
}
}
#[must_use]
pub fn encode_bracketed_paste(text: &str, pane_mode_enabled: bool) -> Vec<u8> {
if !pane_mode_enabled {
return text.as_bytes().to_vec();
}
let mut bytes = Vec::with_capacity(text.len().saturating_add(12));
bytes.extend_from_slice(b"\x1b[200~");
bytes.extend_from_slice(text.as_bytes());
bytes.extend_from_slice(b"\x1b[201~");
bytes
}
pub fn focused_attach_pane_input_mode(view_state: &AttachViewState) -> AttachPaneInputMode {
focused_attach_pane_id(view_state)
.and_then(|pane_id| attach_pane_input_mode(view_state, pane_id))
.unwrap_or_default()
}
#[cfg(test)]
pub const fn mouse_protocol_mode_reports_event(
mode: AttachMouseProtocolMode,
kind: MouseEventKind,
) -> bool {
attach_mouse::mode_reports_event(mode, mouse_event_kind_to_shared(kind))
}
#[cfg(test)]
pub fn encode_attach_mouse_for_protocol(
mouse_event: MouseEvent,
protocol: AttachPaneMouseProtocol,
) -> Option<Vec<u8>> {
attach_mouse::encode_for_protocol(
mouse_event_to_shared(mouse_event),
attach_mouse::PaneProtocol {
mode: protocol.mode,
encoding: protocol.encoding,
},
)
}
pub async fn maybe_forward_attach_mouse_event(
client: &mut StreamingBmuxClient,
view_state: &mut AttachViewState,
mouse_event: MouseEvent,
focus_target: Option<Uuid>,
in_focused: bool,
focus_before_forward: bool,
) -> std::result::Result<bool, ClientError> {
let Some(focus_target) = focus_target else {
return Ok(false);
};
if focus_before_forward && !in_focused {
focus_attach_pane(client, view_state, focus_target).await?;
} else if !in_focused {
return Ok(false);
}
let Some(bytes) = attach_mouse_forward_bytes_for_target(
view_state,
mouse_event,
Some(focus_target),
in_focused || focus_before_forward,
) else {
return Ok(false);
};
client
.send_one_way_pane_direct_input(view_state.attached_id, focus_target, bytes)
.await?;
Ok(true)
}
pub fn attach_mouse_forward_bytes_for_target(
view_state: &AttachViewState,
mouse_event: MouseEvent,
focus_target: Option<Uuid>,
in_focused: bool,
) -> Option<Vec<u8>> {
if !in_focused {
return None;
}
let focus_target = focus_target?;
let protocol = attach_pane_mouse_protocol(view_state, focus_target)?;
let pane_content_rect = attach_scene_pane_content_rect(view_state, focus_target)?;
let shared_event = mouse_event_to_shared(mouse_event);
let local_event = attach_mouse::translate_event_to_pane_local(shared_event, pane_content_rect)?;
attach_mouse::encode_for_protocol(
local_event,
attach_mouse::PaneProtocol {
mode: protocol.mode,
encoding: protocol.encoding,
},
)
}
fn attach_scene_pane_content_rect(
view_state: &AttachViewState,
pane_id: Uuid,
) -> Option<AttachRect> {
let layout_state = view_state.cached_layout_state.as_ref()?;
let mut best: Option<(SurfaceLayer, i32, usize, uuid::Uuid, AttachRect)> = None;
for (index, surface) in layout_state.scene.surfaces.iter().enumerate() {
if surface.pane_id != Some(pane_id) {
continue;
}
if !surface.visible || !surface.accepts_input {
continue;
}
let candidate = (
surface.layer,
surface.z,
index,
surface.id,
surface.content_rect,
);
if best.as_ref().is_none_or(|current| {
(candidate.0, candidate.1, candidate.2) > (current.0, current.1, current.2)
}) {
best = Some(candidate);
}
}
let (_, _, _, surface_id, scene_rect) = best?;
for ext in view_state.presentation_extensions.snapshot() {
if let Some(override_rect) = ext.content_rect_override(surface_id)
&& override_rect.w > 0
&& override_rect.h > 0
{
return Some(AttachRect {
x: override_rect.x,
y: override_rect.y,
w: override_rect.w,
h: override_rect.h,
});
}
}
Some(scene_rect)
}
#[cfg(test)]
pub fn encode_attach_mouse_sgr(mouse_event: MouseEvent) -> Option<Vec<u8>> {
attach_mouse::encode_sgr(mouse_event_to_shared(mouse_event))
}
const fn mouse_button_to_shared(button: MouseButton) -> attach_mouse::Button {
match button {
MouseButton::Left => attach_mouse::Button::Left,
MouseButton::Middle => attach_mouse::Button::Middle,
MouseButton::Right => attach_mouse::Button::Right,
}
}
const fn mouse_event_kind_to_shared(kind: MouseEventKind) -> attach_mouse::EventKind {
match kind {
MouseEventKind::Down(button) => {
attach_mouse::EventKind::Down(mouse_button_to_shared(button))
}
MouseEventKind::Up(button) => attach_mouse::EventKind::Up(mouse_button_to_shared(button)),
MouseEventKind::Drag(button) => {
attach_mouse::EventKind::Drag(mouse_button_to_shared(button))
}
MouseEventKind::Moved => attach_mouse::EventKind::Moved,
MouseEventKind::ScrollUp => attach_mouse::EventKind::ScrollUp,
MouseEventKind::ScrollDown => attach_mouse::EventKind::ScrollDown,
MouseEventKind::ScrollLeft => attach_mouse::EventKind::ScrollLeft,
MouseEventKind::ScrollRight => attach_mouse::EventKind::ScrollRight,
}
}
const fn key_modifiers_to_shared(modifiers: KeyModifiers) -> attach_mouse::Modifiers {
attach_mouse::Modifiers {
shift: modifiers.contains(KeyModifiers::SHIFT),
alt: modifiers.contains(KeyModifiers::ALT),
control: modifiers.contains(KeyModifiers::CONTROL),
}
}
const fn mouse_event_to_shared(mouse_event: MouseEvent) -> attach_mouse::Event {
attach_mouse::Event {
kind: mouse_event_kind_to_shared(mouse_event.kind),
column: mouse_event.column,
row: mouse_event.row,
modifiers: key_modifiers_to_shared(mouse_event.modifiers),
}
}
async fn execute_attach_ui_effects(
client: &mut StreamingBmuxClient,
view_state: &mut AttachViewState,
effects: Vec<AttachUiEffect>,
_kernel_client_factory: Option<&KernelClientFactory>,
_now: Instant,
) -> std::result::Result<(), ClientError> {
for effect in effects {
match effect {
AttachUiEffect::Resize {
pane_id,
direction,
cells,
} => {
if let Err(error) =
resize_attach_pane(client, view_state, pane_id, direction, cells).await
{
tracing::warn!(
%error,
%pane_id,
?direction,
cells,
"attach.mouse_resize.apply_failed"
);
}
}
AttachUiEffect::Focus { pane_id } => {
focus_attach_pane(client, view_state, pane_id).await?;
}
AttachUiEffect::MoveFloating { pane_id, x, y } => {
move_attach_floating_pane(client, view_state, pane_id, x, y).await?;
}
}
}
Ok(())
}
pub async fn handle_attach_mouse_gesture_action(
client: &mut StreamingBmuxClient,
view_state: &mut AttachViewState,
gesture: &str,
kernel_client_factory: Option<&KernelClientFactory>,
now: Instant,
) -> std::result::Result<bool, ClientError> {
let Some(attach_action) = resolve_mouse_gesture_action(view_state, gesture) else {
return Ok(false);
};
match attach_action {
AttachEventAction::PluginCommand {
plugin_id,
command_name,
args,
} => {
handle_attach_plugin_command_action(
client,
&plugin_id,
&command_name,
&args,
view_state,
kernel_client_factory,
Vec::new(),
)
.await?;
Ok(true)
}
AttachEventAction::Ui(action) => {
handle_attach_ui_action_with_scrollback(client, &action, view_state, now).await?;
view_state
.dirty
.mark_layout_frame_dirty(AttachDirtySource::Mouse);
Ok(true)
}
AttachEventAction::Ignore => Ok(true),
AttachEventAction::Detach
| AttachEventAction::Send(_)
| AttachEventAction::Paste(_)
| AttachEventAction::Mouse(_)
| AttachEventAction::Redraw => Ok(false),
}
}
pub fn resolve_mouse_gesture_action(
view_state: &AttachViewState,
gesture: &str,
) -> Option<AttachEventAction> {
let action_name = view_state.mouse.config.gesture_actions.get(gesture)?;
match crate::input::parse_runtime_action_name(action_name) {
Ok(action) => Some(runtime_action_to_attach_event_action(action)),
Err(error) => {
warn!(
gesture = %gesture,
action_name = %action_name,
error = %error,
"attach.mouse_gesture.invalid_action"
);
None
}
}
}
pub async fn handle_attach_mouse_wheel_auto(
client: &mut StreamingBmuxClient,
view_state: &mut AttachViewState,
mouse_event: MouseEvent,
focus_target: Option<Uuid>,
in_focused: bool,
) -> std::result::Result<bool, ClientError> {
if pane_mouse_protocol_reports_event(view_state, focus_target, mouse_event.kind)
&& maybe_forward_attach_mouse_event(
client,
view_state,
mouse_event,
focus_target,
in_focused,
false,
)
.await?
{
return Ok(true);
}
if !in_focused {
return Ok(false);
}
let Some(focus_target) = focus_target else {
return Ok(false);
};
if attach_pane_uses_alternate_screen(view_state, focus_target) {
return match view_state.mouse.config.alternate_screen_wheel {
bmux_config::AlternateScreenWheelBehavior::Ignore => Ok(false),
bmux_config::AlternateScreenWheelBehavior::ForwardOnly => {
maybe_forward_attach_mouse_event(
client,
view_state,
mouse_event,
Some(focus_target),
in_focused,
false,
)
.await
}
bmux_config::AlternateScreenWheelBehavior::ScrollbackOnly => {
handle_attach_mouse_scrollback_with_window(
client,
view_state,
Some(focus_target),
mouse_event.kind,
)
.await
}
};
}
handle_attach_mouse_scrollback_with_window(
client,
view_state,
Some(focus_target),
mouse_event.kind,
)
.await
}
pub fn pane_mouse_protocol_reports_event(
view_state: &AttachViewState,
focus_target: Option<Uuid>,
kind: MouseEventKind,
) -> bool {
let Some(focus_target) = focus_target else {
return false;
};
let Some(protocol) = attach_pane_mouse_protocol(view_state, focus_target) else {
return false;
};
attach_mouse::mode_reports_event(protocol.mode, mouse_event_kind_to_shared(kind))
}
pub fn attach_pane_uses_alternate_screen(view_state: &AttachViewState, pane_id: Uuid) -> bool {
view_state
.pane_buffers
.get(&pane_id)
.is_some_and(|buffer| buffer.protocol_tracker.alternate_screen())
}
pub fn maybe_begin_attach_mouse_selection_drag(
view_state: &mut AttachViewState,
focus_target: Option<Uuid>,
mouse_event: MouseEvent,
) -> bool {
if view_state.mouse.config.effective_wheel_propagation()
== bmux_config::MouseWheelPropagation::ForwardOnly
|| view_state.mouse.config.effective_click_propagation()
== bmux_config::MouseClickPropagation::ForwardOnly
{
return false;
}
let Some(focus_target) = focus_target else {
return false;
};
if focused_attach_pane_id(view_state) != Some(focus_target) {
return false;
}
if !view_state.scrollback_active_for(focus_target)
&& (pane_mouse_protocol_reports_event(view_state, Some(focus_target), mouse_event.kind)
|| attach_pane_uses_alternate_screen(view_state, focus_target))
{
return false;
}
let Some(anchor) = attach_mouse_scrollback_position_for_event(
view_state,
focus_target,
mouse_event.column,
mouse_event.row,
false,
) else {
return false;
};
view_state.mouse.prepare_pointer_owner_acquisition();
view_state.mouse.selection_drag = Some(AttachMouseSelectionDrag {
pane_id: focus_target,
anchor,
active: false,
});
true
}
pub fn handle_attach_mouse_selection_drag_at(
view_state: &mut AttachViewState,
mouse_event: MouseEvent,
now: Instant,
) -> bool {
match mouse_event.kind {
MouseEventKind::Drag(MouseButton::Left) => update_attach_mouse_selection_drag_at(
view_state,
mouse_event.column,
mouse_event.row,
now,
),
MouseEventKind::Up(MouseButton::Left) => {
finish_attach_mouse_selection_drag_at(view_state, now)
}
_ => false,
}
}
fn update_attach_mouse_selection_drag_at(
view_state: &mut AttachViewState,
column: u16,
row: u16,
now: Instant,
) -> bool {
let Some(drag) = view_state.mouse.selection_drag else {
return false;
};
if !view_state.scrollback_active_for(drag.pane_id)
&& !enter_attach_scrollback_for(view_state, drag.pane_id)
{
view_state.mouse.selection_drag = None;
return true;
}
let Some(head) =
attach_mouse_scrollback_position_for_event(view_state, drag.pane_id, column, row, true)
else {
return true;
};
if let Some(view) = view_state.scrollback_for_mut(drag.pane_id) {
view.captured_selection = None;
view.selection_anchor = Some(drag.anchor);
}
let _ = set_attach_scrollback_cursor_to_position(view_state, head);
if !drag.active {
view_state.set_transient_status(
ATTACH_SELECTION_STARTED_STATUS,
now,
ATTACH_TRANSIENT_STATUS_TTL,
);
}
view_state.mouse.selection_drag = Some(AttachMouseSelectionDrag {
active: true,
..drag
});
view_state
.dirty
.mark_full_frame(AttachDirtySource::Selection);
true
}
fn finish_attach_mouse_selection_drag_at(view_state: &mut AttachViewState, now: Instant) -> bool {
let Some(drag) = view_state.mouse.selection_drag.take() else {
return false;
};
if drag.active {
match view_state.mouse.config.selection_release {
bmux_config::MouseSelectionReleaseBehavior::Select => {}
bmux_config::MouseSelectionReleaseBehavior::Copy => {
copy_attach_selection_at(view_state, false, now);
}
bmux_config::MouseSelectionReleaseBehavior::CopyAndExit => {
copy_attach_selection_at(view_state, true, now);
}
}
view_state
.dirty
.mark_full_frame(AttachDirtySource::Selection);
}
true
}
fn attach_mouse_scrollback_position_for_event(
view_state: &AttachViewState,
pane_id: Uuid,
column: u16,
row: u16,
clamp: bool,
) -> Option<AttachScrollbackPosition> {
let content = attach_scene_pane_content_rect(view_state, pane_id)?;
if content.w == 0 || content.h == 0 {
return None;
}
let max_column = content.x.saturating_add(content.w.saturating_sub(1));
let max_row = content.y.saturating_add(content.h.saturating_sub(1));
if !clamp && (column < content.x || column > max_column || row < content.y || row > max_row) {
return None;
}
let local_col = column
.saturating_sub(content.x)
.min(content.w.saturating_sub(1));
let local_row = row
.saturating_sub(content.y)
.min(content.h.saturating_sub(1));
let base = attach_scrollback_viewport_base(view_state, pane_id)?;
Some(AttachScrollbackPosition {
line: base.line_for_viewport_row(usize::from(local_row)),
col: usize::from(local_col),
})
}
fn set_attach_scrollback_cursor_to_position(
view_state: &mut AttachViewState,
position: AttachScrollbackPosition,
) -> bool {
let Some(pane_id) = view_state.focused_pane_id() else {
return false;
};
let Some((inner_w, inner_h)) = attach_pane_inner_size(view_state, pane_id) else {
return false;
};
if inner_w == 0 || inner_h == 0 {
return false;
}
let Some(base) = attach_scrollback_viewport_base(view_state, pane_id) else {
return false;
};
let Some(view) = view_state.scrollback_for_mut(pane_id) else {
return false;
};
let row = position
.line
.saturating_sub(base.top_line())
.min(inner_h.saturating_sub(1) as u64);
view.cursor = AttachScrollbackCursor {
row: usize::try_from(row).unwrap_or_else(|_| inner_h.saturating_sub(1)),
col: position.col.min(inner_w.saturating_sub(1)),
};
true
}
pub fn handle_attach_mouse_scrollback_for(
view_state: &mut AttachViewState,
pane_id: Uuid,
kind: MouseEventKind,
) -> bool {
if !view_state.mouse.config.scroll_scrollback {
return false;
}
#[allow(clippy::cast_possible_wrap)]
let lines = view_state.mouse.config.scroll_lines_per_tick.max(1) as isize;
match kind {
MouseEventKind::ScrollUp => {
if !view_state.scrollback_active_for(pane_id)
&& !enter_attach_scrollback_for(view_state, pane_id)
{
return false;
}
step_attach_scrollback_for(view_state, pane_id, -lines);
view_state
.dirty
.mark_full_frame(AttachDirtySource::Scrollback);
true
}
MouseEventKind::ScrollDown => {
if !view_state.scrollback_active_for(pane_id) {
return false;
}
step_attach_scrollback_for(view_state, pane_id, lines);
if view_state.mouse.config.exit_scrollback_on_bottom
&& view_state
.requested_scroll_offset(pane_id)
.is_some_and(|offset| offset == 0)
&& !view_state.selection_active_for(pane_id)
{
view_state.exit_scrollback_for(pane_id);
}
view_state
.dirty
.mark_full_frame(AttachDirtySource::Scrollback);
true
}
_ => false,
}
}
#[cfg(test)]
pub fn handle_attach_mouse_scrollback(
view_state: &mut AttachViewState,
kind: MouseEventKind,
) -> bool {
let Some(pane_id) = view_state.focused_pane_id() else {
return false;
};
handle_attach_mouse_scrollback_for(view_state, pane_id, kind)
}
async fn send_attach_bytes_to_focused(
client: &mut StreamingBmuxClient,
view_state: &AttachViewState,
bytes: Vec<u8>,
) -> std::result::Result<(), ClientError> {
if let Some(pane_id) = focused_attach_pane_id(view_state) {
client
.send_one_way_pane_direct_input(view_state.attached_id, pane_id, bytes)
.await?;
}
Ok(())
}
fn attach_scene_pane_is_floating(view_state: &AttachViewState, pane_id: Uuid) -> bool {
view_state
.cached_layout_state
.as_ref()
.is_some_and(|layout_state| {
layout_state.scene.surfaces.iter().any(|surface| {
surface.visible
&& surface.pane_id == Some(pane_id)
&& surface.kind == AttachSurfaceKind::FloatingPane
})
})
}
pub async fn focus_attach_pane(
client: &mut StreamingBmuxClient,
view_state: &mut AttachViewState,
pane_id: Uuid,
) -> std::result::Result<(), ClientError> {
if view_state.mouse.last_focused_pane_id == Some(pane_id) {
return Ok(());
}
let selector = attached_session_selector(view_state);
if attach_scene_pane_is_floating(view_state, pane_id) {
let _ack: bmux_tabs_plugin_api::tabs_commands::PaneAck = invoke_windows_command(
client,
"focus-floating-pane",
&tabs_commands::client::FocusFloatingPaneRequest {
session: Some(ipc_to_windows_selector(selector)),
target: pane_id_windows_selector(pane_id),
},
)
.await?;
} else {
let _ack: bmux_tabs_plugin_api::tabs_commands::PaneAck = invoke_windows_command(
client,
"focus-pane-by-selector",
&tabs_commands::client::FocusPaneBySelectorRequest {
session: Some(ipc_to_windows_selector(selector)),
target: pane_id_windows_selector(pane_id),
},
)
.await?;
}
let previous_focus = focused_attach_pane_id(view_state);
view_state.mouse.last_focused_pane_id = Some(pane_id);
if let Some(layout_state) = view_state.cached_layout_state.clone() {
if let Some(previous_pane_id) = previous_focus {
mark_pane_surface_dirty(
&mut view_state.dirty,
&layout_state,
previous_pane_id,
AttachDirtySource::FocusChanged,
);
}
mark_pane_surface_dirty(
&mut view_state.dirty,
&layout_state,
pane_id,
AttachDirtySource::FocusChanged,
);
}
view_state
.dirty
.mark_layout_refresh(AttachDirtySource::FocusChanged);
Ok(())
}
async fn resize_attach_pane(
client: &mut StreamingBmuxClient,
view_state: &mut AttachViewState,
pane_id: Uuid,
direction: bmux_tabs_plugin_api::tabs_commands::PaneResizeDirection,
cells: u16,
) -> std::result::Result<(), ClientError> {
let selector = attached_session_selector(view_state);
let _ack: bmux_tabs_plugin_api::tabs_commands::PaneAck = invoke_windows_command(
client,
"resize-pane",
&tabs_commands::client::ResizePaneRequest {
session: Some(ipc_to_windows_selector(selector)),
target: Some(pane_id_windows_selector(pane_id)),
direction,
cells: cells.max(1),
},
)
.await?;
view_state
.dirty
.mark_layout_frame_dirty(AttachDirtySource::LayoutChanged);
Ok(())
}
async fn move_attach_floating_pane(
client: &mut StreamingBmuxClient,
view_state: &mut AttachViewState,
pane_id: Uuid,
x: u16,
y: u16,
) -> std::result::Result<(), ClientError> {
let selector = attached_session_selector(view_state);
let _ack: bmux_tabs_plugin_api::tabs_commands::PaneAck = invoke_windows_command(
client,
"move-floating-pane",
&tabs_commands::client::MoveFloatingPaneRequest {
session: Some(ipc_to_windows_selector(selector)),
target: pane_id_windows_selector(pane_id),
x,
y,
},
)
.await?;
view_state
.dirty
.mark_layout_frame_dirty(AttachDirtySource::LayoutChanged);
Ok(())
}
pub fn attach_scene_pane_at(view_state: &AttachViewState, column: u16, row: u16) -> Option<Uuid> {
let layout_state = view_state.cached_layout_state.as_ref()?;
attach_mouse::pane_at(&layout_state.scene, column, row)
}
fn attach_scene_floating_drag_surface_at(
view_state: &AttachViewState,
column: u16,
row: u16,
) -> Option<AttachSurface> {
let layout_state = view_state.cached_layout_state.as_ref()?;
layout_state
.scene
.surfaces
.iter()
.filter(|surface| {
surface.visible
&& surface.accepts_input
&& matches!(surface.kind, AttachSurfaceKind::FloatingPane)
&& attach_rect_contains(surface.rect, column, row)
&& !attach_rect_contains(surface.content_rect, column, row)
})
.enumerate()
.max_by_key(|(index, surface)| (surface.layer, surface.z, *index))
.map(|(_, surface)| surface.clone())
}
fn floating_drag_position(drag: AttachMouseFloatingDrag, column: u16, row: u16) -> (u16, u16) {
let dx = i32::from(column) - i32::from(drag.start_column);
let dy = i32::from(row) - i32::from(drag.start_row);
let max_x = drag
.scene_max_x
.saturating_sub(drag.width.saturating_sub(1));
let max_y = drag
.scene_max_y
.saturating_sub(drag.height.saturating_sub(1));
(
u16::try_from((i32::from(drag.start_x) + dx).clamp(0, i32::from(max_x)))
.unwrap_or(u16::MAX),
u16::try_from((i32::from(drag.start_y) + dy).clamp(0, i32::from(max_y)))
.unwrap_or(u16::MAX),
)
}
fn attach_scene_bounds(view_state: &AttachViewState) -> Option<(u16, u16)> {
let layout_state = view_state.cached_layout_state.as_ref()?;
layout_state
.scene
.surfaces
.iter()
.filter(|surface| surface.visible)
.filter_map(|surface| Some((rect_max_x(surface.rect)?, rect_max_y(surface.rect)?)))
.reduce(|(max_x, max_y), (x, y)| (max_x.max(x), max_y.max(y)))
}
fn attach_rect_contains(rect: AttachRect, column: u16, row: u16) -> bool {
let Some(max_x) = rect_max_x(rect) else {
return false;
};
let Some(max_y) = rect_max_y(rect) else {
return false;
};
(rect.x..=max_x).contains(&column) && (rect.y..=max_y).contains(&row)
}
pub fn attach_scene_resize_separator_at(
view_state: &AttachViewState,
column: u16,
row: u16,
last_applied_at: Instant,
) -> Option<AttachMouseResizeDrag> {
let layout_state = view_state.cached_layout_state.as_ref()?;
let pane_surfaces = layout_state
.scene
.surfaces
.iter()
.filter(|surface| {
surface.visible
&& surface.accepts_input
&& matches!(surface.kind, AttachSurfaceKind::Pane)
})
.filter_map(|surface| surface.pane_id.map(|pane_id| (pane_id, surface.rect)))
.collect::<Vec<_>>();
let mut horizontal: Option<AttachMouseResizeAxisDrag> = None;
let mut vertical: Option<AttachMouseResizeAxisDrag> = None;
for (first_pane, first_rect) in &pane_surfaces {
for (second_pane, second_rect) in &pane_surfaces {
if first_pane == second_pane {
continue;
}
if horizontal.is_none()
&& let Some(drag) = vertical_resize_axis_drag(
*first_pane,
*first_rect,
*second_pane,
*second_rect,
column,
row,
)
{
horizontal = Some(drag);
}
if vertical.is_none()
&& let Some(drag) = horizontal_resize_axis_drag(
*first_pane,
*first_rect,
*second_pane,
*second_rect,
column,
row,
)
{
vertical = Some(drag);
}
if horizontal.is_some() && vertical.is_some() {
break;
}
}
if horizontal.is_some() && vertical.is_some() {
break;
}
}
(horizontal.is_some() || vertical.is_some()).then_some(AttachMouseResizeDrag {
horizontal,
vertical,
last_column: column,
last_row: row,
latest_column: column,
latest_row: row,
last_applied_at,
})
}
fn vertical_resize_axis_drag(
left_pane: Uuid,
left_rect: AttachRect,
right_pane: Uuid,
right_rect: AttachRect,
column: u16,
row: u16,
) -> Option<AttachMouseResizeAxisDrag> {
let left_max_x = rect_max_x(left_rect)?;
if left_max_x.saturating_add(1) != right_rect.x {
return None;
}
if !ranges_overlap_at(
left_rect.y,
rect_max_y(left_rect)?,
right_rect.y,
rect_max_y(right_rect)?,
row,
) {
return None;
}
if column != left_max_x && column != right_rect.x {
return None;
}
Some(AttachMouseResizeAxisDrag {
positive_target_pane_id: left_pane,
positive_direction: bmux_tabs_plugin_api::tabs_commands::PaneResizeDirection::Right,
negative_target_pane_id: right_pane,
negative_direction: bmux_tabs_plugin_api::tabs_commands::PaneResizeDirection::Left,
})
}
fn horizontal_resize_axis_drag(
top_pane: Uuid,
top_rect: AttachRect,
bottom_pane: Uuid,
bottom_rect: AttachRect,
column: u16,
row: u16,
) -> Option<AttachMouseResizeAxisDrag> {
let top_max_y = rect_max_y(top_rect)?;
if top_max_y.saturating_add(1) != bottom_rect.y {
return None;
}
if !ranges_overlap_at(
top_rect.x,
rect_max_x(top_rect)?,
bottom_rect.x,
rect_max_x(bottom_rect)?,
column,
) {
return None;
}
if row != top_max_y && row != bottom_rect.y {
return None;
}
Some(AttachMouseResizeAxisDrag {
positive_target_pane_id: top_pane,
positive_direction: bmux_tabs_plugin_api::tabs_commands::PaneResizeDirection::Down,
negative_target_pane_id: bottom_pane,
negative_direction: bmux_tabs_plugin_api::tabs_commands::PaneResizeDirection::Up,
})
}
fn ranges_overlap_at(a_start: u16, a_end: u16, b_start: u16, b_end: u16, value: u16) -> bool {
let start = a_start.max(b_start);
let end = a_end.min(b_end);
value >= start && value <= end
}
fn rect_max_x(rect: AttachRect) -> Option<u16> {
(rect.w > 0).then(|| rect.x.saturating_add(rect.w.saturating_sub(1)))
}
fn rect_max_y(rect: AttachRect) -> Option<u16> {
(rect.h > 0).then(|| rect.y.saturating_add(rect.h.saturating_sub(1)))
}
pub fn restore_terminal_after_attach_ui() -> Result<()> {
let mut stdout = io::stdout();
#[cfg(feature = "kitty-keyboard")]
let _ = queue!(stdout, crossterm::event::PopKeyboardEnhancementFlags);
let _ = queue_attach_input_mode_restore(&mut stdout);
queue!(
stdout,
Show,
Print("\x1b[0m"),
MoveTo(0, 0),
Clear(ClearType::All),
MoveTo(0, 0)
)
.context("failed restoring terminal after attach ui")?;
stdout
.flush()
.context("failed flushing terminal restoration")
}
pub fn attach_event_actions(
event: &Event,
attach_input_processor: &mut InputProcessor,
ui_mode: AttachUiMode,
) -> Result<Vec<AttachEventAction>> {
match event {
Event::Key(key) => attach_key_event_actions(key, attach_input_processor, ui_mode),
Event::Mouse(mouse) => Ok(vec![AttachEventAction::Mouse(*mouse)]),
Event::Resize(_, _) => Ok(vec![AttachEventAction::Redraw]),
Event::Paste(text) => Ok(vec![AttachEventAction::Paste(text.clone())]),
Event::FocusGained | Event::FocusLost => Ok(vec![AttachEventAction::Ignore]),
}
}
fn attach_terminal_input_event_actions(
event: &TerminalInputEvent,
attach_input_processor: &mut InputProcessor,
ui_mode: AttachUiMode,
) -> Result<Vec<AttachEventAction>> {
match event {
TerminalInputEvent::Key(key) => {
attach_terminal_key_event_actions(key, attach_input_processor, ui_mode)
}
TerminalInputEvent::Mouse(mouse) => mouse.to_crossterm().map_or_else(
|| Ok(vec![AttachEventAction::Ignore]),
|mouse| Ok(vec![AttachEventAction::Mouse(mouse)]),
),
TerminalInputEvent::Resize { .. }
| TerminalInputEvent::FocusGained
| TerminalInputEvent::FocusLost => Ok(vec![AttachEventAction::Redraw]),
TerminalInputEvent::Paste(text) => Ok(vec![AttachEventAction::Paste(text.clone())]),
}
}
fn attach_terminal_key_event_actions(
key: &TerminalKeyEvent,
attach_input_processor: &mut InputProcessor,
ui_mode: AttachUiMode,
) -> Result<Vec<AttachEventAction>> {
let Some(key) = key.to_crossterm() else {
return Ok(vec![AttachEventAction::Ignore]);
};
attach_key_event_actions(&key, attach_input_processor, ui_mode)
}
#[allow(clippy::unnecessary_wraps)] pub fn attach_key_event_actions(
key: &KeyEvent,
attach_input_processor: &mut InputProcessor,
_ui_mode: AttachUiMode,
) -> Result<Vec<AttachEventAction>> {
let span = tracing::trace_span!(
target: "bmux_cli::runtime::attach::input",
"attach_key_event",
kind = ?key.kind,
emitted = tracing::field::Empty,
dropped_by_repeat_filter = tracing::field::Empty,
);
let _enter = span.enter();
if key.kind == KeyEventKind::Release {
span.record("emitted", 0usize);
return Ok(vec![AttachEventAction::Ignore]);
}
let actions = attach_input_processor.process_terminal_event(Event::Key(*key));
let pre_filter_count = actions.len();
let is_repeat = key.kind == KeyEventKind::Repeat;
let filtered: Vec<_> = actions
.into_iter()
.filter(|action| !is_repeat || action_supports_repeat(action))
.map(runtime_action_to_attach_event_action)
.collect();
let dropped = pre_filter_count.saturating_sub(filtered.len());
span.record("emitted", filtered.len());
span.record("dropped_by_repeat_filter", dropped);
if dropped > 0 {
tracing::debug!(
target: "bmux_cli::runtime::attach::input",
kind = ?key.kind,
dropped,
"repeat filter dropped repeat-unsafe actions"
);
}
Ok(filtered)
}
pub(super) fn action_supports_repeat(action: &RuntimeAction) -> bool {
match action {
RuntimeAction::ScrollUpLine
| RuntimeAction::ScrollDownLine
| RuntimeAction::ScrollUpPage
| RuntimeAction::ScrollDownPage
| RuntimeAction::MoveCursorLeft
| RuntimeAction::MoveCursorRight
| RuntimeAction::MoveCursorUp
| RuntimeAction::MoveCursorDown
| RuntimeAction::ForwardToPane(_) => true,
RuntimeAction::PluginCommand {
plugin_id,
command_name,
..
} => command_accepts_repeat(plugin_id, command_name),
RuntimeAction::Quit
| RuntimeAction::NoOp
| RuntimeAction::Detach
| RuntimeAction::ShowHelp
| RuntimeAction::EnterScrollMode
| RuntimeAction::ExitScrollMode
| RuntimeAction::ScrollTop
| RuntimeAction::ScrollBottom
| RuntimeAction::BeginSelection
| RuntimeAction::CopyScrollback
| RuntimeAction::ConfirmScrollback
| RuntimeAction::ExitMode
| RuntimeAction::EnterMode(_)
| RuntimeAction::SwitchProfile(_) => false,
}
}
pub fn runtime_action_to_attach_event_action(action: RuntimeAction) -> AttachEventAction {
match action {
RuntimeAction::Detach => AttachEventAction::Detach,
RuntimeAction::ForwardToPane(bytes) => AttachEventAction::Send(bytes),
RuntimeAction::PluginCommand {
plugin_id,
command_name,
args,
} if is_windows_close_active_pane_command(&plugin_id, &command_name) => {
AttachEventAction::Ui(RuntimeAction::PluginCommand {
plugin_id,
command_name,
args,
})
}
RuntimeAction::PluginCommand {
plugin_id,
command_name,
args,
} => AttachEventAction::PluginCommand {
plugin_id,
command_name,
args,
},
RuntimeAction::NoOp
| RuntimeAction::ExitMode
| RuntimeAction::Quit
| RuntimeAction::ShowHelp
| RuntimeAction::EnterMode(_)
| RuntimeAction::SwitchProfile(_)
| RuntimeAction::EnterScrollMode
| RuntimeAction::ExitScrollMode
| RuntimeAction::ScrollUpLine
| RuntimeAction::ScrollDownLine
| RuntimeAction::ScrollUpPage
| RuntimeAction::ScrollDownPage
| RuntimeAction::ScrollTop
| RuntimeAction::ScrollBottom
| RuntimeAction::BeginSelection
| RuntimeAction::MoveCursorLeft
| RuntimeAction::MoveCursorRight
| RuntimeAction::MoveCursorUp
| RuntimeAction::MoveCursorDown
| RuntimeAction::CopyScrollback
| RuntimeAction::ConfirmScrollback => AttachEventAction::Ui(action),
}
}
pub fn is_attach_stream_closed_error(error: &ClientError) -> bool {
matches!(
error,
ClientError::ServerError { code: bmux_ipc::ErrorCode::NotFound, message }
if message.contains("session runtime not found")
) || matches!(
error,
ClientError::ServerError { code: bmux_ipc::ErrorCode::Internal, message }
if message.contains("plugin 'bmux.pane_runtime' service invocation failed with status 4")
)
}
pub fn is_attach_not_attached_runtime_error(error: &ClientError) -> bool {
matches!(
error,
ClientError::ServerError { message, .. }
if message.contains("not attached to session runtime")
|| (message.contains("attach-pane-output-batch typed dispatch failed")
&& message.contains("expected service invoked"))
)
}
#[cfg(test)]
mod tests {
#[cfg(feature = "bundled-plugin-tab-bar")]
fn installed_menu_action(
state: &mut super::AttachViewState,
) -> (
bmux_tab_bar_plugin::TabBarPresentation,
bmux_plugin::AttachPresentationResources,
) {
use bmux_tabs_plugin_api::tabs_list;
let events = std::sync::Arc::new(bmux_plugin::EventBus::new());
let target = uuid::Uuid::from_u128(42);
events.register_state_channel(
tabs_list::STATE_KIND,
tabs_list::TabListSnapshot {
tabs: vec![tabs_list::TabListEntry {
id: target,
name: "tab".into(),
active: true,
workspace: "default".into(),
workspace_id: uuid::Uuid::nil(),
}],
revision: 1,
},
);
events.register_state_channel(
super::ATTACH_LOCAL_PRESENTATION_STATE_KIND,
super::AttachLocalPresentationSnapshot {
viewport_cols: 80,
viewport_rows: 24,
..super::AttachLocalPresentationSnapshot::initial()
},
);
events.register_state_channel(
bmux_tabs_plugin_api::tabs_local_view::STATE_KIND,
bmux_tabs_plugin_api::tabs_local_view::TabSelection { context_id: None },
);
let resources = bmux_plugin::AttachPresentationResources {
events,
layouts: std::sync::Arc::new(bmux_plugin::layout::PluginLayoutRegistry::new(64)),
allocations: std::sync::Arc::new(bmux_plugin::layout::AllocationRegistry::default()),
surfaces: std::sync::Arc::new(bmux_plugin::surface::PluginSurfaceRegistry::new(64)),
input: std::sync::Arc::new(bmux_plugin::AttachPresentationInputRegistry::new()),
};
let installation =
bmux_tab_bar_plugin::TabBarPresentation::install(None, &resources).unwrap();
state.presentation_input = resources.input.clone();
state.presentation_layouts = resources.layouts.clone();
state.presentation_allocations = resources.allocations.clone();
state.presentation_surfaces = resources.surfaces.clone();
(installation, resources)
}
#[cfg(feature = "bundled-plugin-tab-bar")]
async fn dispatch_installed_menu(
client: &mut bmux_client::StreamingBmuxClient,
state: &mut super::AttachViewState,
resources: &bmux_plugin::AttachPresentationResources,
) {
let viewport = DamageRect::new(0, 0, 80, 24);
let lowered = super::retained_plugin_surfaces(state, viewport);
super::replace_retained_surfaces(
state,
lowered,
viewport,
DamageCoalescingPolicy::default(),
);
let (column, row) = (0..24)
.flat_map(|y| (0..80).map(move |x| (x, y)))
.find(|&(x, y)| {
state
.retained_compositor
.hit_test(x, y)
.and_then(|hit| hit.region_id)
.is_some_and(|id| {
id.owner_plugin_id == "bmux.tab_bar"
&& id.region_local_id.starts_with("tab:")
})
})
.expect("tab hit");
assert!(
super::try_handle_plugin_surface_mouse(
client,
state,
MouseEvent {
kind: MouseEventKind::Down(crossterm::event::MouseButton::Right),
column,
row,
modifiers: KeyModifiers::NONE,
}
)
.await
.unwrap()
);
assert!(state.plugin_focus.focused().is_some());
let focused = state.plugin_focus.focused().cloned();
let lowered = super::retained_plugin_surfaces(state, viewport);
super::replace_retained_surfaces(
state,
lowered,
viewport,
DamageCoalescingPolicy::default(),
);
assert_eq!(state.plugin_focus.focused(), focused.as_ref());
let mut output = Vec::new();
for surface in state.retained_compositor.surfaces().values() {
queue_retained_render_ops(
&mut output,
surface,
&retained_full_surface_repaint(surface),
bmux_plugin::TerminalRenderCapabilities::default(),
)
.unwrap();
}
let rendered = String::from_utf8_lossy(&output);
for label in ["Switch", "Rename", "Close"] {
assert!(rendered.contains(label), "missing menu label: {label}");
}
assert!(!resources.surfaces.surfaces().is_empty());
let popup_snapshot = resources.surfaces.owner_snapshot("bmux.tab_bar").unwrap();
for surface in &popup_snapshot.surfaces {
for region in &surface.interactive_regions {
if let Some(endpoint) = ®ion.endpoint {
resources.input.committed(endpoint, popup_snapshot.revision);
}
}
}
exercise_installed_menu_hover(client, state, resources).await;
let key = super::super::input::TerminalKeyEvent::from(KeyEvent::new(
KeyCode::Enter,
KeyModifiers::NONE,
));
assert!(
super::try_handle_plugin_surface_key(client, state, &key)
.await
.unwrap()
);
assert!(state.plugin_focus.focused().is_none());
}
#[cfg(feature = "bundled-plugin-tab-bar")]
async fn exercise_installed_menu_hover(
client: &mut bmux_client::StreamingBmuxClient,
state: &mut super::AttachViewState,
resources: &bmux_plugin::AttachPresentationResources,
) {
let viewport = DamageRect::new(0, 0, 80, 24);
for item in ["item.1", "item.2", "item.1", "item.0"] {
let before = resources.surfaces.owner_snapshot("bmux.tab_bar").unwrap();
let popup = before
.surfaces
.iter()
.find(|surface| surface.id.local_id == "menu")
.unwrap();
let bmux_plugin::surface::PluginSurfaceTarget::Explicit(rect) = popup.target else {
panic!("popup");
};
let hit = popup
.interactive_regions
.iter()
.find(|hit| hit.local_id == item)
.unwrap();
let mouse = MouseEvent {
kind: MouseEventKind::Moved,
column: rect.x + hit.rect.x,
row: rect.y + hit.rect.y,
modifiers: KeyModifiers::NONE,
};
handle_attach_mouse_event_at(
client,
mouse,
state,
None,
Instant::now(),
TerminalGeometry { cols: 80, rows: 24 },
)
.await
.unwrap();
let after = resources.surfaces.owner_snapshot("bmux.tab_bar").unwrap();
assert!(after.revision > before.revision);
let next_popup = after
.surfaces
.iter()
.find(|surface| surface.id.local_id == "menu")
.unwrap();
assert_ne!(popup.ops, next_popup.ops);
super::acknowledge_plugin_surface_output(state);
let mut down = mouse;
down.kind = MouseEventKind::Down(crossterm::event::MouseButton::Left);
assert!(!super::dismiss_plugin_focus_on_pointer_down(state, down));
assert!(state.plugin_focus.focused().is_some());
let lowered = super::retained_plugin_surfaces(state, viewport);
super::replace_retained_surfaces(
state,
lowered,
viewport,
DamageCoalescingPolicy::default(),
);
for region in &next_popup.interactive_regions {
if let Some(endpoint) = ®ion.endpoint {
resources.input.committed(endpoint, after.revision);
}
}
assert!(
super::try_handle_plugin_surface_mouse(client, state, mouse)
.await
.unwrap()
);
assert_eq!(
resources
.surfaces
.owner_snapshot("bmux.tab_bar")
.unwrap()
.revision,
after.revision
);
}
}
#[cfg(feature = "bundled-plugin-tab-bar")]
#[tokio::test]
async fn surface_action_uses_framed_service_transport() {
use bmux_ipc::transport::ErasedIpcStream;
use bmux_ipc::{Envelope, EnvelopeKind, Response, ResponsePayload};
let (client_stream, server_stream) = tokio::io::duplex(8192);
let server = tokio::spawn(async move {
let mut stream = ErasedIpcStream::new(Box::new(server_stream));
for step in 0..3 {
let envelope = stream.recv_envelope().await.unwrap();
let request: bmux_ipc::Request = bmux_ipc::decode(&envelope.payload).unwrap();
let response = if step == 0 {
let bmux_ipc::Request::Hello { contract, .. } = request else {
panic!("expected hello")
};
Response::Ok(ResponsePayload::HelloNegotiated {
negotiated: bmux_ipc::NegotiatedProtocol {
wire_epoch: contract.wire_epoch,
revision: contract.revisions.max,
capabilities: Vec::new(),
},
})
} else {
use bmux_tabs_plugin_api::tabs_commands::client::{
SwitchTabEndpoint, SwitchTabRequest,
};
let bmux_ipc::Request::InvokeService {
capability,
interface_id,
operation,
payload,
..
} = request
else {
panic!("expected service")
};
assert_eq!(capability, SwitchTabEndpoint::CAPABILITY.as_str());
assert_eq!(interface_id, SwitchTabEndpoint::INTERFACE_ID.as_str());
assert_eq!(operation, SwitchTabEndpoint::OPERATION.as_str());
let request: SwitchTabRequest =
bmux_plugin_sdk::decode_service_message(&payload).unwrap();
assert_eq!(request.target, uuid::Uuid::from_u128(42).to_string());
if step == 1 {
Response::Err(bmux_ipc::ErrorResponse {
code: bmux_ipc::ErrorCode::Internal,
message: "transport action rejected".into(),
})
} else {
Response::Ok(ResponsePayload::ServiceInvoked {
payload: Vec::new(),
})
}
};
stream
.send_envelope(&Envelope::new(
envelope.request_id,
EnvelopeKind::Response,
bmux_ipc::encode(&response).unwrap(),
))
.await
.unwrap();
}
});
let client = bmux_client::BmuxClient::connect_with_bridge_stream(
ErasedIpcStream::new(Box::new(client_stream)),
std::time::Duration::from_secs(2),
"surface-test",
uuid::Uuid::new_v4(),
)
.await
.unwrap();
let mut client = bmux_client::StreamingBmuxClient::from_client(client).unwrap();
let mut state = attach_view_state_with_scrollback_fixture();
state.mouse.config.enabled = true;
let pane_id = focused_attach_pane_id(&state).unwrap();
append_pane_output(
state.pane_buffers.get_mut(&pane_id).unwrap(),
b"\x1b[?1049h\x1b[?1003h\x1b[?1006h",
);
let (installation, resources) = installed_menu_action(&mut state);
let mut survivor = super::AttachViewState::new(bmux_client::AttachOpenInfo {
context_id: None,
session_id: uuid::Uuid::new_v4(),
can_write: true,
});
let (_survivor_installation, survivor_resources) = installed_menu_action(&mut survivor);
let before = survivor_resources.surfaces.surfaces();
dispatch_installed_menu(&mut client, &mut state, &resources).await;
assert!(
state
.transient_status
.as_deref()
.unwrap()
.contains("transport action rejected")
);
drop(installation);
assert!(resources.surfaces.surfaces().is_empty());
assert_eq!(survivor_resources.surfaces.surfaces(), before);
dispatch_installed_menu(&mut client, &mut survivor, &survivor_resources).await;
assert!(survivor.transient_status.is_none());
server.await.unwrap();
}
#[cfg(feature = "bundled-plugin-tab-bar")]
#[tokio::test]
async fn inline_rename_uses_attachment_local_output_and_input() {
for workspace in [false, true] {
let mut state = AttachViewState::new(AttachOpenInfo {
context_id: None,
session_id: Uuid::new_v4(),
can_write: true,
});
let (_installation, resources) = installed_menu_action(&mut state);
let endpoint = bmux_plugin::AttachInputEndpoint {
capability: "bmux.tab_bar.input".into(),
interface_id: "presentation-input".into(),
operation: "handle-input".into(),
};
let target = if workspace {
format!("workspace:{}", Uuid::nil())
} else {
format!("tab:{}", Uuid::from_u128(42))
};
let mut event = bmux_plugin::AttachInputEvent {
hook_id: format!("bmux.tab_bar:strip:{target}"),
event_kind: "pointer".into(),
phase: "down".into(),
button: Some("left".into()),
key: None,
col: Some(0),
row: Some(0),
wheel_delta: 0,
modifiers: bmux_plugin::AttachInputModifiers::default(),
focused_pane: None,
hovered_pane: None,
};
let viewport = DamageRect::new(0, 0, 80, 24);
let surfaces = super::retained_plugin_surfaces(&state, viewport);
super::replace_retained_surfaces(
&mut state,
surfaces,
viewport,
DamageCoalescingPolicy::default(),
);
let hit = (0..24)
.flat_map(|y| (0..80).map(move |x| (x, y)))
.filter_map(|(x, y)| state.retained_compositor.hit_test(x, y))
.find(|hit| {
hit.region_id
.as_ref()
.is_some_and(|id| id.region_local_id == target)
})
.expect("rename field");
begin_rename_through_pointer_router(&mut state, &hit, viewport).await;
event.event_kind = "key".into();
event.phase = "press".into();
event.key = Some("backspace".into());
assert!(resources.input.invoke(&endpoint, &event).unwrap().consumed);
event.key = Some("enter".into());
let empty = resources.input.invoke(&endpoint, &event).unwrap();
assert!(empty.status_message.unwrap().contains("must not be empty"));
assert!(empty.service_invocation.is_none());
event.key = Some("界".into());
assert!(resources.input.invoke(&endpoint, &event).unwrap().consumed);
assert!(super::try_handle_plugin_surface_paste(
&mut state, " renamed"
));
event.key = Some("enter".into());
let result = resources.input.invoke(&endpoint, &event).unwrap();
let invocation = result.service_invocation.expect("rename submission");
assert!(
invocation
.payload
.windows("界 renamed".len())
.any(|bytes| bytes == "界 renamed".as_bytes())
);
}
}
#[cfg(feature = "bundled-plugin-tab-bar")]
async fn begin_rename_through_pointer_router(
state: &mut AttachViewState,
hit: &bmux_attach_pipeline::RetainedSurfaceHit,
viewport: DamageRect,
) {
let mut client = disconnected_focus_test_client().await;
let down = MouseEvent {
kind: MouseEventKind::Down(crossterm::event::MouseButton::Left),
column: hit.absolute_x,
row: hit.absolute_y,
modifiers: KeyModifiers::NONE,
};
for _ in 0..2 {
assert!(
try_handle_plugin_surface_mouse(&mut client, state, down)
.await
.unwrap()
);
}
let up = MouseEvent {
kind: MouseEventKind::Up(crossterm::event::MouseButton::Left),
..down
};
assert!(
try_handle_plugin_surface_mouse(&mut client, state, up)
.await
.unwrap()
);
let surfaces = super::retained_plugin_surfaces(state, viewport);
super::replace_retained_surfaces(
state,
surfaces,
viewport,
DamageCoalescingPolicy::default(),
);
super::acknowledge_plugin_surface_output(state);
assert!(state.plugin_focus.focused().is_some());
let backspace = super::super::input::TerminalKeyEvent::from(KeyEvent::new(
KeyCode::Backspace,
KeyModifiers::NONE,
));
assert!(
try_handle_plugin_surface_key(&mut client, state, &backspace)
.await
.unwrap()
);
}
struct FailingWriter {
fail_flush: bool,
}
impl std::io::Write for FailingWriter {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
if self.fail_flush {
Ok(bytes.len())
} else {
Err(std::io::Error::other("write rejected"))
}
}
fn flush(&mut self) -> std::io::Result<()> {
Err(std::io::Error::other("flush rejected"))
}
}
#[test]
fn terminal_and_headless_same_viewport_share_prepared_frame_and_fail_transactionally() {
let session_id = Uuid::new_v4();
let pane_id = Uuid::new_v4();
let make_state = || {
let mut state = AttachViewState::with_private_presentation(AttachOpenInfo {
context_id: None,
session_id,
can_write: true,
});
let layout = retry_test_layout(&mut state, pane_id, session_id);
state.presentation_extensions = Arc::new(bmux_plugin::RenderExtensionRegistry::new());
state
.pane_buffers
.get_mut(&pane_id)
.unwrap()
.terminal_grid
.process(b"same output");
(state, layout)
};
let (mut terminal, terminal_layout) = make_state();
let (publisher, observed) = super::super::observer::attach_observer_channel();
terminal.set_observer(publisher);
render_observed_test_frame(&mut terminal, &terminal_layout);
let (mut headless, headless_layout) = make_state();
headless.presentation_only = true;
let (publisher, output) = super::super::observer::attach_observer_channel();
headless.set_observer(publisher);
let mut forbidden_writer = FailingWriter { fail_flush: false };
render_attach_frame_to_writer(
&mut forbidden_writer,
&mut headless,
&headless_layout,
&RuntimeAppearance::default(),
&[],
0,
&bmux_config::DamageBehaviorConfig::default(),
u64::MAX,
&mut DisplayCaptureFanout::default(),
TerminalGeometry { cols: 80, rows: 24 },
None,
)
.unwrap();
assert_eq!(
observed.latest().unwrap().prepared,
output.latest().unwrap().prepared
);
let committed = output.latest().unwrap();
headless
.observer
.as_mut()
.unwrap()
.set_delivery(|_| anyhow::bail!("serialization rejected"));
headless
.dirty
.mark_full_frame(AttachDirtySource::ManualRedraw);
assert!(
render_attach_frame_to_writer(
&mut forbidden_writer,
&mut headless,
&headless_layout,
&RuntimeAppearance::default(),
&[],
0,
&bmux_config::DamageBehaviorConfig::default(),
u64::MAX,
&mut DisplayCaptureFanout::default(),
TerminalGeometry { cols: 80, rows: 24 },
None
)
.is_ok()
);
assert!(Arc::ptr_eq(&committed, &output.latest().unwrap()));
}
#[test]
fn headless_rejection_preserves_commit_deduplicates_status_and_recovers() {
let session = Uuid::new_v4();
let pane = Uuid::new_v4();
let mut state = AttachViewState::with_private_presentation(AttachOpenInfo {
context_id: None,
session_id: session,
can_write: true,
});
let layout = retry_test_layout(&mut state, pane, session);
state.presentation_only = true;
state.presentation_extensions = Arc::new(bmux_plugin::RenderExtensionRegistry::new());
let (mut publisher, observer) = super::super::observer::attach_observer_channel();
let reports = Arc::new(std::sync::Mutex::new(Vec::<Option<String>>::new()));
let captured = Arc::clone(&reports);
publisher.set_rejection(move |message| {
captured.lock().unwrap().push(message.map(str::to_owned));
});
state.set_observer(publisher);
render_observed_test_frame(&mut state, &layout);
let committed = observer.latest().unwrap();
state
.observer
.as_mut()
.unwrap()
.set_delivery(|_| anyhow::bail!("resource bytes: used=9437184, limit=8388608"));
for _ in 0..3 {
state.dirty.mark_full_frame(AttachDirtySource::ManualRedraw);
render_observed_test_frame(&mut state, &layout);
assert!(Arc::ptr_eq(&committed, &observer.latest().unwrap()));
}
assert_eq!(reports.lock().unwrap().len(), 1);
let committed_area = state.presentation.area();
render_attach_frame_to_writer(
&mut Vec::new(),
&mut state,
&layout,
&RuntimeAppearance::default(),
&[],
0,
&bmux_config::DamageBehaviorConfig::default(),
u64::MAX,
&mut DisplayCaptureFanout::default(),
TerminalGeometry {
cols: 120,
rows: 40,
},
None,
)
.unwrap();
assert_eq!(state.presentation.area(), committed_area);
assert!(Arc::ptr_eq(&committed, &observer.latest().unwrap()));
assert!(
reports.lock().unwrap()[0]
.as_ref()
.unwrap()
.contains("9437184")
);
state.observer.as_mut().unwrap().set_delivery(|_| Ok(()));
state.dirty.mark_full_frame(AttachDirtySource::ManualRedraw);
render_observed_test_frame(&mut state, &layout);
assert!(observer.latest().unwrap().revision > committed.revision);
assert_eq!(reports.lock().unwrap().last().unwrap(), &None);
}
#[test]
fn observed_synchronized_update_retains_commit_until_complete() {
for headless in [false, true] {
let session_id = Uuid::new_v4();
let pane_id = Uuid::new_v4();
let mut state = AttachViewState::with_private_presentation(AttachOpenInfo {
context_id: None,
session_id,
can_write: true,
});
let layout = retry_test_layout(&mut state, pane_id, session_id);
state.presentation_extensions = Arc::new(bmux_plugin::RenderExtensionRegistry::new());
state.presentation_only = headless;
let (publisher, observer) = super::super::observer::attach_observer_channel();
state.set_observer(publisher);
state
.pane_buffers
.get_mut(&pane_id)
.unwrap()
.sync_update_in_progress = true;
render_observed_test_frame(&mut state, &layout);
assert!(observer.latest().is_none());
state
.pane_buffers
.get_mut(&pane_id)
.unwrap()
.sync_update_in_progress = false;
render_observed_test_frame(&mut state, &layout);
let committed = observer.latest().unwrap();
let graph = state.retained_compositor.clone();
let cursor = state.last_cursor_state;
let buffer = state.pane_buffers.get_mut(&pane_id).unwrap();
buffer.sync_update_in_progress = true;
buffer
.terminal_grid
.process(b"\x1b[Hcompleted atomic update");
state.dirty.mark_full_frame(AttachDirtySource::ManualRedraw);
render_observed_test_frame(&mut state, &layout);
assert!(Arc::ptr_eq(&committed, &observer.latest().unwrap()));
assert_eq!(state.retained_compositor, graph);
assert_eq!(state.last_cursor_state, cursor);
assert!(state.dirty.needs_render());
state
.pane_buffers
.get_mut(&pane_id)
.unwrap()
.sync_update_in_progress = false;
render_observed_test_frame(&mut state, &layout);
let next = observer.latest().unwrap();
assert!(next.revision > committed.revision);
assert_ne!(next.prepared.buffer(), committed.prepared.buffer());
}
}
#[test]
fn selected_driver_viewport_survives_retarget_geometry_selection() {
let mut state = AttachViewState::with_private_presentation(AttachOpenInfo {
session_id: Uuid::new_v4(),
context_id: None,
can_write: true,
});
for geometry in [
TerminalGeometry {
cols: 100,
rows: 30,
},
TerminalGeometry { cols: 60, rows: 16 },
TerminalGeometry {
cols: 120,
rows: 36,
},
] {
state.driver_geometry = Some(geometry);
state.attached_id = Uuid::new_v4();
let selected = selected_attach_geometry(&state);
assert_eq!(
(selected.cols, selected.rows),
(geometry.cols, geometry.rows)
);
}
}
#[test]
fn extension_query_preserves_unchanged_prompt_on_terminal() {
let session = Uuid::new_v4();
let pane = Uuid::new_v4();
let mut state = AttachViewState::with_private_presentation(AttachOpenInfo {
session_id: session,
context_id: None,
can_write: true,
});
let layout = retry_test_layout(&mut state, pane, session);
install_bundled_render_extensions(&mut state);
state.prompt.enqueue_internal(
PromptRequest::search_select(
"Find Tab",
vec![PromptOption::new("tab-2", "default/tab-2")],
),
super::super::prompt_ui::AttachInternalPromptAction::QuitSession,
);
let render = |state: &mut AttachViewState| {
let mut bytes = Vec::new();
render_attach_frame_to_writer(
&mut bytes,
state,
&layout,
&RuntimeAppearance::default(),
&[],
0,
&bmux_config::DamageBehaviorConfig::default(),
u64::MAX,
&mut DisplayCaptureFanout::default(),
TerminalGeometry { cols: 80, rows: 24 },
None,
)
.unwrap();
bytes
};
let mut screen = bmux_terminal_grid::TerminalGridStream::new(
80,
24,
bmux_terminal_grid::GridLimits::default(),
)
.unwrap();
screen.process(&render(&mut state));
assert!(bmux_terminal_grid::visible_text(screen.grid(), 0, 24).contains("default/tab-2"));
state
.pane_buffers
.get_mut(&pane)
.unwrap()
.terminal_grid
.process(b"\x1b[2J\x1b[HNAV_RESULT_MARKER");
state
.dirty
.mark_pane_dirty(pane, AttachDirtySource::PaneOutput);
state
.dirty
.mark_extension_dirty(AttachDirtySource::PluginCommand);
screen.process(&render(&mut state));
assert!(bmux_terminal_grid::visible_text(screen.grid(), 0, 24).contains("default/tab-2"));
}
fn render_observed_test_frame(state: &mut AttachViewState, layout: &AttachLayoutState) {
render_attach_frame_to_writer(
&mut Vec::new(),
state,
layout,
&RuntimeAppearance::default(),
&[],
0,
&bmux_config::DamageBehaviorConfig::default(),
u64::MAX,
&mut DisplayCaptureFanout::default(),
TerminalGeometry { cols: 80, rows: 24 },
None,
)
.unwrap();
}
#[test]
#[allow(clippy::too_many_lines)]
fn observer_full_repair_preserves_unchanged_cells_and_owner_lifetime() {
use bmux_plugin::surface::{
PluginSurface, PluginSurfaceId, PluginSurfaceSnapshot, PluginSurfaceTarget,
};
let session_id = Uuid::new_v4();
let pane_id = Uuid::new_v4();
let mut state = AttachViewState::with_private_presentation(AttachOpenInfo {
context_id: None,
session_id,
can_write: true,
});
let layout = retry_test_layout(&mut state, pane_id, session_id);
state.presentation_extensions = Arc::new(bmux_plugin::RenderExtensionRegistry::new());
let (publisher, observer) = super::super::observer::attach_observer_channel();
state.set_observer(publisher);
state
.pane_buffers
.get_mut(&pane_id)
.unwrap()
.terminal_grid
.process(b"\x1b[2J\x1b[HA");
render_observed_test_frame(&mut state, &layout);
let first = observer.latest().unwrap();
let a = first
.prepared
.buffer()
.cells()
.iter()
.position(|cell| cell.symbol == "A")
.expect("initial A");
state
.pane_buffers
.get_mut(&pane_id)
.unwrap()
.terminal_grid
.process(b"B");
state
.dirty
.mark_pane_dirty(pane_id, AttachDirtySource::ManualRedraw);
render_observed_test_frame(&mut state, &layout);
let second = observer.latest().unwrap();
assert_eq!(second.prepared.buffer().cells()[a].symbol, "A");
assert!(
second
.prepared
.buffer()
.cells()
.iter()
.any(|cell| cell.symbol == "B")
);
let stream_before_chrome = state
.pane_buffers
.get(&pane_id)
.unwrap()
.terminal_grid
.grid()
.content_revision();
let owner = state.presentation_surfaces.clone();
let mut surface_changes = owner.subscribe();
let publisher = owner.publisher("test.chrome");
let id = PluginSurfaceId::new("test.chrome", "status", Uuid::new_v4());
let surface = PluginSurface {
id: id.clone(),
revision: 1,
target: PluginSurfaceTarget::Explicit(ExtensionRect::new(0, 0, 8, 1)),
clip_rect: None,
metadata: None,
interactive_regions: Vec::new(),
accepts_input: false,
layer: 10,
z: 0,
opaque: true,
modal: false,
visible: true,
ops: vec![RenderOp::TextRun {
x: 0,
y: 0,
text: "chrome".into(),
style: bmux_plugin::RenderStyle::default(),
}],
};
publisher
.publish(PluginSurfaceSnapshot {
revision: 1,
surfaces: vec![surface.clone()],
})
.unwrap();
assert!(surface_changes.has_changed().unwrap());
surface_changes.borrow_and_update();
state
.dirty
.mark_retained_surfaces_dirty(AttachDirtySource::SceneChanged);
render_observed_test_frame(&mut state, &layout);
let chrome = observer.latest().unwrap();
assert_eq!(
state
.pane_buffers
.get(&pane_id)
.unwrap()
.terminal_grid
.grid()
.content_revision(),
stream_before_chrome
);
assert!(chrome.revision > second.revision);
assert!(
chrome
.prepared
.buffer()
.row_symbols(0)
.unwrap()
.contains("chrome")
);
assert!(
chrome
.surfaces
.iter()
.any(
|surface| surface.owner_plugin_id.as_deref() == Some("test.chrome")
&& surface.revision == Some(1)
)
);
let mut updated = surface;
updated.revision = 2;
updated.ops = vec![RenderOp::TextRun {
x: 0,
y: 0,
text: "CHROME".into(),
style: bmux_plugin::RenderStyle::default(),
}];
publisher
.publish(PluginSurfaceSnapshot {
revision: 2,
surfaces: vec![updated],
})
.unwrap();
assert!(surface_changes.has_changed().unwrap());
surface_changes.borrow_and_update();
state
.dirty
.mark_retained_surfaces_dirty(AttachDirtySource::SceneChanged);
render_observed_test_frame(&mut state, &layout);
let updated_frame = observer.latest().unwrap();
assert!(
updated_frame
.prepared
.buffer()
.row_symbols(0)
.unwrap()
.contains("CHROME")
);
assert!(updated_frame.surfaces.iter().any(|surface| surface.retained_id == id.retained_id && surface.revision == Some(2)));
assert_eq!(
state
.pane_buffers
.get(&pane_id)
.unwrap()
.terminal_grid
.grid()
.content_revision(),
stream_before_chrome
);
drop(publisher);
assert!(surface_changes.has_changed().unwrap());
surface_changes.borrow_and_update();
state
.dirty
.mark_retained_surfaces_dirty(AttachDirtySource::SceneChanged);
render_observed_test_frame(&mut state, &layout);
assert!(
!observer
.latest()
.unwrap()
.surfaces
.iter()
.any(|surface| surface.retained_id == id.retained_id)
);
let removed_row = observer
.latest()
.unwrap()
.prepared
.buffer()
.row_symbols(0)
.unwrap();
assert!(!removed_row.contains("chrome") && !removed_row.contains("CHROME"));
assert_eq!(
state
.pane_buffers
.get(&pane_id)
.unwrap()
.terminal_grid
.grid()
.content_revision(),
stream_before_chrome
);
let committed = observer.latest().unwrap();
let revision = committed.revision;
let result = render_attach_frame_to_writer(
&mut FailingWriter { fail_flush: true },
&mut state,
&layout,
&RuntimeAppearance::default(),
&[],
0,
&bmux_config::DamageBehaviorConfig::default(),
u64::MAX,
&mut DisplayCaptureFanout::default(),
TerminalGeometry { cols: 80, rows: 24 },
None,
);
assert!(result.is_err());
assert_eq!(observer.latest().unwrap().revision, revision);
assert!(Arc::ptr_eq(&observer.latest().unwrap(), &committed));
drop(state);
assert!(observer.latest().is_none());
}
#[test]
fn failed_terminal_frames_do_not_commit_display_capture() {
for fail_flush in [false, true] {
let dir = tempfile::tempdir().unwrap();
let client_id = Uuid::new_v4();
let recording_id = Uuid::new_v4();
let mut capture = DisplayCaptureFanout::default();
capture.writers.insert(
recording_id,
display_capture::DisplayCaptureWriter::open(
recording_id,
dir.path(),
client_id,
None,
)
.unwrap(),
);
capture.writers[&recording_id].flush().unwrap();
let track_path = dir.path().join(format!("display-{client_id}.bin"));
let initial = std::fs::read(&track_path).unwrap();
let initial = bmux_recording_protocol::read_frames::<
bmux_recording_protocol::DisplayTrackEnvelope,
>(&initial)
.unwrap();
let baseline_frames = initial
.frames
.iter()
.filter(|frame| {
matches!(
frame.event,
bmux_recording_protocol::DisplayTrackEvent::FrameBytes { .. }
)
})
.count();
let pane_id = Uuid::new_v4();
let session_id = Uuid::new_v4();
let mut state = AttachViewState::new(AttachOpenInfo {
context_id: None,
session_id,
can_write: true,
});
let layout = retry_test_layout(&mut state, pane_id, session_id);
let prior_cursor = Some(super::super::state::AttachCursorState {
x: 7,
y: 3,
visible: true,
});
state.last_cursor_state = prior_cursor;
let now = Instant::now();
state.scroll_started.insert(pane_id, (now, now));
let result = render_attach_frame_to_writer(
&mut FailingWriter { fail_flush },
&mut state,
&layout,
&RuntimeAppearance::default(),
&[],
0,
&bmux_config::DamageBehaviorConfig::default(),
u64::MAX,
&mut capture,
TerminalGeometry { cols: 80, rows: 24 },
None,
);
assert!(result.is_err());
assert!(state.scroll_started.contains_key(&pane_id));
assert_eq!(
state.last_cursor_state, prior_cursor,
"failed output committed cursor state"
);
capture.close_all();
let bytes = std::fs::read(dir.path().join(format!("display-{client_id}.bin"))).unwrap();
let track = bmux_recording_protocol::read_frames::<
bmux_recording_protocol::DisplayTrackEnvelope,
>(&bytes)
.unwrap();
assert_eq!(
track
.frames
.iter()
.filter(|frame| matches!(
frame.event,
bmux_recording_protocol::DisplayTrackEvent::FrameBytes { .. }
))
.count(),
baseline_frames
);
verify_frame_retry(&mut state, &layout);
}
}
#[cfg(feature = "image-sixel")]
fn assert_single_observed_image(observer: &super::super::observer::AttachObserver) {
assert_eq!(
observer
.latest()
.unwrap()
.prepared
.image_scene()
.placements()
.count(),
1
);
}
#[cfg(feature = "image-sixel")]
fn observed_test_image(
observer: &super::super::observer::AttachObserver,
) -> bmux_tui::image::ImagePlacement {
observer
.latest()
.unwrap()
.prepared
.image_scene()
.placements()
.next()
.unwrap()
.clone()
}
#[cfg(feature = "image-sixel")]
#[test]
fn headless_images_do_not_require_terminal_protocol_advertisement() {
let session = Uuid::new_v4();
let pane = Uuid::new_v4();
let mut state = AttachViewState::with_private_presentation(AttachOpenInfo {
session_id: session,
context_id: None,
can_write: true,
});
let layout = retry_test_layout(&mut state, pane, session);
state.presentation_only = true;
assert!(!state.host_image_caps.any_supported());
state.pane_images.insert(
pane,
vec![bmux_attach_image_protocol::AttachPaneImage {
id: 42,
protocol: bmux_attach_image_protocol::AttachImageProtocol::Sixel,
raw_data: b"\"1;1;1;6#1;2;100;0;0~".to_vec(),
compression: bmux_attach_image_protocol::CompressionId::None,
position_row: 0,
position_col: 3,
cell_rows: 1,
cell_cols: 1,
pixel_width: 1,
pixel_height: 6,
}],
);
let (publisher, observer) = super::super::observer::attach_observer_channel();
state.set_observer(publisher);
render_attach_frame_to_writer(
&mut FailingWriter { fail_flush: false },
&mut state,
&layout,
&RuntimeAppearance::default(),
&[],
0,
&bmux_config::DamageBehaviorConfig::default(),
u64::MAX,
&mut DisplayCaptureFanout::default(),
TerminalGeometry { cols: 80, rows: 24 },
None,
)
.unwrap();
assert_single_observed_image(&observer);
assert!(!state.host_image_caps.any_supported());
let initial = observed_test_image(&observer);
state
.pane_buffers
.get_mut(&pane)
.unwrap()
.expected_stream_start = Some(900);
state.pane_images.get_mut(&pane).unwrap()[0].position_col = 9;
state.dirty.mark_full_frame(AttachDirtySource::ManualRedraw);
render_observed_test_frame(&mut state, &layout);
let moved = observed_test_image(&observer);
assert_eq!(initial.key, moved.key);
assert_ne!(initial.destination, moved.destination);
state.pane_images.get_mut(&pane).unwrap()[0].raw_data = b"\"1;1;1;6#1;2;0;100;0~".to_vec();
state.dirty.mark_full_frame(AttachDirtySource::ManualRedraw);
render_observed_test_frame(&mut state, &layout);
let replaced = observed_test_image(&observer);
assert_eq!(moved.key, replaced.key);
assert_ne!(moved.payload, replaced.payload);
let retained = state.pane_images[&pane].clone();
handle_pane_runtime_plugin_event(
pane_events::PaneEvent::Restarted {
session_id: session,
pane_id: pane,
},
&mut state,
&mut false,
);
assert!(!state.pane_images.contains_key(&pane));
state.pane_images.insert(pane, retained);
state.dirty.mark_full_frame(AttachDirtySource::ManualRedraw);
render_observed_test_frame(&mut state, &layout);
let restarted = observed_test_image(&observer);
assert_ne!(replaced.key, restarted.key);
}
#[cfg(feature = "image-sixel")]
#[test]
fn historical_images_are_emitted_and_cleared_by_attachment_frames() {
let session = Uuid::new_v4();
let pane = Uuid::new_v4();
let mut state = AttachViewState::new(bmux_client::AttachOpenInfo {
session_id: session,
context_id: None,
can_write: true,
});
let layout = retry_test_layout(&mut state, pane, session);
state.host_image_caps = bmux_image::host_caps::HostImageCapabilities {
sixel: true,
..Default::default()
};
state.pane_scrollback.insert(
pane,
bmux_attach_pipeline::PaneScrollbackView {
offset: 1,
cursor: bmux_attach_pipeline::AttachScrollbackCursor { row: 0, col: 0 },
selection_anchor: None,
captured_selection: None,
pin: None,
},
);
state.pane_buffers.get_mut(&pane).unwrap().scrollback_window =
Some(bmux_attach_pipeline::PaneScrollbackWindow {
images: vec![bmux_attach_image_protocol::AttachPaneImage {
id: 42,
protocol: bmux_attach_image_protocol::AttachImageProtocol::Sixel,
raw_data: b"\"1;1;1;6#1;2;100;0;0~".to_vec(),
compression: bmux_attach_image_protocol::CompressionId::None,
position_row: 0,
position_col: 3,
cell_rows: 1,
cell_cols: 1,
pixel_width: 1,
pixel_height: 6,
}],
projection_width: 78,
row_anchors: Vec::new(),
palette: bmux_terminal_grid::StylePalette::default(),
scrollback_offset: 1,
max_scrollback_offset: 1,
total_scrolled_rows: 1,
rows: vec![bmux_terminal_grid::PhysicalRow::default(); 22],
});
for visible in [true, false] {
if !visible {
state
.pane_buffers
.get_mut(&pane)
.unwrap()
.scrollback_window
.as_mut()
.unwrap()
.images
.clear();
}
state.dirty.mark_full_frame(AttachDirtySource::Scrollback);
let mut output = Vec::new();
render_attach_frame_to_writer(
&mut output,
&mut state,
&layout,
&RuntimeAppearance::default(),
&[],
0,
&bmux_config::DamageBehaviorConfig::default(),
u64::MAX,
&mut DisplayCaptureFanout::default(),
TerminalGeometry { cols: 80, rows: 24 },
None,
)
.unwrap();
assert_eq!(output.windows(2).any(|bytes| bytes == b"\x1bP"), visible);
if !visible {
let clear = output
.windows(4)
.position(|bytes| bytes == b"\x1b[2J")
.unwrap();
let begin = output
.windows(8)
.position(|bytes| bytes == b"\x1b[?2026h")
.unwrap();
assert!(begin < clear, "raster deletion must be inside the frame");
}
assert_eq!(state.pane_images_presented, visible);
}
}
fn retry_test_layout(
state: &mut AttachViewState,
pane_id: Uuid,
session_id: Uuid,
) -> AttachLayoutState {
seed_retry_graphics_cache(state, pane_id);
state
.pane_buffers
.insert(pane_id, PaneRenderBuffer::default());
append_pane_output(
state.pane_buffers.get_mut(&pane_id).unwrap(),
b"retry-visible-content",
);
AttachLayoutState {
context_id: None,
session_id,
focused_pane_id: pane_id,
panes: Vec::new(),
layout_root: PaneLayoutNode::Leaf { pane_id },
scene: AttachScene {
session_id,
focus: AttachFocusTarget::Pane { pane_id },
surfaces: vec![test_pane_surface(
pane_id,
AttachRect {
x: 0,
y: 0,
w: 80,
h: 24,
},
)],
},
zoomed: false,
}
}
#[cfg(feature = "image-kitty")]
#[test]
fn kitty_pane_images_survive_idle_redraw_and_slide_navigation_without_ed2() {
let session = Uuid::new_v4();
let pane = Uuid::new_v4();
let mut state = AttachViewState::new(bmux_client::AttachOpenInfo {
session_id: session,
context_id: None,
can_write: true,
});
let layout = retry_test_layout(&mut state, pane, session);
state.host_image_caps.kitty_graphics = true;
let image = bmux_attach_image_protocol::AttachPaneImage {
id: 42,
protocol: bmux_attach_image_protocol::AttachImageProtocol::KittyGraphics,
raw_data: include_bytes!("../../../../presenter/examples/architecture.png").to_vec(),
compression: bmux_attach_image_protocol::CompressionId::None,
position_row: 1,
position_col: 3,
cell_rows: 8,
cell_cols: 30,
pixel_width: 640,
pixel_height: 240,
};
let render = |state: &mut AttachViewState| {
let mut output = Vec::new();
render_attach_frame_to_writer(
&mut output,
state,
&layout,
&RuntimeAppearance::default(),
&[],
0,
&bmux_config::DamageBehaviorConfig::default(),
u64::MAX,
&mut DisplayCaptureFanout::default(),
TerminalGeometry { cols: 80, rows: 24 },
None,
)
.unwrap();
output
};
for visible in [true, true, false, true, false, true] {
state
.pane_images
.insert(pane, if visible { vec![image.clone()] } else { vec![] });
state.dirty.mark_full_frame(AttachDirtySource::ManualRedraw);
let output = render(&mut state);
assert!(
!output.windows(4).any(|bytes| bytes == b"\x1b[2J"),
"Kitty frame erased uploads"
);
assert_eq!(String::from_utf8_lossy(&output).contains("Ga=p"), visible);
assert!(
!state.pane_images_presented,
"Kitty marked as raster footprint"
);
}
}
#[cfg(feature = "image-kitty")]
#[test]
fn partial_attach_frame_preserves_retained_kitty_image() {
use bmux_image::compositor::{PaneRect, render_pane_images};
use bmux_image::model::{
ImageCellSize, ImagePayload, ImagePixelSize, ImagePosition, ImageProtocol, PaneImage,
PixelBuffer, PixelFormat,
};
let session = Uuid::new_v4();
let pane = Uuid::new_v4();
let mut state = AttachViewState::new(bmux_client::AttachOpenInfo {
session_id: session,
context_id: None,
can_write: true,
});
let layout = retry_test_layout(&mut state, pane, session);
state.host_image_caps.kitty_graphics = true;
let render = |state: &mut AttachViewState| {
let mut output = Vec::new();
render_attach_frame_to_writer(
&mut output,
state,
&layout,
&RuntimeAppearance::default(),
&[],
0,
&bmux_config::DamageBehaviorConfig::default(),
u64::MAX,
&mut DisplayCaptureFanout::default(),
TerminalGeometry { cols: 80, rows: 24 },
None,
)
.unwrap();
output
};
render(&mut state);
let image = PaneImage {
id: 42,
protocol: ImageProtocol::KittyGraphics,
position: ImagePosition { row: 0, col: 0 },
cell_size: ImageCellSize { rows: 1, cols: 1 },
pixel_size: ImagePixelSize {
width: 1,
height: 1,
},
payload: ImagePayload {
raw: None,
pixels: Some(PixelBuffer {
width: 1,
height: 1,
format: PixelFormat::Rgba8,
data: vec![255, 0, 0, 255],
}),
},
};
let mut initial = Vec::new();
state.kitty_host_state.begin_frame();
render_pane_images(
&mut initial,
&[image],
PaneRect {
x: 0,
y: 0,
w: 80,
h: 24,
},
&state.host_image_caps,
bmux_image::config::ImageDecodeMode::Server,
&mut state.kitty_host_state,
)
.unwrap();
state.kitty_host_state.finish_frame(&mut initial).unwrap();
let committed = state.kitty_host_state.transmitted.clone();
for _ in 0..3 {
let output = render(&mut state);
assert!(
!String::from_utf8_lossy(&output).contains("Ga=d"),
"partial frame erased image"
);
assert_eq!(state.kitty_host_state.transmitted, committed);
}
state.host_image_caps.kitty_graphics = false;
let output = render(&mut state);
assert!(String::from_utf8_lossy(&output).contains("Ga=d,d=I"));
assert!(state.kitty_host_state.transmitted.is_empty());
}
fn seed_retry_graphics_cache(state: &mut AttachViewState, pane_id: Uuid) {
state.terminal_graphics_cache.insert(
1,
bmux_attach_pipeline::types::TerminalGraphicCacheEntry {
pane_id,
surface_id: pane_id,
source: bmux_attach_pipeline::types::TerminalGraphicSourceSignature {
pixel_width: 1,
pixel_height: 1,
color: bmux_plugin::TerminalRgba {
r: 1,
g: 2,
b: 3,
a: 255,
},
fill: bmux_plugin::TerminalGraphicFill::Full,
},
placement: None,
host_image_id: 1,
},
);
}
fn verify_frame_retry(state: &mut AttachViewState, layout: &AttachLayoutState) {
assert_eq!(
state.terminal_graphics_cache.len(),
1,
"failed output committed graphics cleanup"
);
let mut output = Vec::new();
render_attach_frame_to_writer(
&mut output,
state,
layout,
&RuntimeAppearance::default(),
&[],
0,
&bmux_config::DamageBehaviorConfig::default(),
u64::MAX,
&mut DisplayCaptureFanout::default(),
TerminalGeometry { cols: 80, rows: 24 },
None,
)
.unwrap();
assert!(String::from_utf8_lossy(&output).contains("retry-visible-content"));
assert!(
state.terminal_graphics_cache.is_empty(),
"successful retry did not commit graphics cleanup"
);
assert!(state.last_cursor_state.is_some());
}
#[test]
fn surface_action_failure_preserves_consumption_and_reports_status() {
let mut state = super::AttachViewState::new(bmux_client::AttachOpenInfo {
context_id: None,
session_id: uuid::Uuid::new_v4(),
can_write: true,
});
for consumed in [false, true] {
assert_eq!(
super::finish_surface_action(&mut state, consumed, Ok(())),
consumed
);
let failure = bmux_client::ClientError::ServerError {
code: bmux_ipc::ErrorCode::Internal,
message: "action rejected".to_owned(),
};
assert_eq!(
super::finish_surface_action::<()>(&mut state, consumed, Err(failure)),
consumed
);
assert!(
state
.transient_status
.as_deref()
.unwrap()
.contains("action rejected")
);
}
}
#[allow(clippy::wildcard_imports)]
use super::*;
use crate::input::InputProcessor;
use crate::runtime::attach::input::TerminalModifiers;
use crate::runtime::attach::render::append_pane_output;
use crate::runtime::attach::state::{
AttachEventAction, AttachMouseSelectionDrag, AttachScrollbackCursor,
AttachScrollbackPosition, AttachUiMode, AttachViewState, PaneRenderBuffer,
};
use bmux_plugin::RenderStyle;
use bmux_attach_layout_protocol::{
AttachFocusTarget, AttachInputModeState, AttachMouseProtocolState, AttachRect, AttachScene,
AttachSurface, AttachSurfaceKind, PaneLayoutNode, PaneState, PaneSummary,
};
use bmux_attach_view_protocol::AttachViewComponent;
use bmux_client::{AttachLayoutState, AttachOpenInfo};
use bmux_config::{
BmuxConfig, MouseClickPropagation, MouseSelectionReleaseBehavior, MouseWheelPropagation,
};
use bmux_plugin::layout::{
LayoutEdge, LayoutExtent, LayoutOperation, PluginLayoutId, PluginLayoutRequest,
};
use bmux_plugin::{
AttachInputEndpoint, AttachInputHook, AttachInputHookFilter, AttachRenderExtension,
};
use crossterm::event::{
Event as CrosstermEvent, KeyCode as CrosstermKeyCode, KeyEvent as CrosstermKeyEvent,
KeyEventKind as CrosstermKeyEventKind, KeyModifiers, MouseButton, MouseEvent,
MouseEventKind,
};
use serial_test::serial;
use std::collections::{BTreeMap, BTreeSet};
use uuid::Uuid;
#[test]
fn plugin_layout_reservations_produce_four_edge_viewport_insets() {
let requests = [
PluginLayoutRequest {
id: PluginLayoutId::new("example.sidebar", "main"),
order: 0,
operation: LayoutOperation::Split {
edge: LayoutEdge::Left,
extent: LayoutExtent::Cells(18),
},
},
PluginLayoutRequest {
id: PluginLayoutId::new("example.footer", "main"),
order: 1,
operation: LayoutOperation::Split {
edge: LayoutEdge::Bottom,
extent: LayoutExtent::Cells(2),
},
},
];
let insets = resolved_attach_viewport_insets_for_requests(
TerminalGeometry {
cols: 100,
rows: 30,
},
&requests,
);
assert_eq!(
insets,
AttachViewportInsets {
top: 0,
right: 0,
bottom: 2,
left: 18,
}
);
}
#[test]
fn plugin_layout_reservations_preserve_a_host_content_cell() {
let requests = [
PluginLayoutRequest {
id: PluginLayoutId::new("example.left", "main"),
order: 0,
operation: LayoutOperation::Split {
edge: LayoutEdge::Left,
extent: LayoutExtent::Cells(u16::MAX),
},
},
PluginLayoutRequest {
id: PluginLayoutId::new("example.top", "main"),
order: 1,
operation: LayoutOperation::Split {
edge: LayoutEdge::Top,
extent: LayoutExtent::Cells(u16::MAX),
},
},
];
let insets = resolved_attach_viewport_insets_for_requests(
TerminalGeometry { cols: 9, rows: 5 },
&requests,
);
assert_eq!(
insets,
AttachViewportInsets {
top: 4,
right: 0,
bottom: 0,
left: 8,
}
);
}
#[test]
fn invalid_plugin_layout_fails_closed_to_baseline_viewport() {
let duplicate_id = PluginLayoutId::new("example.invalid", "duplicate");
let requests = [
PluginLayoutRequest {
id: duplicate_id.clone(),
order: 0,
operation: LayoutOperation::Split {
edge: LayoutEdge::Left,
extent: LayoutExtent::Cells(20),
},
},
PluginLayoutRequest {
id: duplicate_id,
order: 1,
operation: LayoutOperation::Split {
edge: LayoutEdge::Right,
extent: LayoutExtent::Cells(20),
},
},
];
let insets = resolved_attach_viewport_insets_for_requests(
TerminalGeometry { cols: 80, rows: 24 },
&requests,
);
assert_eq!(
insets,
AttachViewportInsets {
top: 0,
right: 0,
bottom: 0,
left: 0,
}
);
}
#[test]
fn plugin_layout_overlays_do_not_reserve_viewport_space() {
let requests = [PluginLayoutRequest {
id: PluginLayoutId::new("example.overlay", "main"),
order: 0,
operation: LayoutOperation::Overlay,
}];
let insets = resolved_attach_viewport_insets_for_requests(
TerminalGeometry { cols: 80, rows: 24 },
&requests,
);
assert_eq!(insets, AttachViewportInsets::default());
}
fn normal_mode_input_processor() -> InputProcessor {
let mut config = BmuxConfig::default();
config.keybindings.initial_mode = "normal".to_owned();
InputProcessor::new(attach_keymap_from_config(&config), false)
}
struct RetainedOverlayExtension;
impl AttachRenderExtension for RetainedOverlayExtension {
fn name(&self) -> &'static str {
"test.retained-overlay"
}
fn render_surface(
&self,
_stdout: &mut dyn Write,
_surface_id: Uuid,
_surface_rect: &ExtensionRect,
_damage: &RenderDamage,
) -> io::Result<bool> {
Ok(false)
}
fn render_layer_ops_with_context(
&self,
_surface_id: Uuid,
surface_rect: &ExtensionRect,
_damage: &RenderDamage,
layer: RenderExtensionLayer,
_context: &RenderExtensionContext,
) -> Option<Vec<RenderOp>> {
(layer == RenderExtensionLayer::AfterPaneContent).then(|| {
vec![RenderOp::border(
*surface_rect,
bmux_plugin::BorderGlyphs::rounded(),
RenderStyle::new().bold(),
)]
})
}
}
#[test]
fn retained_plugin_surface_content_is_not_overpainted_by_pane_extensions() {
let surface = RetainedSurface::builder(Uuid::from_u128(99), DamageRect::new(0, 29, 80, 1))
.opaque()
.render_ops(vec![RenderOp::text_run(
0,
29,
"tab-1 NORMAL write",
RenderStyle::new(),
)])
.build();
let repaint = retained_full_surface_repaint(&surface);
let mut output = Vec::new();
queue_retained_render_ops(
&mut output,
&surface,
&repaint,
bmux_plugin::TerminalRenderCapabilities::default(),
)
.expect("plugin surface render");
assert!(String::from_utf8_lossy(&output).contains("tab-1 NORMAL write"));
}
#[test]
fn retained_non_pane_surface_runs_generic_render_extensions() {
let surface = RetainedSurface::builder(Uuid::from_u128(99), DamageRect::new(2, 3, 12, 5))
.opaque()
.render_ops(Vec::new())
.build();
let repaint = retained_full_surface_repaint(&surface);
let extensions: Vec<Arc<dyn AttachRenderExtension>> =
vec![Arc::new(RetainedOverlayExtension)];
let mut output = Vec::new();
let mut graphics_cache = TerminalGraphicsCache::new();
let wrote = queue_retained_extension_ops(
&mut output,
&surface,
&repaint,
&extensions,
bmux_plugin::TerminalRenderCapabilities::default(),
&mut graphics_cache,
)
.expect("extension render");
assert!(wrote);
assert!(!output.is_empty());
}
#[test]
fn native_provider_key_encoding_respects_focused_application_cursor_mode() {
let stroke = bmux_keyboard::crossterm::crossterm_key_event_to_stroke(&KeyEvent::new(
KeyCode::Up,
KeyModifiers::NONE,
))
.unwrap();
let bytes = bmux_keyboard::encode::encode_key_with_modes(
&stroke,
false,
bmux_keyboard::encode::KeyEncodingModes {
application_cursor: true,
application_keypad: false,
},
)
.unwrap();
assert_eq!(bytes, b"\x1bOA");
let payload = AttachInputPayload::Key {
stroke,
enhanced: false,
};
assert!(matches!(
payload,
AttachInputPayload::Key {
stroke: received,
enhanced: false
} if received == stroke
));
assert_eq!(
encode_bracketed_paste("input", true),
b"\x1b[200~input\x1b[201~"
);
}
#[test]
fn native_provider_actions_translate_window_keymap_commands() {
let focused = AttachStreamCursor {
stream_id: bmux_client::AttachStreamId::new("focused").unwrap(),
surface_id: Uuid::from_u128(9),
generation: 4,
offset: 0,
};
let focus = native_provider_plugin_action(
7,
Some(&focused),
"bmux.tabs",
"focus-pane-in-direction",
&["--direction".to_string(), "left".to_string()],
)
.unwrap();
assert_eq!(focus.action, "focus");
assert_eq!(focus.arguments, ["left"]);
let split = native_provider_plugin_action(
8,
Some(&focused),
"bmux.tabs",
"split-pane",
&["--direction".to_string(), "vertical".to_string()],
)
.unwrap();
assert_eq!(split.action, "split");
assert_eq!(split.arguments[0], focused.surface_id.to_string());
assert_eq!(split.arguments[1], "vertical");
assert!(
native_provider_plugin_action(9, Some(&focused), "bmux.sessions", "new-session", &[],)
.is_err()
);
}
#[test]
fn native_provider_mouse_translation_selects_local_pane_coordinates() {
let pane_id = Uuid::from_u128(5);
let scene = AttachScene {
session_id: Uuid::nil(),
focus: AttachFocusTarget::Pane { pane_id },
surfaces: vec![AttachSurface {
id: pane_id,
kind: AttachSurfaceKind::Pane,
layer: SurfaceLayer::Pane,
z: 0,
rect: AttachRect {
x: 10,
y: 4,
w: 8,
h: 6,
},
content_rect: AttachRect {
x: 11,
y: 5,
w: 6,
h: 4,
},
interactive_regions: Vec::new(),
opaque: true,
visible: true,
accepts_input: true,
cursor_owner: true,
pane_id: Some(pane_id),
}],
};
let event = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 13,
row: 7,
modifiers: KeyModifiers::CONTROL,
};
let shared = provider_mouse_to_shared(event);
let (_, rect) = bmux_attach_pipeline::mouse::pane_and_rect_at(&scene, 13, 7).unwrap();
let local = bmux_attach_pipeline::mouse::translate_event_to_pane_local(shared, rect)
.expect("point is inside content rect");
assert_eq!((local.column, local.row), (2, 2));
assert!(local.modifiers.control);
assert!(
bmux_attach_pipeline::mouse::translate_event_to_pane_local(
provider_mouse_to_shared(MouseEvent {
column: 10,
row: 4,
..event
}),
rect,
)
.is_none()
);
}
#[test]
#[allow(clippy::too_many_lines)]
fn native_provider_compositor_renders_and_repairs_independent_surfaces_atomically() {
let left = Uuid::from_u128(1);
let right = Uuid::from_u128(2);
let scene = AttachScene {
session_id: Uuid::nil(),
focus: AttachFocusTarget::Pane { pane_id: left },
surfaces: vec![
AttachSurface {
id: left,
kind: AttachSurfaceKind::Pane,
layer: SurfaceLayer::Pane,
z: 0,
rect: AttachRect {
x: 0,
y: 0,
w: 5,
h: 2,
},
content_rect: AttachRect {
x: 0,
y: 0,
w: 5,
h: 2,
},
interactive_regions: Vec::new(),
opaque: true,
visible: true,
accepts_input: true,
cursor_owner: true,
pane_id: Some(left),
},
AttachSurface {
id: right,
kind: AttachSurfaceKind::Pane,
layer: SurfaceLayer::Pane,
z: 1,
rect: AttachRect {
x: 5,
y: 0,
w: 5,
h: 2,
},
content_rect: AttachRect {
x: 5,
y: 0,
w: 5,
h: 2,
},
interactive_regions: Vec::new(),
opaque: true,
visible: true,
accepts_input: true,
cursor_owner: false,
pane_id: Some(right),
},
],
};
let stream = |name: &str, surface_id, bytes: &[u8]| bmux_client::AttachStreamSnapshot {
cursor: bmux_client::AttachStreamCursor {
stream_id: bmux_client::AttachStreamId::new(name).unwrap(),
surface_id,
generation: 1,
offset: u64::try_from(bytes.len()).unwrap(),
},
snapshot: bytes.to_vec(),
};
let left_stream = stream("left", left, b"left");
let right_stream = stream("right", right, b"right");
let snapshot = bmux_client::AttachProviderSnapshot {
view_revision: bmux_client::AttachViewRevision(1),
event_sequence: bmux_client::AttachDeltaSequence(0),
scene: scene.clone(),
streams: vec![left_stream.clone(), right_stream.clone()],
resume: bmux_client::AttachResumeState {
view_revision: bmux_client::AttachViewRevision(1),
event_sequence: bmux_client::AttachDeltaSequence(0),
streams: vec![left_stream.cursor.clone(), right_stream.cursor.clone()],
provider_token: Vec::new(),
},
};
let mut buffers = provider_pane_buffers_from_snapshot(&snapshot).unwrap();
let (mut terminal, handle) = HeadlessAttachTerminal::new(10, 2);
let mut graphics = TerminalGraphicsCache::new();
render_native_provider_scene(&mut terminal, &scene, &mut buffers, &mut graphics).unwrap();
let repaired_cursor = bmux_client::AttachStreamCursor {
offset: 8,
..left_stream.cursor
};
let delta = bmux_client::AttachProviderDelta {
sequence: bmux_client::AttachDeltaSequence(1),
base_view_revision: bmux_client::AttachViewRevision(1),
view_revision: bmux_client::AttachViewRevision(1),
changes: vec![AttachProviderChange::StreamRepair(
bmux_client::AttachStreamSnapshot {
cursor: repaired_cursor.clone(),
snapshot: b"fixed".to_vec(),
},
)],
resume: bmux_client::AttachResumeState {
view_revision: bmux_client::AttachViewRevision(1),
event_sequence: bmux_client::AttachDeltaSequence(1),
streams: vec![repaired_cursor, right_stream.cursor],
provider_token: Vec::new(),
},
};
apply_provider_delta(&scene, &mut buffers, &delta).unwrap();
render_native_provider_scene(&mut terminal, &scene, &mut buffers, &mut graphics).unwrap();
let mut rendered = bmux_terminal_grid::TerminalGridStream::new(
10,
2,
bmux_terminal_grid::GridLimits::default(),
)
.unwrap();
rendered.process(&handle.output_bytes());
let visible = bmux_terminal_grid::visible_text(rendered.grid(), 0, 2);
assert!(visible.starts_with("fixedright"), "{visible:?}");
}
#[test]
fn attach_input_mode_lifecycle_sequences_cover_enabled_and_disabled_modes() {
let mut enabled = Vec::new();
queue_attach_input_modes(&mut enabled, false, true).expect("enable paste mode");
assert_eq!(enabled, b"\x1b[?2004h");
let mut disabled = Vec::new();
queue_attach_input_modes(&mut disabled, false, false).expect("leave modes disabled");
assert!(disabled.is_empty());
let mut active_restored = Vec::new();
queue_active_attach_input_mode_restore(&mut active_restored, false, true)
.expect("restore active paste mode after detach or error");
assert_eq!(active_restored, b"\x1b[?2004l");
let mut inactive_restored = Vec::new();
queue_active_attach_input_mode_restore(&mut inactive_restored, false, false)
.expect("skip inactive mode restoration");
assert!(inactive_restored.is_empty());
let mut restored = Vec::new();
queue_attach_input_mode_restore(&mut restored).expect("restore input modes");
assert!(restored.ends_with(b"\x1b[?2004l"));
}
#[test]
fn bracketed_paste_capability_follows_build_feature_and_config() {
assert!(!bracketed_paste_enabled(false));
assert_eq!(
bracketed_paste_enabled(true),
cfg!(feature = "bracketed-paste")
);
}
#[test]
fn bracketed_paste_encoder_wraps_only_when_pane_requests_it() {
let text = "hello\n世界\x1b[200~";
assert_eq!(encode_bracketed_paste(text, false), text.as_bytes());
let mut expected = b"\x1b[200~".to_vec();
expected.extend_from_slice(text.as_bytes());
expected.extend_from_slice(b"\x1b[201~");
assert_eq!(encode_bracketed_paste(text, true), expected);
assert_eq!(encode_bracketed_paste("", true), b"\x1b[200~\x1b[201~");
}
fn test_attach_input_hook() -> AttachInputHook {
AttachInputHook {
id: "test-hook".to_string(),
owner_plugin_id: "test-plugin".to_string(),
priority: 0,
endpoint: AttachInputEndpoint {
capability: "test".to_string(),
interface_id: "test".to_string(),
operation: "test".to_string(),
},
filter: AttachInputHookFilter {
mouse_phases: vec!["drag".to_string()],
keys: Vec::new(),
scope: "global".to_string(),
min_interval_ms: 10,
},
}
}
#[test]
fn plugin_surface_focus_requires_consumed_focusable_pointer_down() {
let owner = "example.focus";
let surface = bmux_plugin::surface::PluginSurface {
id: bmux_plugin::surface::PluginSurfaceId::new(owner, "surface", Uuid::from_u128(50)),
revision: 1,
target: bmux_plugin::surface::PluginSurfaceTarget::Explicit(ExtensionRect::new(
2, 2, 8, 4,
)),
clip_rect: None,
metadata: None,
interactive_regions: Vec::new(),
accepts_input: false,
layer: 0,
z: 0,
opaque: false,
modal: false,
visible: true,
ops: Vec::new(),
}
.interactive_region(
bmux_plugin::surface::PluginSurfaceRegion::new("field", ExtensionRect::new(0, 0, 8, 4))
.focusable(bmux_plugin::surface::PluginSurfaceCursor::Text),
);
let lowered = retained_surfaces_from_plugin_surfaces(
&[surface],
&BTreeMap::new(),
DamageRect::new(0, 0, 80, 24),
);
let mut compositor = bmux_attach_pipeline::RetainedCompositor::new();
let _ = compositor.replace_surfaces(
lowered,
DamageRect::new(0, 0, 80, 24),
DamageCoalescingPolicy::default(),
);
let hit = compositor.hit_test(3, 3).expect("focusable region hit");
let down = bmux_attach_pipeline::RetainedPointerEvent {
phase: bmux_attach_pipeline::RetainedPointerPhase::Down,
hit: hit.clone(),
button: Some(bmux_attach_pipeline::RetainedPointerButton::Primary),
wheel_delta: 0,
};
let mut focus = bmux_attach_pipeline::RetainedFocusState::default();
update_plugin_surface_focus(&mut focus, &compositor, &down, false);
assert!(focus.focused().is_none());
update_plugin_surface_focus(&mut focus, &compositor, &down, true);
assert_eq!(focus.focused(), hit.region_id.as_ref());
let move_event = bmux_attach_pipeline::RetainedPointerEvent {
phase: bmux_attach_pipeline::RetainedPointerPhase::Move,
..down
};
let _ = focus.clear();
update_plugin_surface_focus(&mut focus, &compositor, &move_event, true);
assert!(focus.focused().is_none());
}
async fn disconnected_focus_test_client() -> StreamingBmuxClient {
let (client_stream, server_stream) = tokio::io::duplex(8192);
let server = tokio::spawn(async move {
use bmux_ipc::{Envelope, EnvelopeKind, Response, ResponsePayload};
let mut stream = bmux_ipc::transport::ErasedIpcStream::new(Box::new(server_stream));
let envelope = stream.recv_envelope().await.unwrap();
let bmux_ipc::Request::Hello { contract, .. } =
bmux_ipc::decode(&envelope.payload).unwrap()
else {
panic!("expected hello")
};
let response = Response::Ok(ResponsePayload::HelloNegotiated {
negotiated: bmux_ipc::NegotiatedProtocol {
wire_epoch: contract.wire_epoch,
revision: contract.revisions.max,
capabilities: Vec::new(),
},
});
stream
.send_envelope(&Envelope::new(
envelope.request_id,
EnvelopeKind::Response,
bmux_ipc::encode(&response).unwrap(),
))
.await
.unwrap();
});
let client = bmux_client::BmuxClient::connect_with_bridge_stream(
bmux_ipc::transport::ErasedIpcStream::new(Box::new(client_stream)),
std::time::Duration::from_secs(2),
"focus-test",
Uuid::new_v4(),
)
.await
.unwrap();
let client = StreamingBmuxClient::from_client(client).unwrap();
server.await.unwrap();
client
}
#[tokio::test]
async fn opted_in_control_is_dismissed_only_by_outside_pointer_down() {
let endpoint = bmux_plugin::AttachInputEndpoint {
capability: "example.blur".to_string(),
interface_id: "input".to_string(),
operation: "handle".to_string(),
};
let notified = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let counter = notified.clone();
let registry = std::sync::Arc::new(bmux_plugin::AttachPresentationInputRegistry::default());
registry.register_focus_lost(
endpoint.clone(),
std::sync::Arc::new(move |_| {
counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
true
}),
);
let mut surface = bmux_plugin::surface::PluginSurface::layout(
bmux_plugin::surface::PluginSurfaceId::new(
"example.blur",
"surface",
Uuid::from_u128(51),
),
1,
bmux_plugin::layout::PluginLayoutId::new("example.blur", "layout"),
Vec::new(),
);
surface.target =
bmux_plugin::surface::PluginSurfaceTarget::Explicit(ExtensionRect::new(2, 2, 8, 4));
let surface = surface.interactive_region(
bmux_plugin::surface::PluginSurfaceRegion::new("field", ExtensionRect::new(0, 0, 8, 4))
.endpoint(endpoint.clone())
.focusable(bmux_plugin::surface::PluginSurfaceCursor::Text),
);
let mut view = attach_view_state_with_scrollback_fixture();
view.mouse.config.enabled = true;
let pane_id = focused_attach_pane_id(&view).unwrap();
append_pane_output(
view.pane_buffers.get_mut(&pane_id).unwrap(),
b"\x1b[?1000h\x1b[?1006h",
);
view.presentation_input = registry.clone();
let _ = view.retained_compositor.replace_surfaces(
retained_surfaces_from_plugin_surfaces(
&[surface],
&BTreeMap::new(),
DamageRect::new(0, 0, 80, 24),
),
DamageRect::new(0, 0, 80, 24),
DamageCoalescingPolicy::default(),
);
let hit = view.retained_compositor.hit_test(3, 3).unwrap();
view.plugin_focus.focus_hit(&view.retained_compositor, &hit);
let mut event = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 3,
row: 3,
modifiers: KeyModifiers::empty(),
};
assert!(!dismiss_plugin_focus_on_pointer_down(&mut view, event));
assert!(view.plugin_focus.focused().is_some());
event.column = 40;
event.kind = MouseEventKind::Moved;
assert!(!dismiss_plugin_focus_on_pointer_down(&mut view, event));
assert!(view.plugin_focus.focused().is_some());
event.kind = MouseEventKind::Down(MouseButton::Left);
view.plugin_pointer_router.capture(hit);
assert!(dismiss_plugin_focus_on_pointer_down(&mut view, event));
assert!(view.plugin_pointer_router.release_capture().is_none());
assert!(view.plugin_focus.focused().is_none());
assert_eq!(notified.load(std::sync::atomic::Ordering::SeqCst), 1);
let mut client = disconnected_focus_test_client().await;
let hit = view.retained_compositor.hit_test(3, 3).unwrap();
view.plugin_focus.focus_hit(&view.retained_compositor, &hit);
event.column = 1;
let target = attach_mouse_target_context(&view, event.column, event.row);
assert!(pane_mouse_protocol_reports_event(
&view,
target.focus_target,
event.kind
));
handle_attach_mouse_event_at(
&mut client,
event,
&mut view,
None,
Instant::now(),
TerminalGeometry { cols: 80, rows: 24 },
)
.await
.unwrap();
assert!(view.plugin_focus.focused().is_none());
assert_eq!(notified.load(std::sync::atomic::Ordering::SeqCst), 2);
registry.remove(&endpoint);
}
#[test]
fn prompt_acquisition_cancels_pointer_owner() {
let mut view_state = AttachViewState::new(AttachOpenInfo {
context_id: None,
session_id: Uuid::from_u128(1),
can_write: true,
});
view_state.mouse.selection_drag = Some(AttachMouseSelectionDrag {
pane_id: Uuid::from_u128(2),
anchor: AttachScrollbackPosition { line: 0, col: 0 },
active: false,
});
handle_attach_ui_action_at(&RuntimeAction::Quit, &mut view_state, Instant::now());
assert!(view_state.prompt.is_active());
assert_eq!(view_state.mouse.pointer_owner(), None);
}
#[test]
fn input_result_updates_capture_without_dropping_existing_modes() {
let mut view_state = AttachViewState::new(AttachOpenInfo {
context_id: None,
session_id: Uuid::from_u128(1),
can_write: true,
});
let hook = test_attach_input_hook();
apply_attach_input_result(
&mut view_state,
hook.clone(),
&AttachInputResult {
capture_pointer: true,
capture_keyboard: vec!["esc".to_string()],
..AttachInputResult::default()
},
);
apply_attach_input_result(
&mut view_state,
hook,
&AttachInputResult {
capture_pointer: true,
..AttachInputResult::default()
},
);
let capture = view_state
.mouse
.input_capture
.as_ref()
.expect("capture should remain");
assert!(capture.pointer);
assert_eq!(capture.keyboard_keys, ["esc"]);
view_state.mouse.clear_plugin_pointer_capture();
let capture = view_state
.mouse
.input_capture
.as_ref()
.expect("keyboard capture should remain after pointer release");
assert!(!capture.pointer);
assert_eq!(capture.keyboard_keys, ["esc"]);
}
#[test]
fn builtin_owner_ignores_nonmatching_pointer_events() {
let now = Instant::now();
let mut view_state = AttachViewState::new(AttachOpenInfo {
context_id: None,
session_id: Uuid::from_u128(1),
can_write: true,
});
view_state.mouse.resize_drag = Some(AttachMouseResizeDrag {
horizontal: None,
vertical: None,
last_column: 1,
last_row: 1,
latest_column: 1,
latest_row: 1,
last_applied_at: now,
});
let right_up = TerminalMouseEvent {
phase: TerminalMousePhase::Up,
button: Some(TerminalMouseButton::Right),
col: 2,
row: 2,
modifiers: TerminalModifiers::default(),
};
assert!(matches!(
continue_attach_builtin_pointer_owner(
&mut view_state,
right_up,
now,
TerminalGeometry { cols: 80, rows: 24 }
),
AttachPointerContinuation::Owned(_)
));
assert_eq!(
view_state.mouse.pointer_owner(),
Some(AttachPointerOwner::Resize)
);
}
#[test]
fn hook_dispatch_eligibility_observes_interval_boundaries() {
let now = Instant::now();
let interval = Duration::from_millis(10);
assert!(hook_dispatch_allowed(None, interval, now));
assert!(hook_dispatch_allowed(Some(now), Duration::ZERO, now));
assert!(!hook_dispatch_allowed(
Some(now),
interval,
now + Duration::from_millis(9)
));
assert!(hook_dispatch_allowed(Some(now), interval, now + interval));
}
#[test]
fn hook_throttle_disposition_distinguishes_owner_policy() {
assert_eq!(
attach_hook_throttle_disposition(false, false),
AttachHookThrottleDisposition::Dispatch
);
assert_eq!(
attach_hook_throttle_disposition(true, true),
AttachHookThrottleDisposition::ConsumeOwned
);
assert_eq!(
attach_hook_throttle_disposition(true, false),
AttachHookThrottleDisposition::SkipUnowned
);
}
#[test]
fn throttled_hook_does_not_advance_dispatch_timestamp() {
let now = Instant::now();
let mut view_state = AttachViewState::new(AttachOpenInfo {
context_id: None,
session_id: Uuid::from_u128(1),
can_write: true,
});
let hook = test_attach_input_hook();
assert!(!hook_throttled(&mut view_state, &hook, now));
assert!(hook_throttled(
&mut view_state,
&hook,
now + Duration::from_millis(9)
));
assert_eq!(
view_state.mouse.input_hook_last_dispatched_at.get(&hook.id),
Some(&now)
);
assert!(!hook_throttled(
&mut view_state,
&hook,
now + Duration::from_millis(10)
));
}
#[cfg(any(
feature = "image-sixel",
feature = "image-kitty",
feature = "image-iterm2"
))]
#[test]
fn image_capability_detection_fills_missing_cell_pixels() {
let mut caps = bmux_image::host_caps::HostImageCapabilities {
kitty_graphics: true,
cell_pixel_width: 0,
cell_pixel_height: 0,
..bmux_image::host_caps::HostImageCapabilities::default()
};
fill_missing_image_cell_pixels(&mut caps);
assert_eq!(caps.cell_pixel_width, 8);
assert_eq!(caps.cell_pixel_height, 16);
}
#[test]
fn pointer_focus_immediate_focuses_target_on_first_move() {
let session_id = Uuid::new_v4();
let focused = Uuid::new_v4();
let focus_target = Uuid::new_v4();
let mut view_state = AttachViewState::new(AttachOpenInfo {
context_id: None,
session_id,
can_write: true,
});
view_state.mouse.config.focus_on_hover = true;
view_state.mouse.config.hover_delay_ms = 0;
let decision = reduce_attach_pointer_focus(
&mut view_state,
Some(focus_target),
Some(focused),
AttachPointerFocusEventKind::Move,
Instant::now(),
);
assert_eq!(decision, AttachPointerFocusDecision::Focus(focus_target));
assert_eq!(view_state.mouse.hovered_pane_id, Some(focus_target));
}
#[test]
fn pointer_focus_delayed_waits_for_move_dwell() {
let session_id = Uuid::new_v4();
let focused = Uuid::new_v4();
let focus_target = Uuid::new_v4();
let mut view_state = AttachViewState::new(AttachOpenInfo {
context_id: None,
session_id,
can_write: true,
});
view_state.mouse.config.focus_on_hover = true;
view_state.mouse.config.hover_delay_ms = 175;
let now = Instant::now();
let first_decision = reduce_attach_pointer_focus(
&mut view_state,
Some(focus_target),
Some(focused),
AttachPointerFocusEventKind::Move,
now,
);
let dwell_decision = reduce_attach_pointer_focus(
&mut view_state,
Some(focus_target),
Some(focused),
AttachPointerFocusEventKind::Move,
now + Duration::from_millis(175),
);
assert_eq!(first_decision, AttachPointerFocusDecision::None);
assert_eq!(
dwell_decision,
AttachPointerFocusDecision::Focus(focus_target)
);
}
#[test]
fn pointer_focus_wheel_only_focuses_when_immediate() {
let session_id = Uuid::new_v4();
let focused = Uuid::new_v4();
let focus_target = Uuid::new_v4();
let mut view_state = AttachViewState::new(AttachOpenInfo {
context_id: None,
session_id,
can_write: true,
});
view_state.mouse.config.focus_on_hover = true;
view_state.mouse.config.hover_delay_ms = 175;
let now = Instant::now();
let delayed_decision = reduce_attach_pointer_focus(
&mut view_state,
Some(focus_target),
Some(focused),
AttachPointerFocusEventKind::Wheel,
now + Duration::from_millis(175),
);
view_state.mouse.config.hover_delay_ms = 0;
let immediate_decision = reduce_attach_pointer_focus(
&mut view_state,
Some(focus_target),
Some(focused),
AttachPointerFocusEventKind::Wheel,
now,
);
assert_eq!(delayed_decision, AttachPointerFocusDecision::None);
assert_eq!(
immediate_decision,
AttachPointerFocusDecision::Focus(focus_target)
);
}
#[test]
fn pane_runtime_status_four_is_treated_as_closed_attach_stream() {
let error = ClientError::ServerError {
code: bmux_ipc::ErrorCode::Internal,
message:
"service invocation failed: plugin 'bmux.pane_runtime' service invocation failed with status 4"
.to_string(),
};
assert!(is_attach_stream_closed_error(&error));
}
#[test]
fn typed_service_codec_round_trips_attach_input_result() {
let result = AttachInputResult {
consumed: true,
dirty: true,
capture_pointer: true,
..AttachInputResult::default()
};
let bytes = bmux_plugin_sdk::encode_service_message(&result).unwrap();
let decoded = bmux_plugin_sdk::decode_service_message::<AttachInputResult>(&bytes).unwrap();
assert_eq!(decoded, result);
}
#[test]
fn close_fallback_rejects_window_without_live_catalog_binding() {
let current_context = Uuid::from_u128(10);
let stale_context = Uuid::from_u128(11);
let current_session = Uuid::from_u128(20);
let mut view_state = AttachViewState::new(AttachOpenInfo {
context_id: Some(current_context),
session_id: current_session,
can_write: true,
});
view_state.cached_tab_list =
Some(Arc::new(bmux_tabs_plugin_api::tabs_list::TabListSnapshot {
tabs: vec![
bmux_tabs_plugin_api::tabs_list::TabListEntry {
id: current_context,
name: "current".to_string(),
active: true,
workspace: "default".to_string(),
workspace_id: Uuid::nil(),
},
bmux_tabs_plugin_api::tabs_list::TabListEntry {
id: stale_context,
name: "stale".to_string(),
active: false,
workspace: "default".to_string(),
workspace_id: Uuid::nil(),
},
],
revision: 1,
}));
view_state.cached_contexts = vec![ContextRow {
id: current_context,
name: Some("current".to_string()),
}];
view_state.cached_context_session_bindings = vec![ContextSessionBinding {
context_id: current_context,
session_id: current_session,
}];
assert!(next_context_fallback(&view_state).is_none());
}
#[test]
fn close_fallback_accepts_live_context_in_another_session() {
let current_context = Uuid::from_u128(10);
let fallback_context = Uuid::from_u128(11);
let current_session = Uuid::from_u128(20);
let fallback_session = Uuid::from_u128(21);
let mut view_state = AttachViewState::new(AttachOpenInfo {
context_id: Some(current_context),
session_id: current_session,
can_write: true,
});
view_state.cached_tab_list =
Some(Arc::new(bmux_tabs_plugin_api::tabs_list::TabListSnapshot {
tabs: vec![bmux_tabs_plugin_api::tabs_list::TabListEntry {
id: fallback_context,
name: "fallback".to_string(),
active: false,
workspace: "default".to_string(),
workspace_id: Uuid::nil(),
}],
revision: 1,
}));
view_state.cached_contexts = vec![ContextRow {
id: fallback_context,
name: Some("fallback".to_string()),
}];
view_state.cached_context_session_bindings = vec![ContextSessionBinding {
context_id: fallback_context,
session_id: fallback_session,
}];
assert!(matches!(
next_context_fallback(&view_state).map(|fallback| fallback.target),
Some(AttachCloseFallbackTarget::Context { context_id }) if context_id == fallback_context
));
}
#[test]
fn initial_authoritative_windows_survive_empty_forwarded_seed() {
let current = authoritative_window_list_snapshot(
vec![tabs_state::TabEntry {
id: Uuid::from_u128(11).to_string(),
name: "main".to_string(),
active: true,
workspace: "default".to_string(),
workspace_id: Uuid::nil(),
}],
0,
);
let preferred = prefer_nonempty_window_list_snapshot(
¤t,
bmux_tabs_plugin_api::tabs_list::TabListSnapshot {
tabs: Vec::new(),
revision: 7,
},
);
assert_eq!(preferred.revision, 7);
assert_eq!(preferred.tabs.len(), 1);
assert_eq!(preferred.tabs[0].name, "main");
}
#[test]
fn later_empty_windows_snapshot_is_authoritative() {
let current = bmux_tabs_plugin_api::tabs_list::TabListSnapshot {
tabs: vec![bmux_tabs_plugin_api::tabs_list::TabListEntry {
id: Uuid::from_u128(11),
name: "main".to_string(),
active: true,
workspace: "default".to_string(),
workspace_id: Uuid::nil(),
}],
revision: 6,
};
let preferred = prefer_nonempty_window_list_snapshot(
¤t,
bmux_tabs_plugin_api::tabs_list::TabListSnapshot {
tabs: Vec::new(),
revision: 7,
},
);
assert!(preferred.tabs.is_empty());
}
#[test]
fn local_presentation_snapshot_ignores_revision_for_semantic_equality() {
let mut view_state = AttachViewState::new(AttachOpenInfo {
session_id: Uuid::from_u128(1),
context_id: Some(Uuid::from_u128(2)),
can_write: true,
});
let keymap = attach_keymap_from_config(&BmuxConfig::default());
let appearance = RuntimeAppearance::default();
publish_attach_local_presentation(
&mut view_state,
&keymap,
None,
false,
TerminalGeometry { cols: 80, rows: 24 },
&appearance,
);
let first = view_state.local_presentation.clone().unwrap();
publish_attach_local_presentation(
&mut view_state,
&keymap,
None,
false,
TerminalGeometry { cols: 80, rows: 24 },
&appearance,
);
assert_eq!(view_state.local_presentation.as_ref(), Some(&first));
view_state.active_mode_id = "insert".to_string();
publish_attach_local_presentation(
&mut view_state,
&keymap,
None,
false,
TerminalGeometry { cols: 80, rows: 24 },
&appearance,
);
let changed = view_state.local_presentation.as_ref().unwrap();
assert_eq!(changed.revision, first.revision + 1);
assert_eq!(changed.mode_label, "INSERT");
publish_attach_local_presentation(
&mut view_state,
&keymap,
None,
false,
TerminalGeometry { cols: 80, rows: 12 },
&appearance,
);
let resized = view_state.local_presentation.as_ref().unwrap();
assert_eq!(resized.viewport_rows, 12);
assert_eq!(resized.revision, first.revision + 2);
}
#[test]
fn headless_terminal_replays_resize_boundaries() {
let (mut terminal, handle) = HeadlessAttachTerminal::new(4, 3);
terminal.write_all(b"\x1b[1;8HX\r\nefgh").unwrap();
handle.resize(8, 3).unwrap();
terminal.write_all(b"\x1b[3;1Htail").unwrap();
let grid = handle.output_grid().unwrap();
let text = bmux_terminal_grid::visible_text(grid.grid(), 0, 3);
assert!(text.lines().next().unwrap().starts_with(" X"), "{text:?}");
assert!(text.lines().nth(1).unwrap().starts_with("efgh"), "{text:?}");
assert!(text.lines().nth(2).unwrap().starts_with("tail"), "{text:?}");
handle.resize(6, 3).unwrap();
handle.resize(8, 3).unwrap();
assert_eq!(
bmux_terminal_grid::visible_text(handle.output_grid().unwrap().grid(), 0, 3),
text
);
}
#[test]
fn headless_terminal_replays_host_composited_row() {
let (mut terminal, handle) = HeadlessAttachTerminal::new(40, 3);
terminal
.write_all(b"\x1b[3;1H tab-1 NORMAL write")
.unwrap();
let screen = handle.output_screen_text(40, 3);
let bottom = screen.lines().nth(2).unwrap_or_default();
assert!(bottom.contains("tab-1"), "{screen:?}");
assert!(bottom.contains("NORMAL"), "{screen:?}");
assert!(bottom.contains("write"), "{screen:?}");
}
fn focus_action(direction: &str) -> RuntimeAction {
RuntimeAction::PluginCommand {
plugin_id: "bmux.tabs".to_string(),
command_name: "focus-pane-in-direction".to_string(),
args: vec!["--direction".to_string(), direction.to_string()],
}
}
fn frame_stats_for_classifier(scene_render: AttachSceneRenderStats) -> AttachFrameRenderStats {
AttachFrameRenderStats {
frame_bytes: 0,
terminal_write_ms: 0,
damage_rects: 0,
damage_area_cells: 0,
full_surface_fallbacks: 0,
full_frame_fallback: false,
scene_render,
status_rendered: false,
overlay_rendered: false,
synchronized_update: false,
dirty_event_count: 1,
}
}
#[test]
fn render_inefficiency_classifier_flags_no_visible_row_change() {
let flags = classify_attach_render_inefficiency(&frame_stats_for_classifier(
AttachSceneRenderStats {
pane_rows_examined: 3,
pane_rows_cached_skipped: 3,
..AttachSceneRenderStats::default()
},
));
assert!(flags.contains(AttachRenderInefficiencyFlags::DIRTY_NO_VISIBLE_ROW_CHANGE));
}
#[test]
fn render_inefficiency_classifier_flags_large_partial_frame() {
let flags = classify_attach_render_inefficiency(&frame_stats_for_classifier(
AttachSceneRenderStats {
viewport_cells: 100,
pane_cells_emitted: 60,
pane_rows_emitted: 3,
..AttachSceneRenderStats::default()
},
));
assert!(flags.contains(AttachRenderInefficiencyFlags::LARGE_PARTIAL_FRAME));
}
#[test]
fn render_inefficiency_classifier_flags_full_frame_and_slow_write() {
let mut stats = frame_stats_for_classifier(AttachSceneRenderStats::default());
stats.full_frame_fallback = true;
stats.frame_bytes = 1024;
stats.terminal_write_ms = ATTACH_OVERRENDER_SLOW_TERMINAL_WRITE_MS_PER_KIB;
let flags = classify_attach_render_inefficiency(&stats);
assert!(flags.contains(AttachRenderInefficiencyFlags::FULL_FRAME_FALLBACK));
assert!(flags.contains(AttachRenderInefficiencyFlags::SLOW_TERMINAL_WRITE_PER_KIB));
}
#[test]
fn render_inefficiency_classifier_flags_extension_cache_miss_pressure() {
let flags = classify_attach_render_inefficiency(&frame_stats_for_classifier(
AttachSceneRenderStats {
extension_render_calls: 10,
extension_cache_hits: 1,
extension_imperative_calls: 1,
..AttachSceneRenderStats::default()
},
));
assert!(flags.contains(AttachRenderInefficiencyFlags::EXTENSION_IMPERATIVE_OR_CACHE_MISS));
}
#[test]
fn caller_process_command_execution_routes_to_caller_process() {
assert_eq!(
attach_plugin_command_execution_route(
&bmux_plugin_sdk::CommandExecutionKind::CallerProcess
),
AttachPluginCommandExecutionRoute::CallerProcess
);
}
#[test]
fn provider_command_executions_route_to_provider_pipeline() {
for execution in [
bmux_plugin_sdk::CommandExecutionKind::ProviderExec,
bmux_plugin_sdk::CommandExecutionKind::BackgroundTask,
bmux_plugin_sdk::CommandExecutionKind::RuntimeHook,
] {
assert_eq!(
attach_plugin_command_execution_route(&execution),
AttachPluginCommandExecutionRoute::ProviderPipeline
);
}
}
fn test_pane_surface(pane_id: Uuid, rect: AttachRect) -> AttachSurface {
AttachSurface {
id: Uuid::new_v4(),
kind: AttachSurfaceKind::Pane,
layer: SurfaceLayer::Pane,
z: 0,
rect,
content_rect: rect,
interactive_regions: Vec::new(),
opaque: true,
visible: true,
accepts_input: true,
cursor_owner: false,
pane_id: Some(pane_id),
}
}
fn test_prompt_overlay_render(rect: AttachRect, text: &str) -> AttachPromptOverlayRender {
AttachPromptOverlayRender {
surface: AttachSurface {
id: Uuid::from_u128(2),
kind: AttachSurfaceKind::Modal,
layer: SurfaceLayer::Overlay,
z: i32::MAX,
rect,
content_rect: rect,
interactive_regions: Vec::new(),
opaque: true,
visible: true,
accepts_input: true,
cursor_owner: false,
pane_id: None,
},
ops: vec![RenderOp::text_run(
rect.x,
rect.y,
text,
RenderStyle::default(),
)],
cursor_state: None,
}
}
#[test]
fn private_presentation_appearance_updates_and_teardown_are_isolated() {
let create = || {
AttachViewState::with_private_presentation(AttachOpenInfo {
context_id: None,
session_id: Uuid::new_v4(),
can_write: true,
})
};
let first = create();
let second = create();
for state in [&first, &second] {
state.presentation_events.register_state_channel(
RUNTIME_APPEARANCE_STATE_KIND,
RuntimeAppearance::default(),
);
}
let (_, first_rx) = first
.presentation_events
.subscribe_state::<RuntimeAppearance>(&RUNTIME_APPEARANCE_STATE_KIND)
.unwrap();
let (_, second_rx) = second
.presentation_events
.subscribe_state::<RuntimeAppearance>(&RUNTIME_APPEARANCE_STATE_KIND)
.unwrap();
first
.presentation_events
.publish_state(&RUNTIME_APPEARANCE_STATE_KIND, RuntimeAppearance::default())
.unwrap();
assert!(first_rx.has_changed().unwrap());
assert!(!second_rx.has_changed().unwrap());
let released = std::sync::Arc::downgrade(&first.presentation_events);
drop(first);
assert!(released.upgrade().is_none());
second
.presentation_events
.publish_state(&RUNTIME_APPEARANCE_STATE_KIND, RuntimeAppearance::default())
.unwrap();
assert!(second_rx.has_changed().unwrap());
}
#[test]
fn bundled_render_extensions_are_owned_by_each_attach() {
let create = || {
let mut state = AttachViewState::with_private_presentation(AttachOpenInfo {
context_id: None,
session_id: Uuid::new_v4(),
can_write: true,
});
state.presentation_events = std::sync::Arc::new(bmux_plugin::EventBus::new());
install_bundled_render_extensions(&mut state);
state
};
let first = create();
let second = create();
assert!(!std::sync::Arc::ptr_eq(
&first.presentation_extensions,
&second.presentation_extensions
));
let first_extensions = first.presentation_extensions.snapshot();
let second_extensions = second.presentation_extensions.snapshot();
assert_eq!(first_extensions.len(), second_extensions.len());
for (first, second) in first_extensions.iter().zip(&second_extensions) {
assert!(!std::sync::Arc::ptr_eq(first, second));
}
let released = std::sync::Arc::downgrade(&first.presentation_extensions);
drop(first);
assert!(released.upgrade().is_none());
assert_eq!(
second.presentation_extensions.len(),
second_extensions.len()
);
}
#[test]
fn private_presentation_notifications_hydrate_and_teardown_independently() {
let create = || {
AttachViewState::with_private_presentation(AttachOpenInfo {
context_id: None,
session_id: Uuid::new_v4(),
can_write: true,
})
};
let first = create();
let second = create();
let viewport = DamageRect::new(0, 0, 80, 24);
super::super::local_presentation::publish_notification(
&first.presentation_layouts,
&first.presentation_surfaces,
Some("first"),
80,
24,
);
assert_eq!(retained_plugin_surfaces(&first, viewport).len(), 1);
assert!(retained_plugin_surfaces(&second, viewport).is_empty());
super::super::local_presentation::publish_notification(
&second.presentation_layouts,
&second.presentation_surfaces,
Some("second"),
80,
24,
);
first.presentation_surfaces.clear();
first.presentation_layouts.clear();
assert!(retained_plugin_surfaces(&first, viewport).is_empty());
assert_eq!(retained_plugin_surfaces(&second, viewport).len(), 1);
assert!(!std::sync::Arc::ptr_eq(
&first.presentation_input,
&second.presentation_input
));
assert!(!std::sync::Arc::ptr_eq(
&first.presentation_allocations,
&second.presentation_allocations
));
}
#[test]
fn view_change_components_request_reconcile_without_full_frame_damage() {
let session_id = Uuid::new_v4();
let mut view_state = AttachViewState::new(AttachOpenInfo {
context_id: None,
session_id,
can_write: true,
});
view_state.dirty.clear_frame_damage();
apply_attach_view_change_components(&[AttachViewComponent::Layout], &mut view_state);
let damage = view_state.dirty.frame_damage(&AttachScene {
session_id,
focus: AttachFocusTarget::None,
surfaces: Vec::new(),
});
assert!(view_state.dirty.layout_needs_refresh);
assert!(!damage.is_full_frame());
}
#[test]
#[serial]
fn opaque_prompt_content_edit_prunes_pane_repaint_without_extension_query() {
let session_id = Uuid::new_v4();
let pane_id = Uuid::new_v4();
let surface = test_pane_surface(
pane_id,
AttachRect {
x: 0,
y: 0,
w: 80,
h: 24,
},
);
let layout_state = AttachLayoutState {
context_id: None,
session_id,
focused_pane_id: pane_id,
panes: Vec::new(),
layout_root: PaneLayoutNode::Leaf { pane_id },
scene: AttachScene {
session_id,
focus: AttachFocusTarget::Pane { pane_id },
surfaces: vec![surface],
},
zoomed: false,
};
let prompt_render = test_prompt_overlay_render(
AttachRect {
x: 20,
y: 8,
w: 40,
h: 8,
},
"opaque prompt",
);
let prompt_surface = retained_prompt_overlay_surface(&prompt_render);
let prompt_rect = ExtensionRect::new(
prompt_surface.rect.x,
prompt_surface.rect.y,
prompt_surface.rect.w,
prompt_surface.rect.h,
);
let mut view_state = AttachViewState::new(AttachOpenInfo {
context_id: None,
session_id,
can_write: true,
});
let _ = view_state.retained_compositor.replace_surfaces(
retained_surfaces_from_attach_scene(&layout_state.scene)
.into_iter()
.chain(std::iter::once(prompt_surface)),
DamageRect::new(0, 0, 80, 24),
DamageCoalescingPolicy::default(),
);
view_state.opaque_overlay_rects = vec![prompt_rect];
let mut frame_damage = bmux_attach_pipeline::FrameDamage::default();
frame_damage.mark_content_surface_rect(
pane_id,
DamageRect::new(20, 8, 40, 8),
(80, 24),
DamageCoalescingPolicy::default(),
);
let plan = build_retained_frame_plan(
&mut view_state,
&layout_state,
&mut frame_damage,
None,
Some(prompt_render),
DamageRect::new(0, 0, 80, 24),
DamageCoalescingPolicy::default(),
);
assert!(!frame_damage.extension_query_requested());
assert!(!plan.scene_repaint);
assert!(
plan.repaint_plan
.iter()
.all(|repaint| repaint.surface_id != layout_state.scene.surfaces[0].id)
);
let frame_plan = RenderFramePlan::from_damage(plan, &frame_damage, false);
assert!(frame_plan.use_synchronized_update);
assert!(!frame_plan.render_scene);
}
#[test]
#[serial]
fn retained_frame_plan_composes_published_plugin_surface_through_attach_path() {
use bmux_plugin::layout::{LayoutEdge, LayoutExtent, PluginLayoutId, PluginLayoutSnapshot};
use bmux_plugin::surface::{
PluginSurface, PluginSurfaceId, PluginSurfaceSnapshot, global_plugin_surface_registry,
};
let owner = "example.attach-surface";
global_plugin_layout_registry().remove_owner(owner);
global_plugin_surface_registry().remove_owner(owner);
let layout_id = PluginLayoutId::new(owner, "leading");
assert_eq!(
global_plugin_layout_registry().publish(
owner,
PluginLayoutSnapshot {
revision: 1,
requests: vec![PluginLayoutRequest::split(
layout_id.clone(),
0,
LayoutEdge::Left,
LayoutExtent::Cells(12),
)],
},
),
Ok(bmux_plugin::layout::PluginLayoutPublishOutcome::Applied)
);
let retained_id = Uuid::from_u128(900);
assert_eq!(
global_plugin_surface_registry().publish(
owner,
PluginSurfaceSnapshot {
revision: 2,
surfaces: vec![PluginSurface::layout(
PluginSurfaceId::new(owner, "main", retained_id),
2,
layout_id,
vec![bmux_plugin::RenderOp::text_run(
0,
0,
"plugin",
bmux_plugin::RenderStyle::new(),
)],
)],
},
),
Ok(bmux_plugin::surface::PluginSurfacePublishOutcome::Applied)
);
let session_id = Uuid::new_v4();
let pane_id = Uuid::new_v4();
let layout_state = AttachLayoutState {
context_id: None,
session_id,
focused_pane_id: pane_id,
panes: Vec::new(),
layout_root: PaneLayoutNode::Leaf { pane_id },
scene: AttachScene {
session_id,
focus: AttachFocusTarget::Pane { pane_id },
surfaces: vec![test_pane_surface(
pane_id,
AttachRect {
x: 12,
y: 0,
w: 68,
h: 24,
},
)],
},
zoomed: false,
};
let mut view_state = AttachViewState::new(AttachOpenInfo {
context_id: None,
session_id,
can_write: true,
});
let mut frame_damage = bmux_attach_pipeline::FrameDamage::default();
let plan = build_retained_frame_plan(
&mut view_state,
&layout_state,
&mut frame_damage,
None,
None,
DamageRect::new(0, 0, 80, 24),
DamageCoalescingPolicy::default(),
);
let plugin_surface = view_state
.retained_compositor
.surfaces()
.get(&retained_id)
.expect("published plugin surface should reach attach compositor");
assert_eq!(plugin_surface.rect, DamageRect::new(0, 0, 12, 24));
assert_eq!(plugin_surface.revision, Some(2));
assert!(
plan.repaint_plan
.iter()
.any(|entry| entry.surface_id == retained_id)
);
global_plugin_surface_registry().remove_owner(owner);
global_plugin_layout_registry().remove_owner(owner);
}
#[test]
#[serial]
fn retained_plugin_surface_lifecycle_cleans_and_rehydrates_without_stale_state() {
use bmux_plugin::layout::{LayoutEdge, LayoutExtent, PluginLayoutId, PluginLayoutSnapshot};
use bmux_plugin::surface::{
PluginSurface, PluginSurfaceId, PluginSurfaceSnapshot, global_plugin_surface_registry,
};
let owner = "example.attach-lifecycle";
global_plugin_layout_registry().remove_owner(owner);
global_plugin_surface_registry().remove_owner(owner);
let viewport = DamageRect::new(0, 0, 80, 24);
let retained_id = Uuid::from_u128(901);
let layout_id = PluginLayoutId::new(owner, "leading");
let publish = |revision, width| {
assert_eq!(
global_plugin_layout_registry().publish(
owner,
PluginLayoutSnapshot {
revision,
requests: vec![PluginLayoutRequest::split(
layout_id.clone(),
0,
LayoutEdge::Left,
LayoutExtent::Cells(width),
)],
},
),
Ok(bmux_plugin::layout::PluginLayoutPublishOutcome::Applied)
);
assert_eq!(
global_plugin_surface_registry().publish(
owner,
PluginSurfaceSnapshot {
revision,
surfaces: vec![PluginSurface::layout(
PluginSurfaceId::new(owner, "main", retained_id),
revision,
layout_id.clone(),
vec![bmux_plugin::RenderOp::text_run(
0,
0,
format!("revision-{revision}"),
bmux_plugin::RenderStyle::new(),
)],
)],
},
),
Ok(bmux_plugin::surface::PluginSurfacePublishOutcome::Applied)
);
};
publish(1, 12);
let view_state = AttachViewState::new(AttachOpenInfo {
context_id: None,
session_id: Uuid::from_u128(1),
can_write: true,
});
let mut compositor = bmux_attach_pipeline::RetainedCompositor::new();
let initial = compositor.replace_surfaces(
retained_plugin_surfaces(&view_state, viewport),
viewport,
DamageCoalescingPolicy::default(),
);
assert_eq!(initial.rects(), &[DamageRect::new(0, 0, 12, 24)]);
let mut reconnected_compositor = bmux_attach_pipeline::RetainedCompositor::new();
let reconnect_hydration = reconnected_compositor.replace_surfaces(
retained_plugin_surfaces(&view_state, viewport),
viewport,
DamageCoalescingPolicy::default(),
);
assert_eq!(
reconnect_hydration.rects(),
&[DamageRect::new(0, 0, 12, 24)]
);
let reconnected_surface = reconnected_compositor
.surfaces()
.get(&retained_id)
.expect("new attach compositor should hydrate retained owner state");
assert_eq!(reconnected_surface.revision, Some(1));
assert!(global_plugin_surface_registry().remove_owner(owner));
assert!(global_plugin_layout_registry().remove_owner(owner));
let removed = compositor.replace_surfaces(
retained_plugin_surfaces(&view_state, viewport),
viewport,
DamageCoalescingPolicy::default(),
);
assert_eq!(removed.rects(), &[DamageRect::new(0, 0, 12, 24)]);
assert!(!compositor.surfaces().contains_key(&retained_id));
publish(2, 16);
let rehydrated = compositor.replace_surfaces(
retained_plugin_surfaces(&view_state, viewport),
viewport,
DamageCoalescingPolicy::default(),
);
assert_eq!(rehydrated.rects(), &[DamageRect::new(0, 0, 16, 24)]);
let surface = compositor
.surfaces()
.get(&retained_id)
.expect("rehydrated retained surface");
assert_eq!(surface.revision, Some(2));
assert_eq!(surface.rect, DamageRect::new(0, 0, 16, 24));
global_plugin_surface_registry().remove_owner(owner);
global_plugin_layout_registry().remove_owner(owner);
}
#[test]
#[serial]
fn incremental_plugin_surface_output_stays_within_legacy_byte_budget() {
struct FlushFailure;
impl Write for FlushFailure {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
Ok(bytes.len())
}
fn flush(&mut self) -> io::Result<()> {
Err(io::Error::other("injected flush failure"))
}
}
use bmux_plugin::surface::{
PluginSurface, PluginSurfaceId, PluginSurfaceSnapshot, PluginSurfaceTarget,
global_plugin_surface_registry,
};
const OWNER: &str = "test.incremental-presentation-output";
let session_id = Uuid::new_v4();
let pane_id = Uuid::new_v4();
let geometry = TerminalGeometry {
cols: 100,
rows: 24,
};
let pane_rect = AttachRect {
x: 0,
y: 1,
w: 100,
h: 23,
};
let layout_state = AttachLayoutState {
context_id: None,
session_id,
focused_pane_id: pane_id,
panes: Vec::new(),
layout_root: PaneLayoutNode::Leaf { pane_id },
scene: AttachScene {
session_id,
focus: AttachFocusTarget::Pane { pane_id },
surfaces: vec![test_pane_surface(pane_id, pane_rect)],
},
zoomed: false,
};
let mut view_state = AttachViewState::new(AttachOpenInfo {
context_id: None,
session_id,
can_write: true,
});
view_state
.pane_buffers
.insert(pane_id, PaneRenderBuffer::default());
let publish = |revision, label: &str| {
global_plugin_surface_registry()
.publish(
OWNER,
PluginSurfaceSnapshot {
revision,
surfaces: vec![PluginSurface {
id: PluginSurfaceId::new(OWNER, "strip", Uuid::nil()),
revision,
target: PluginSurfaceTarget::Explicit(ExtensionRect::new(0, 0, 100, 1)),
clip_rect: Some(ExtensionRect::new(0, 0, 100, 1)),
metadata: None,
interactive_regions: Vec::new(),
accepts_input: false,
layer: 0,
z: 0,
opaque: true,
modal: false,
visible: true,
ops: vec![RenderOp::text_run(0, 0, label, RenderStyle::new())],
}],
},
)
.expect("publish test presentation");
};
global_plugin_surface_registry().clear();
global_plugin_layout_registry().clear();
publish(1, "1:one 2:two 3:three");
let mut output = Vec::new();
render_attach_frame_to_writer(
&mut output,
&mut view_state,
&layout_state,
&RuntimeAppearance::default(),
&[],
0,
&bmux_config::DamageBehaviorConfig::default(),
u64::MAX,
&mut DisplayCaptureFanout::default(),
geometry,
None,
)
.expect("initial frame");
publish(2, "1:one 2:TWO 3:three");
view_state
.dirty
.mark_retained_surfaces_dirty(AttachDirtySource::SceneChanged);
output.clear();
let stats = render_attach_frame_to_writer(
&mut output,
&mut view_state,
&layout_state,
&RuntimeAppearance::default(),
&[],
0,
&bmux_config::DamageBehaviorConfig::default(),
u64::MAX,
&mut DisplayCaptureFanout::default(),
geometry,
None,
)
.expect("incremental frame");
assert!(
String::from_utf8_lossy(&output).contains("TWO"),
"incremental presentation output omitted changed plugin paint operations"
);
assert!(
stats.frame_bytes <= 330,
"incremental presentation emitted {} bytes",
stats.frame_bytes
);
assert!(!stats.full_frame_fallback);
assert_eq!(stats.full_surface_fallbacks, 0);
let previous = view_state.retained_compositor.clone();
publish(3, "not displayed");
view_state
.dirty
.mark_retained_surfaces_dirty(AttachDirtySource::SceneChanged);
assert!(
render_attach_frame_to_writer(
&mut FlushFailure,
&mut view_state,
&layout_state,
&RuntimeAppearance::default(),
&[],
0,
&bmux_config::DamageBehaviorConfig::default(),
u64::MAX,
&mut DisplayCaptureFanout::default(),
geometry,
None
)
.is_err()
);
assert_eq!(view_state.retained_compositor, previous);
global_plugin_surface_registry().remove_owner(OWNER);
global_plugin_layout_registry().remove_owner(OWNER);
}
#[test]
fn opaque_prompt_coverage_change_queries_extensions_once() {
let session_id = Uuid::new_v4();
let pane_id = Uuid::new_v4();
let surface = test_pane_surface(
pane_id,
AttachRect {
x: 0,
y: 0,
w: 80,
h: 24,
},
);
let layout_state = AttachLayoutState {
context_id: None,
session_id,
focused_pane_id: pane_id,
panes: Vec::new(),
layout_root: PaneLayoutNode::Leaf { pane_id },
scene: AttachScene {
session_id,
focus: AttachFocusTarget::Pane { pane_id },
surfaces: vec![surface],
},
zoomed: false,
};
let prompt_render = test_prompt_overlay_render(
AttachRect {
x: 20,
y: 8,
w: 40,
h: 8,
},
"opaque prompt",
);
let mut view_state = AttachViewState::new(AttachOpenInfo {
context_id: None,
session_id,
can_write: true,
});
view_state.dirty.clear_frame_damage();
let mut frame_damage = bmux_attach_pipeline::FrameDamage::default();
let _ = build_retained_frame_plan(
&mut view_state,
&layout_state,
&mut frame_damage,
None,
Some(prompt_render.clone()),
DamageRect::new(0, 0, 80, 24),
DamageCoalescingPolicy::default(),
);
assert!(frame_damage.extension_query_requested());
assert_eq!(
view_state.opaque_overlay_rects,
vec![ExtensionRect::new(20, 8, 40, 8)]
);
let mut unchanged_damage = bmux_attach_pipeline::FrameDamage::default();
unchanged_damage.mark_overlay();
let _ = build_retained_frame_plan(
&mut view_state,
&layout_state,
&mut unchanged_damage,
None,
Some(prompt_render),
DamageRect::new(0, 0, 80, 24),
DamageCoalescingPolicy::default(),
);
assert!(!unchanged_damage.extension_query_requested());
}
#[test]
fn pane_surface_dirty_marks_extension_damage_without_content_damage() {
let session_id = Uuid::new_v4();
let pane_id = Uuid::new_v4();
let surface = test_pane_surface(
pane_id,
AttachRect {
x: 0,
y: 0,
w: 20,
h: 10,
},
);
let surface_id = surface.id;
let layout_state = AttachLayoutState {
context_id: None,
session_id,
focused_pane_id: pane_id,
panes: Vec::new(),
layout_root: PaneLayoutNode::Leaf { pane_id },
scene: AttachScene {
session_id,
focus: AttachFocusTarget::Pane { pane_id },
surfaces: vec![surface],
},
zoomed: false,
};
let mut dirty = AttachDirtyFlags::default();
dirty.clear_frame_damage();
assert!(mark_pane_surface_dirty(
&mut dirty,
&layout_state,
pane_id,
AttachDirtySource::FocusChanged,
));
let damage = dirty.frame_damage(&layout_state.scene);
assert!(!damage.is_full_frame());
assert!(!damage.content_surface_damaged(pane_id));
assert!(damage.extension_surface_damaged(surface_id, pane_id));
}
fn test_scrollback_offset(view_state: &AttachViewState) -> usize {
view_state
.focused_scrollback()
.map_or(0, |view| view.offset)
}
fn test_scrollback_cursor(view_state: &AttachViewState) -> Option<AttachScrollbackCursor> {
view_state.focused_scrollback().map(|view| view.cursor)
}
fn test_selection_anchor(view_state: &AttachViewState) -> Option<AttachScrollbackPosition> {
view_state
.focused_scrollback()
.and_then(|view| view.selection_anchor)
}
fn test_set_scrollback_offset(view_state: &mut AttachViewState, offset: usize) {
assert!(
enter_attach_scrollback(view_state),
"fixture must support scrollback"
);
if let Some(view) = view_state.focused_scrollback_mut() {
view.offset = offset;
}
}
fn attach_view_state_with_scrollback_fixture() -> AttachViewState {
let session_id = Uuid::new_v4();
let pane_id = Uuid::new_v4();
let mut view_state = AttachViewState::new(AttachOpenInfo {
context_id: None,
session_id,
can_write: true,
});
view_state.cached_layout_state = Some(AttachLayoutState {
context_id: None,
session_id,
focused_pane_id: pane_id,
panes: vec![PaneSummary {
id: pane_id,
index: 1,
name: None,
focused: true,
state: PaneState::Running,
state_reason: None,
}],
layout_root: PaneLayoutNode::Leaf { pane_id },
scene: AttachScene {
session_id,
focus: AttachFocusTarget::Pane { pane_id },
surfaces: vec![AttachSurface {
id: Uuid::new_v4(),
kind: AttachSurfaceKind::Pane,
layer: SurfaceLayer::Pane,
z: 0,
pane_id: Some(pane_id),
rect: AttachRect {
x: 0,
y: 0,
w: 9,
h: 6,
},
content_rect: AttachRect {
x: 1,
y: 1,
w: 7,
h: 4,
},
interactive_regions: Vec::new(),
opaque: true,
visible: true,
accepts_input: true,
cursor_owner: true,
}],
},
zoomed: false,
});
let buffer = view_state
.pane_buffers
.entry(pane_id)
.or_insert_with(|| PaneRenderBuffer {
terminal_grid: bmux_terminal_grid::TerminalGridStream::new(
20,
4,
bmux_terminal_grid::GridLimits::default(),
)
.expect("test grid dimensions are valid"),
..PaneRenderBuffer::default()
});
append_pane_output(buffer, b"one\r\n four\r\n five\r\n six\r\n\x1b[4;3H");
view_state
}
fn attach_view_state_with_two_panes_fixture() -> (AttachViewState, Uuid, Uuid) {
let session_id = Uuid::new_v4();
let left_pane = Uuid::from_u128(0x1eaf);
let right_pane = Uuid::from_u128(0x21c8);
let mut view_state = AttachViewState::new(AttachOpenInfo {
context_id: None,
session_id,
can_write: true,
});
let pane_surface = |pane_id: Uuid, x: u16, cursor_owner: bool| AttachSurface {
id: Uuid::new_v4(),
kind: AttachSurfaceKind::Pane,
layer: SurfaceLayer::Pane,
z: 0,
pane_id: Some(pane_id),
rect: AttachRect {
x,
y: 0,
w: 9,
h: 6,
},
content_rect: AttachRect {
x: x + 1,
y: 1,
w: 7,
h: 4,
},
interactive_regions: Vec::new(),
opaque: true,
visible: true,
accepts_input: true,
cursor_owner,
};
view_state.cached_layout_state = Some(AttachLayoutState {
context_id: None,
session_id,
focused_pane_id: left_pane,
panes: vec![
PaneSummary {
id: left_pane,
index: 1,
name: None,
focused: true,
state: PaneState::Running,
state_reason: None,
},
PaneSummary {
id: right_pane,
index: 2,
name: None,
focused: false,
state: PaneState::Running,
state_reason: None,
},
],
layout_root: PaneLayoutNode::Leaf { pane_id: left_pane },
scene: AttachScene {
session_id,
focus: AttachFocusTarget::Pane { pane_id: left_pane },
surfaces: vec![
pane_surface(left_pane, 0, true),
pane_surface(right_pane, 10, false),
],
},
zoomed: false,
});
for pane_id in [left_pane, right_pane] {
let buffer =
view_state
.pane_buffers
.entry(pane_id)
.or_insert_with(|| PaneRenderBuffer {
terminal_grid: bmux_terminal_grid::TerminalGridStream::new(
20,
4,
bmux_terminal_grid::GridLimits::default(),
)
.expect("test grid dimensions are valid"),
..PaneRenderBuffer::default()
});
append_pane_output(buffer, b"one\r\n four\r\n five\r\n six\r\n\x1b[4;3H");
}
(view_state, left_pane, right_pane)
}
fn test_focus_pane(view_state: &mut AttachViewState, pane_id: Uuid) {
let layout_state = view_state
.cached_layout_state
.as_mut()
.expect("layout state");
layout_state.focused_pane_id = pane_id;
layout_state.scene.focus = AttachFocusTarget::Pane { pane_id };
for pane in &mut layout_state.panes {
pane.focused = pane.id == pane_id;
}
}
#[test]
fn scrollback_state_is_isolated_per_pane_across_focus_changes() {
let (mut view_state, left_pane, right_pane) = attach_view_state_with_two_panes_fixture();
assert!(enter_attach_scrollback_for(&mut view_state, left_pane));
step_attach_scrollback_for(&mut view_state, left_pane, -2);
let left_offset = view_state
.requested_scroll_offset(left_pane)
.expect("left pane in scrollback");
assert!(left_offset > 0, "left pane should be scrolled back");
test_focus_pane(&mut view_state, right_pane);
assert!(
!view_state.scrollback_active_for(right_pane),
"focusing an unrelated pane must not put it in scrollback"
);
assert!(
!view_state.scrollback_active(),
"derived scroll mode must be false for a pane that is not scrolled back"
);
assert!(
view_state.scrollback_active_for(left_pane),
"left pane must stay in scrollback while unfocused"
);
assert!(enter_attach_scrollback_for(&mut view_state, right_pane));
step_attach_scrollback_for(&mut view_state, right_pane, -1);
assert_eq!(
view_state
.requested_scroll_offset(left_pane)
.expect("left pane view"),
left_offset,
"scrolling one pane must not change another pane's offset"
);
test_focus_pane(&mut view_state, left_pane);
assert_eq!(
view_state.requested_scroll_offset(left_pane),
Some(left_offset)
);
assert_eq!(test_scrollback_offset(&view_state), 0);
assert!(view_state.scrollback_active());
}
#[test]
fn exit_scrollback_only_affects_focused_pane() {
let (mut view_state, left_pane, right_pane) = attach_view_state_with_two_panes_fixture();
assert!(enter_attach_scrollback_for(&mut view_state, left_pane));
assert!(enter_attach_scrollback_for(&mut view_state, right_pane));
test_focus_pane(&mut view_state, right_pane);
handle_attach_ui_action_at(
&RuntimeAction::ExitScrollMode,
&mut view_state,
Instant::now(),
);
assert!(!view_state.scrollback_active_for(right_pane));
assert!(
view_state.scrollback_active_for(left_pane),
"exiting scrollback must not exit it for other panes"
);
}
#[test]
fn selection_state_is_isolated_per_pane() {
let (mut view_state, left_pane, right_pane) = attach_view_state_with_two_panes_fixture();
assert!(enter_attach_scrollback_for(&mut view_state, left_pane));
assert!(begin_attach_selection_for(&mut view_state, left_pane));
assert!(view_state.selection_active_for(left_pane));
assert!(!view_state.selection_active_for(right_pane));
test_focus_pane(&mut view_state, right_pane);
assert!(
!view_state.selection_active(),
"focused pane without a selection must not report one"
);
}
#[test]
fn mouse_wheel_scrolls_pane_under_pointer_not_focused_pane() {
let (mut view_state, left_pane, right_pane) = attach_view_state_with_two_panes_fixture();
view_state.mouse.config.scroll_lines_per_tick = 1;
view_state.mouse.config.scroll_scrollback = true;
assert!(handle_attach_mouse_scrollback_for(
&mut view_state,
right_pane,
MouseEventKind::ScrollUp,
));
assert!(view_state.scrollback_active_for(right_pane));
assert!(
!view_state.scrollback_active_for(left_pane),
"wheel over one pane must not scroll the focused pane"
);
}
#[test]
fn scrollback_views_are_pruned_for_removed_panes() {
let (mut view_state, left_pane, right_pane) = attach_view_state_with_two_panes_fixture();
assert!(enter_attach_scrollback_for(&mut view_state, left_pane));
assert!(enter_attach_scrollback_for(&mut view_state, right_pane));
let remaining = std::iter::once(left_pane).collect::<BTreeSet<_>>();
view_state.retain_scrollback_panes(&remaining);
assert!(view_state.scrollback_active_for(left_pane));
assert!(!view_state.scrollback_active_for(right_pane));
}
#[test]
fn command_outcome_selected_context_metadata_parses_uuid() {
let context_id = Uuid::from_u128(42);
let mut outcome = PluginCommandOutcome::default();
outcome.metadata.insert(
COMMAND_OUTCOME_SELECTED_CONTEXT_ID_KEY.to_string(),
serde_json::json!(context_id.to_string()),
);
assert_eq!(
selected_context_id_from_command_outcome(&outcome),
Some(context_id)
);
}
#[test]
fn command_outcome_selected_context_metadata_ignores_invalid_value() {
let mut outcome = PluginCommandOutcome::default();
outcome.metadata.insert(
COMMAND_OUTCOME_SELECTED_CONTEXT_ID_KEY.to_string(),
serde_json::json!("not-a-uuid"),
);
assert_eq!(selected_context_id_from_command_outcome(&outcome), None);
}
#[test]
fn command_outcome_status_message_metadata_is_truncated() {
let mut outcome = PluginCommandOutcome::default();
let long_message = "x".repeat(ATTACH_PLUGIN_STATUS_MESSAGE_MAX_CHARS + 1);
outcome.metadata.insert(
COMMAND_OUTCOME_STATUS_MESSAGE_KEY.to_string(),
serde_json::json!(long_message),
);
let message = status_message_from_command_outcome(&outcome)
.expect("status message should be present");
assert!(message.ends_with('…'));
assert_eq!(
message.chars().count(),
ATTACH_PLUGIN_STATUS_MESSAGE_MAX_CHARS + 1
);
}
#[test]
fn attach_plugin_command_pipeline_is_generic_command_step() {
let pipeline = build_attach_plugin_command_pipeline(
"example.plugin",
"do-thing",
&["arg".to_string()],
)
.expect("pipeline should build");
assert!(pipeline.inputs.is_empty());
assert_eq!(pipeline.steps.len(), 1);
let step = &pipeline.steps[0];
assert_eq!(
step.capability,
bmux_plugin_sdk::CORE_CLI_COMMAND_CAPABILITY
);
assert_eq!(step.kind, InvokeServiceKind::Command);
assert_eq!(
step.interface_id,
bmux_plugin_sdk::CORE_CLI_COMMAND_INTERFACE_V1
);
assert_eq!(
step.operation,
bmux_plugin_sdk::CORE_CLI_COMMAND_RUN_PLUGIN_OPERATION_V1
);
}
#[test]
fn attach_plugin_command_pipeline_metadata_is_optional() {
let response = bmux_plugin_sdk::PluginCliCommandResponse::new(0);
let payload =
bmux_plugin_sdk::encode_service_message(&response).expect("response should encode");
let execution = decode_attach_plugin_command_pipeline_results(
"example.plugin",
"do-thing",
&[bmux_ipc::ServicePipelineStepResult {
payload,
metadata: BTreeMap::new(),
}],
)
.expect("pipeline result should decode");
assert_eq!(execution.status, 0);
assert!(execution.outcome.metadata.is_empty());
assert_eq!(
selected_context_id_from_command_outcome(&execution.outcome),
None
);
}
#[test]
fn protocol_only_output_marks_full_redraw_on_alt_screen_toggle() {
let mut view_state = attach_view_state_with_scrollback_fixture();
view_state.dirty.full_pane_redraw = false;
view_state.force_cursor_move_next_frame = false;
let pane_id = view_state
.cached_layout_state
.as_ref()
.map(|layout| layout.focused_pane_id)
.expect("focused pane id");
let mut payload = Vec::new();
payload.extend_from_slice(b"\x1b[?1049h");
payload.extend(std::iter::repeat_n(
b'x',
super::ATTACH_OUTPUT_BATCH_MAX_BYTES * super::ATTACH_OUTPUT_DRAIN_MAX_ROUNDS + 64,
));
payload.extend_from_slice(b"\x1b[?1049l\r\n$ ");
let outcome = super::apply_attach_output_chunk_protocol_only(
&mut view_state,
pane_id,
&payload,
super::AttachOutputChunkMeta {
stream_start: 0,
stream_end: u64::try_from(payload.len()).expect("payload length fits u64"),
stream_gap: false,
sync_update_active: false,
},
);
assert!(matches!(
outcome,
super::AttachChunkApplyOutcome::Applied { had_data: true }
));
assert!(!view_state.dirty.pane_dirty_ids.contains(&pane_id));
assert!(view_state.dirty.full_pane_redraw);
assert!(view_state.force_cursor_move_next_frame);
let buffer = view_state
.pane_buffers
.get(&pane_id)
.expect("pane render buffer");
assert!(!buffer.protocol_tracker.alternate_screen());
}
#[test]
fn apply_attach_output_chunk_updates_continuity_state() {
let mut view_state = attach_view_state_with_scrollback_fixture();
let pane_id = view_state
.cached_layout_state
.as_ref()
.map(|layout| layout.focused_pane_id)
.expect("focused pane id");
let mut frame_needs_render = false;
let outcome = super::apply_attach_output_chunk(
&mut view_state,
pane_id,
b"abc",
super::AttachOutputChunkMeta {
stream_start: 100,
stream_end: 103,
stream_gap: false,
sync_update_active: true,
},
&mut frame_needs_render,
);
assert!(matches!(
outcome,
super::AttachChunkApplyOutcome::Applied { had_data: true }
));
assert!(frame_needs_render);
let buffer = view_state
.pane_buffers
.get(&pane_id)
.expect("pane render buffer");
assert_eq!(buffer.expected_stream_start, Some(103));
assert!(buffer.sync_update_in_progress);
}
#[test]
fn structured_incremental_path_does_not_feed_raw_bytes_to_grid() {
let mut view_state = attach_view_state_with_scrollback_fixture();
let pane_id = view_state
.cached_layout_state
.as_ref()
.map(|layout| layout.focused_pane_id)
.expect("focused pane id");
let before_revision = view_state
.pane_buffers
.get(&pane_id)
.expect("pane render buffer")
.terminal_grid
.grid()
.revision();
let outcome = super::apply_attach_output_chunk_protocol_only(
&mut view_state,
pane_id,
b"abc",
super::AttachOutputChunkMeta {
stream_start: 0,
stream_end: 3,
stream_gap: false,
sync_update_active: false,
},
);
assert!(matches!(
outcome,
super::AttachChunkApplyOutcome::Applied { had_data: true }
));
let protocol_only_buffer = view_state
.pane_buffers
.get(&pane_id)
.expect("pane render buffer");
assert_eq!(protocol_only_buffer.expected_stream_start, Some(3));
assert_eq!(
protocol_only_buffer.terminal_grid.grid().revision(),
before_revision
);
assert!(!view_state.dirty.pane_dirty_ids.contains(&pane_id));
}
#[test]
fn protocol_only_output_preserves_snapshot_mouse_hint_until_explicit_disable() {
let mut view_state = attach_view_state_with_scrollback_fixture();
let pane_id = view_state
.cached_layout_state
.as_ref()
.map(|layout| layout.focused_pane_id)
.expect("focused pane id");
view_state.pane_mouse_protocol_hints.insert(
pane_id,
AttachMouseProtocolState {
mode: AttachMouseProtocolMode::PressRelease,
encoding: AttachMouseProtocolEncoding::Sgr,
},
);
let outcome = super::apply_attach_output_chunk_protocol_only(
&mut view_state,
pane_id,
b"\x1b[2Jplain",
super::AttachOutputChunkMeta {
stream_start: 0,
stream_end: 9,
stream_gap: false,
sync_update_active: false,
},
);
assert!(matches!(
outcome,
super::AttachChunkApplyOutcome::Applied { had_data: true }
));
assert_eq!(
view_state
.pane_mouse_protocol_hints
.get(&pane_id)
.map(|hint| hint.mode),
Some(AttachMouseProtocolMode::PressRelease)
);
let outcome = super::apply_attach_output_chunk_protocol_only(
&mut view_state,
pane_id,
b"\x1b[?1000l",
super::AttachOutputChunkMeta {
stream_start: 9,
stream_end: 17,
stream_gap: false,
sync_update_active: false,
},
);
assert!(matches!(
outcome,
super::AttachChunkApplyOutcome::Applied { had_data: true }
));
assert_eq!(
view_state
.pane_mouse_protocol_hints
.get(&pane_id)
.map(|hint| hint.mode),
Some(AttachMouseProtocolMode::None)
);
}
#[test]
fn apply_attach_output_chunk_marks_gap_as_desync() {
let mut view_state = attach_view_state_with_scrollback_fixture();
let pane_id = view_state
.cached_layout_state
.as_ref()
.map(|layout| layout.focused_pane_id)
.expect("focused pane id");
let mut frame_needs_render = false;
let outcome = super::apply_attach_output_chunk(
&mut view_state,
pane_id,
b"",
super::AttachOutputChunkMeta {
stream_start: 50,
stream_end: 50,
stream_gap: true,
sync_update_active: false,
},
&mut frame_needs_render,
);
assert_eq!(outcome, super::AttachChunkApplyOutcome::Desync);
assert!(!frame_needs_render);
}
#[test]
fn apply_attach_output_chunk_ignores_stale_chunks() {
let mut view_state = attach_view_state_with_scrollback_fixture();
let pane_id = view_state
.cached_layout_state
.as_ref()
.map(|layout| layout.focused_pane_id)
.expect("focused pane id");
view_state
.pane_buffers
.get_mut(&pane_id)
.expect("pane render buffer")
.expected_stream_start = Some(80);
let mut frame_needs_render = false;
let outcome = super::apply_attach_output_chunk(
&mut view_state,
pane_id,
b"late",
super::AttachOutputChunkMeta {
stream_start: 70,
stream_end: 80,
stream_gap: false,
sync_update_active: false,
},
&mut frame_needs_render,
);
assert_eq!(outcome, super::AttachChunkApplyOutcome::Stale);
assert!(!frame_needs_render);
let buffer = view_state
.pane_buffers
.get(&pane_id)
.expect("pane render buffer");
assert_eq!(buffer.expected_stream_start, Some(80));
}
#[test]
fn apply_attach_output_chunk_detects_offset_mismatch() {
let mut view_state = attach_view_state_with_scrollback_fixture();
let pane_id = view_state
.cached_layout_state
.as_ref()
.map(|layout| layout.focused_pane_id)
.expect("focused pane id");
view_state
.pane_buffers
.get_mut(&pane_id)
.expect("pane render buffer")
.expected_stream_start = Some(80);
let mut frame_needs_render = false;
let outcome = super::apply_attach_output_chunk(
&mut view_state,
pane_id,
b"future",
super::AttachOutputChunkMeta {
stream_start: 81,
stream_end: 87,
stream_gap: false,
sync_update_active: false,
},
&mut frame_needs_render,
);
assert_eq!(outcome, super::AttachChunkApplyOutcome::Desync);
assert!(!frame_needs_render);
}
#[test]
fn attach_view_change_components_mark_expected_dirty_flags() {
let mut view_state = AttachViewState::new(AttachOpenInfo {
context_id: None,
session_id: uuid::Uuid::new_v4(),
can_write: true,
});
view_state.dirty.layout_needs_refresh = false;
view_state.dirty.full_pane_redraw = false;
apply_attach_view_change_components(&[AttachViewComponent::Layout], &mut view_state);
assert!(view_state.dirty.layout_needs_refresh);
assert!(!view_state.dirty.full_pane_redraw);
view_state.dirty.layout_needs_refresh = false;
apply_attach_view_change_components(
&[AttachViewComponent::Scene, AttachViewComponent::Layout],
&mut view_state,
);
assert!(view_state.dirty.layout_needs_refresh);
assert!(!view_state.dirty.full_pane_redraw);
}
#[test]
fn attach_key_event_action_detaches_on_prefix_d() {
let mut processor = normal_mode_input_processor();
let _ = attach_key_event_actions(
&CrosstermKeyEvent::new_with_kind(
CrosstermKeyCode::Char('a'),
KeyModifiers::CONTROL,
CrosstermKeyEventKind::Press,
),
&mut processor,
AttachUiMode::Normal,
)
.expect("attach key action should parse");
let actions = attach_key_event_actions(
&CrosstermKeyEvent::new_with_kind(
CrosstermKeyCode::Char('d'),
KeyModifiers::NONE,
CrosstermKeyEventKind::Press,
),
&mut processor,
AttachUiMode::Normal,
)
.expect("attach key action should parse");
assert_eq!(actions.len(), 1);
assert!(matches!(actions[0], AttachEventAction::Detach));
}
#[test]
fn attach_key_event_action_ctrl_d_forwards_to_pane() {
let mut processor = normal_mode_input_processor();
let actions = attach_key_event_actions(
&CrosstermKeyEvent::new_with_kind(
CrosstermKeyCode::Char('d'),
KeyModifiers::CONTROL,
CrosstermKeyEventKind::Press,
),
&mut processor,
AttachUiMode::Normal,
)
.expect("attach key action should parse");
assert!(actions.is_empty());
}
#[test]
fn action_supports_repeat_allows_navigation() {
assert!(super::action_supports_repeat(&RuntimeAction::ScrollUpLine));
assert!(super::action_supports_repeat(
&RuntimeAction::ForwardToPane(b"x".to_vec())
));
}
#[test]
fn action_supports_repeat_denies_mutating_actions() {
assert!(!super::action_supports_repeat(
&windows_close_active_pane_action()
));
assert!(!super::action_supports_repeat(&RuntimeAction::Quit));
assert!(!super::action_supports_repeat(&RuntimeAction::Detach));
}
#[test]
fn action_supports_repeat_denies_plugin_commands() {
let action = RuntimeAction::PluginCommand {
plugin_id: "bmux.tabs".to_string(),
command_name: "new-tab".to_string(),
args: vec![],
};
assert!(!super::action_supports_repeat(&action));
}
#[test]
fn repeat_event_drops_plugin_command_action() {
let mut processor = normal_mode_input_processor();
let press = attach_key_event_actions(
&CrosstermKeyEvent::new_with_kind(
CrosstermKeyCode::Char('c'),
KeyModifiers::NONE,
CrosstermKeyEventKind::Press,
),
&mut processor,
AttachUiMode::Normal,
)
.expect("press should parse");
let press_has_plugin = press
.iter()
.any(|a| matches!(a, AttachEventAction::PluginCommand { .. }));
assert!(press_has_plugin, "press should emit a PluginCommand");
let repeat = attach_key_event_actions(
&CrosstermKeyEvent::new_with_kind(
CrosstermKeyCode::Char('c'),
KeyModifiers::NONE,
CrosstermKeyEventKind::Repeat,
),
&mut processor,
AttachUiMode::Normal,
)
.expect("repeat should parse");
let repeat_has_plugin = repeat
.iter()
.any(|a| matches!(a, AttachEventAction::PluginCommand { .. }));
assert!(!repeat_has_plugin, "repeat must not emit a PluginCommand");
}
#[test]
fn repeat_event_preserves_navigation_action() {
let mut processor =
InputProcessor::new(attach_keymap_from_config(&BmuxConfig::default()), false);
let repeat = attach_key_event_actions(
&CrosstermKeyEvent::new_with_kind(
CrosstermKeyCode::Left,
KeyModifiers::NONE,
CrosstermKeyEventKind::Repeat,
),
&mut processor,
AttachUiMode::Normal,
)
.expect("repeat should parse");
assert!(repeat.iter().all(|action| match action {
AttachEventAction::PluginCommand {
plugin_id,
command_name,
..
} => command_accepts_repeat(plugin_id, command_name),
AttachEventAction::Ui(action) => super::action_supports_repeat(action),
AttachEventAction::Send(_) | AttachEventAction::Paste(_) => true,
AttachEventAction::Detach
| AttachEventAction::Redraw
| AttachEventAction::Ignore
| AttachEventAction::Mouse(_) => false,
}));
}
#[test]
fn attach_key_event_action_encodes_char_input() {
let mut processor =
InputProcessor::new(attach_keymap_from_config(&BmuxConfig::default()), false);
let _ = attach_key_event_actions(
&CrosstermKeyEvent::new_with_kind(
CrosstermKeyCode::Char('i'),
KeyModifiers::NONE,
CrosstermKeyEventKind::Press,
),
&mut processor,
AttachUiMode::Normal,
)
.expect("attach key action should parse");
let actions = attach_key_event_actions(
&CrosstermKeyEvent::new_with_kind(
CrosstermKeyCode::Char('x'),
KeyModifiers::NONE,
CrosstermKeyEventKind::Press,
),
&mut processor,
AttachUiMode::Normal,
)
.expect("attach key action should parse");
assert_eq!(actions.len(), 1);
assert!(matches!(actions[0], AttachEventAction::Send(ref bytes) if bytes == b"x"));
}
#[test]
fn attach_event_actions_maps_paste_without_keymap_processing() {
let mut processor =
InputProcessor::new(attach_keymap_from_config(&BmuxConfig::default()), false);
let event = CrosstermEvent::Paste("ctrl+b detach".to_string());
let actions = attach_event_actions(&event, &mut processor, AttachUiMode::Normal)
.expect("paste event should map");
assert!(matches!(
actions.as_slice(),
[AttachEventAction::Paste(text)] if text == "ctrl+b detach"
));
}
#[test]
fn attach_event_actions_maps_mouse_events() {
let mut processor =
InputProcessor::new(attach_keymap_from_config(&BmuxConfig::default()), false);
let event = CrosstermEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 12,
row: 8,
modifiers: KeyModifiers::NONE,
});
let actions = attach_event_actions(&event, &mut processor, AttachUiMode::Normal)
.expect("mouse event should map");
assert!(matches!(
actions.first(),
Some(AttachEventAction::Mouse(mouse)) if mouse.column == 12 && mouse.row == 8
));
}
#[test]
fn record_attach_mouse_event_tracks_position_and_timestamp() {
let mut view_state = AttachViewState::new(AttachOpenInfo {
context_id: None,
session_id: Uuid::new_v4(),
can_write: true,
});
let event = MouseEvent {
kind: MouseEventKind::Moved,
column: 3,
row: 4,
modifiers: KeyModifiers::NONE,
};
let now = Instant::now();
record_attach_mouse_event(event, &mut view_state, now);
assert_eq!(view_state.mouse.last_position, Some((3, 4)));
assert_eq!(view_state.mouse.last_event_at, Some(now));
}
#[test]
fn encode_attach_mouse_sgr_encodes_button_down() {
let encoded = encode_attach_mouse_sgr(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 2,
row: 4,
modifiers: KeyModifiers::NONE,
})
.expect("mouse down should encode");
assert_eq!(encoded, b"\x1b[<0;3;5M".to_vec());
}
#[test]
fn encode_attach_mouse_sgr_encodes_release_with_modifier_bits() {
let encoded = encode_attach_mouse_sgr(MouseEvent {
kind: MouseEventKind::Up(MouseButton::Right),
column: 0,
row: 0,
modifiers: KeyModifiers::CONTROL | KeyModifiers::ALT,
})
.expect("mouse up should encode");
assert_eq!(encoded, b"\x1b[<26;1;1m".to_vec());
}
#[test]
fn encode_attach_mouse_sgr_encodes_scroll_and_move_events() {
let scroll = encode_attach_mouse_sgr(MouseEvent {
kind: MouseEventKind::ScrollDown,
column: 10,
row: 1,
modifiers: KeyModifiers::SHIFT,
})
.expect("scroll should encode");
let moved = encode_attach_mouse_sgr(MouseEvent {
kind: MouseEventKind::Moved,
column: 10,
row: 1,
modifiers: KeyModifiers::SHIFT,
})
.expect("moved should encode");
assert_eq!(scroll, b"\x1b[<69;11;2M".to_vec());
assert_eq!(moved, b"\x1b[<39;11;2M".to_vec());
}
#[test]
fn click_forwarding_policy_disables_click_forward_for_focus_only() {
let mut view_state = attach_view_state_with_scrollback_fixture();
view_state.mouse.config.click_propagation = MouseClickPropagation::FocusOnly;
assert!(!should_forward_click_like_mouse(&view_state));
}
#[test]
fn pane_mouse_protocol_reports_clicks_even_when_click_propagation_is_focus_only() {
let mut view_state = attach_view_state_with_scrollback_fixture();
view_state.mouse.config.click_propagation = MouseClickPropagation::FocusOnly;
let pane_id = focused_attach_pane_id(&view_state).expect("focused pane id");
assert!(!pane_mouse_protocol_reports_event(
&view_state,
Some(pane_id),
MouseEventKind::Down(MouseButton::Left),
));
let buffer = view_state
.pane_buffers
.get_mut(&pane_id)
.expect("pane render buffer");
append_pane_output(buffer, b"\x1b[?1000h\x1b[?1006h");
assert!(pane_mouse_protocol_reports_event(
&view_state,
Some(pane_id),
MouseEventKind::Down(MouseButton::Left),
));
}
#[test]
fn click_forwarding_policy_enables_click_forward_for_focus_and_forward() {
let mut view_state = attach_view_state_with_scrollback_fixture();
view_state.mouse.config.click_propagation = MouseClickPropagation::FocusAndForward;
assert!(should_forward_click_like_mouse(&view_state));
}
#[test]
fn wheel_policy_forward_and_scrollback_is_available() {
let mut view_state = attach_view_state_with_scrollback_fixture();
view_state.mouse.config.wheel_propagation = MouseWheelPropagation::ForwardAndScrollback;
view_state.mouse.config.scroll_scrollback = true;
assert_eq!(
view_state.mouse.config.effective_wheel_propagation(),
MouseWheelPropagation::ForwardAndScrollback
);
}
#[test]
fn wheel_policy_defaults_to_auto() {
let view_state = attach_view_state_with_scrollback_fixture();
assert_eq!(
view_state.mouse.config.effective_wheel_propagation(),
MouseWheelPropagation::Auto
);
}
#[test]
fn mouse_selection_drag_selects_visible_scrollback_text() {
let mut view_state = attach_view_state_with_scrollback_fixture();
let pane_id = focused_attach_pane_id(&view_state).expect("focused pane");
let down = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 2,
row: 2,
modifiers: KeyModifiers::empty(),
};
assert!(maybe_begin_attach_mouse_selection_drag(
&mut view_state,
Some(pane_id),
down,
));
assert!(!view_state.scrollback_active());
let drag = MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
column: 5,
row: 3,
modifiers: KeyModifiers::empty(),
};
assert!(handle_attach_mouse_selection_drag_at(
&mut view_state,
drag,
Instant::now()
));
assert!(view_state.scrollback_active());
assert_eq!(
test_selection_anchor(&view_state),
Some(AttachScrollbackPosition { line: 2, col: 1 })
);
assert_eq!(
test_scrollback_cursor(&view_state),
Some(AttachScrollbackCursor { row: 2, col: 4 })
);
let up = MouseEvent {
kind: MouseEventKind::Up(MouseButton::Left),
column: 5,
row: 3,
modifiers: KeyModifiers::empty(),
};
assert!(handle_attach_mouse_selection_drag_at(
&mut view_state,
up,
Instant::now()
));
assert!(view_state.scrollback_active());
assert_eq!(view_state.mouse.selection_drag, None);
assert!(view_state.selection_active());
}
#[test]
fn mouse_selection_drag_ignores_single_click_without_drag() {
let mut view_state = attach_view_state_with_scrollback_fixture();
let pane_id = focused_attach_pane_id(&view_state).expect("focused pane");
let down = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 2,
row: 2,
modifiers: KeyModifiers::empty(),
};
assert!(maybe_begin_attach_mouse_selection_drag(
&mut view_state,
Some(pane_id),
down,
));
let up = MouseEvent {
kind: MouseEventKind::Up(MouseButton::Left),
column: 2,
row: 2,
modifiers: KeyModifiers::empty(),
};
assert!(handle_attach_mouse_selection_drag_at(
&mut view_state,
up,
Instant::now()
));
assert!(!view_state.scrollback_active());
assert_eq!(test_selection_anchor(&view_state), None);
}
#[test]
fn mouse_selection_drag_respects_copy_release_config_without_clearing_on_failure() {
let mut view_state = attach_view_state_with_scrollback_fixture();
view_state.mouse.config.selection_release = MouseSelectionReleaseBehavior::Copy;
let pane_id = focused_attach_pane_id(&view_state).expect("focused pane");
view_state.mouse.selection_drag = Some(AttachMouseSelectionDrag {
pane_id,
anchor: AttachScrollbackPosition { line: 2, col: 1 },
active: true,
});
assert!(enter_attach_scrollback(&mut view_state));
if let Some(view) = view_state.focused_scrollback_mut() {
view.selection_anchor = Some(AttachScrollbackPosition { line: 2, col: 1 });
view.cursor = AttachScrollbackCursor { row: 2, col: 4 };
}
let up = MouseEvent {
kind: MouseEventKind::Up(MouseButton::Left),
column: 5,
row: 3,
modifiers: KeyModifiers::empty(),
};
assert!(handle_attach_mouse_selection_drag_at(
&mut view_state,
up,
Instant::now()
));
assert!(view_state.selection_active());
}
#[test]
fn mouse_selection_drag_does_not_start_in_alternate_screen() {
let mut view_state = attach_view_state_with_scrollback_fixture();
let pane_id = focused_attach_pane_id(&view_state).expect("focused pane");
let buffer = view_state
.pane_buffers
.get_mut(&pane_id)
.expect("pane render buffer");
append_pane_output(buffer, b"\x1b[?1049h");
let down = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 2,
row: 2,
modifiers: KeyModifiers::empty(),
};
assert!(!maybe_begin_attach_mouse_selection_drag(
&mut view_state,
Some(pane_id),
down,
));
}
#[test]
fn mouse_selection_drag_starts_in_alternate_screen_after_entering_scrollback() {
let mut view_state = attach_view_state_with_scrollback_fixture();
let pane_id = focused_attach_pane_id(&view_state).expect("focused pane");
let buffer = view_state
.pane_buffers
.get_mut(&pane_id)
.expect("pane render buffer");
append_pane_output(buffer, b"\x1b[?1049h\x1b[?1000h");
assert!(attach_pane_uses_alternate_screen(&view_state, pane_id));
assert!(enter_attach_scrollback_for(&mut view_state, pane_id));
let down = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 2,
row: 2,
modifiers: KeyModifiers::empty(),
};
assert!(maybe_begin_attach_mouse_selection_drag(
&mut view_state,
Some(pane_id),
down,
));
assert_eq!(
view_state.mouse.selection_drag.map(|drag| drag.pane_id),
Some(pane_id)
);
}
#[test]
fn mouse_selection_drag_does_not_start_when_clicks_always_forward() {
let mut view_state = attach_view_state_with_scrollback_fixture();
view_state.mouse.config.click_propagation = MouseClickPropagation::ForwardOnly;
let pane_id = focused_attach_pane_id(&view_state).expect("focused pane");
let down = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 2,
row: 2,
modifiers: KeyModifiers::empty(),
};
assert!(!maybe_begin_attach_mouse_selection_drag(
&mut view_state,
Some(pane_id),
down,
));
}
#[test]
fn attach_pane_mouse_protocol_reads_parser_state() {
let mut view_state = attach_view_state_with_scrollback_fixture();
let pane_id = focused_attach_pane_id(&view_state).expect("focused pane id");
let buffer = view_state
.pane_buffers
.get_mut(&pane_id)
.expect("pane render buffer");
append_pane_output(buffer, b"\x1b[?1000h\x1b[?1006h");
let protocol = attach_pane_mouse_protocol(&view_state, pane_id).expect("pane protocol");
assert_eq!(protocol.mode, AttachMouseProtocolMode::PressRelease);
assert_eq!(protocol.encoding, AttachMouseProtocolEncoding::Sgr);
}
#[test]
fn attach_pane_mouse_protocol_uses_snapshot_hint_when_parser_mode_is_none() {
let mut view_state = attach_view_state_with_scrollback_fixture();
let pane_id = focused_attach_pane_id(&view_state).expect("focused pane id");
view_state.pane_mouse_protocol_hints.insert(
pane_id,
AttachMouseProtocolState {
mode: AttachMouseProtocolMode::AnyMotion,
encoding: AttachMouseProtocolEncoding::Sgr,
},
);
let protocol = attach_pane_mouse_protocol(&view_state, pane_id).expect("pane protocol");
assert_eq!(protocol.mode, AttachMouseProtocolMode::AnyMotion);
assert_eq!(protocol.encoding, AttachMouseProtocolEncoding::Sgr);
}
#[test]
fn attach_pane_input_mode_reads_parser_state() {
let mut view_state = attach_view_state_with_scrollback_fixture();
let pane_id = focused_attach_pane_id(&view_state).expect("focused pane id");
let buffer = view_state
.pane_buffers
.get_mut(&pane_id)
.expect("pane render buffer");
append_pane_output(buffer, b"\x1b[?1h\x1b[?2004h\x1b=");
let mode = attach_pane_input_mode(&view_state, pane_id).expect("pane mode");
assert!(mode.application_cursor);
assert!(mode.application_keypad);
assert!(mode.bracketed_paste);
let buffer = view_state
.pane_buffers
.get_mut(&pane_id)
.expect("pane render buffer");
append_pane_output(buffer, b"\x1b[?2004l");
update_protocol_hints_from_state(
&mut view_state.pane_mouse_protocol_hints,
&mut view_state.pane_input_mode_hints,
pane_id,
buffer.protocol_tracker.protocol_state(),
);
let mode = attach_pane_input_mode(&view_state, pane_id).expect("pane mode after reset");
assert!(!mode.bracketed_paste);
}
#[test]
fn attach_pane_input_mode_uses_snapshot_hint_when_parser_mode_is_default() {
let mut view_state = attach_view_state_with_scrollback_fixture();
let pane_id = focused_attach_pane_id(&view_state).expect("focused pane id");
view_state.pane_input_mode_hints.insert(
pane_id,
AttachInputModeState {
application_cursor: true,
application_keypad: true,
bracketed_paste: true,
},
);
let mode = attach_pane_input_mode(&view_state, pane_id).expect("pane mode");
assert!(mode.application_cursor);
assert!(mode.application_keypad);
assert!(mode.bracketed_paste);
}
#[test]
fn attach_layout_requires_snapshot_hydration_ignores_focus_only_scene_change() {
let view_state = attach_view_state_with_scrollback_fixture();
let previous = view_state.cached_layout_state.expect("layout state");
let mut next = previous.clone();
next.scene.surfaces[0].cursor_owner = false;
assert_ne!(previous.scene, next.scene);
assert!(!attach_layout_requires_snapshot_hydration(&previous, &next));
}
#[test]
fn attach_scene_revealed_pane_ids_detects_zoom_focus_switch() {
let view_state = attach_view_state_with_scrollback_fixture();
let previous = view_state.cached_layout_state.expect("layout state");
let previous_pane_id = previous.panes[0].id;
let next_pane_id = Uuid::new_v4();
let mut next = previous.clone();
next.focused_pane_id = next_pane_id;
next.scene.focus = AttachFocusTarget::Pane {
pane_id: next_pane_id,
};
next.scene.surfaces[0].id = next_pane_id;
next.scene.surfaces[0].pane_id = Some(next_pane_id);
let revealed = attach_scene_revealed_pane_ids(&previous.scene, &next.scene);
assert_eq!(revealed, BTreeSet::from([next_pane_id]));
assert!(!revealed.contains(&previous_pane_id));
}
#[test]
fn attach_scene_revealed_pane_ids_ignores_focus_metadata_only_changes() {
let view_state = attach_view_state_with_scrollback_fixture();
let previous = view_state.cached_layout_state.expect("layout state");
let mut next = previous.clone();
next.scene.surfaces[0].cursor_owner = false;
let revealed = attach_scene_revealed_pane_ids(&previous.scene, &next.scene);
assert!(revealed.is_empty());
}
#[test]
fn attach_layout_requires_snapshot_hydration_on_layout_tree_change() {
let view_state = attach_view_state_with_scrollback_fixture();
let previous = view_state.cached_layout_state.expect("layout state");
let existing_pane = previous.panes[0].id;
let new_pane = Uuid::new_v4();
let mut next = previous.clone();
next.panes.push(PaneSummary {
id: new_pane,
index: 2,
name: None,
focused: false,
state: PaneState::Running,
state_reason: None,
});
next.layout_root = PaneLayoutNode::Split {
direction: bmux_attach_layout_protocol::PaneSplitDirection::Vertical,
ratio_percent: 50,
first: Box::new(PaneLayoutNode::Leaf {
pane_id: existing_pane,
}),
second: Box::new(PaneLayoutNode::Leaf { pane_id: new_pane }),
};
assert!(attach_layout_requires_snapshot_hydration(&previous, &next));
}
#[test]
fn encode_attach_mouse_for_protocol_skips_when_mode_is_disabled() {
let encoded = encode_attach_mouse_for_protocol(
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 1,
row: 1,
modifiers: KeyModifiers::NONE,
},
AttachPaneMouseProtocol {
mode: AttachMouseProtocolMode::None,
encoding: AttachMouseProtocolEncoding::Sgr,
},
);
assert!(encoded.is_none());
}
#[test]
fn mouse_protocol_mode_reports_event_rejects_move_without_any_motion_mode() {
assert!(!mouse_protocol_mode_reports_event(
AttachMouseProtocolMode::PressRelease,
MouseEventKind::Moved,
));
assert!(mouse_protocol_mode_reports_event(
AttachMouseProtocolMode::AnyMotion,
MouseEventKind::Moved,
));
}
#[test]
fn mouse_protocol_mode_reports_event_rejects_release_in_press_mode() {
assert!(!mouse_protocol_mode_reports_event(
AttachMouseProtocolMode::Press,
MouseEventKind::Up(MouseButton::Left),
));
assert!(mouse_protocol_mode_reports_event(
AttachMouseProtocolMode::Press,
MouseEventKind::Down(MouseButton::Left),
));
}
#[test]
fn encode_attach_mouse_default_uses_csi_m_sequence() {
let encoded = encode_attach_mouse_for_protocol(
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 0,
row: 0,
modifiers: KeyModifiers::NONE,
},
AttachPaneMouseProtocol {
mode: AttachMouseProtocolMode::PressRelease,
encoding: AttachMouseProtocolEncoding::Default,
},
)
.expect("default-encoded mouse event");
assert_eq!(encoded, vec![0x1b, b'[', b'M', 32, 33, 33]);
}
#[test]
fn encode_attach_mouse_default_rejects_wide_coordinates() {
let encoded = encode_attach_mouse_for_protocol(
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 223,
row: 0,
modifiers: KeyModifiers::NONE,
},
AttachPaneMouseProtocol {
mode: AttachMouseProtocolMode::PressRelease,
encoding: AttachMouseProtocolEncoding::Default,
},
);
assert!(encoded.is_none());
}
#[test]
fn encode_attach_mouse_utf8_supports_wide_coordinates() {
let encoded = encode_attach_mouse_for_protocol(
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 223,
row: 0,
modifiers: KeyModifiers::NONE,
},
AttachPaneMouseProtocol {
mode: AttachMouseProtocolMode::PressRelease,
encoding: AttachMouseProtocolEncoding::Utf8,
},
)
.expect("utf8-encoded mouse event");
assert_eq!(encoded, vec![0x1b, b'[', b'M', 32, 0xC4, 0x80, 33]);
}
#[test]
fn attach_loop_mouse_moved_without_pane_mouse_mode_does_not_forward_bytes() {
let mut view_state = attach_view_state_with_scrollback_fixture();
let mut processor =
InputProcessor::new(attach_keymap_from_config(&BmuxConfig::default()), false);
let event = CrosstermEvent::Mouse(MouseEvent {
kind: MouseEventKind::Moved,
column: 2,
row: 2,
modifiers: KeyModifiers::NONE,
});
let actions = attach_event_actions(&event, &mut processor, AttachUiMode::Normal)
.expect("mouse event should map through attach loop");
let mouse_event = match actions.as_slice() {
[AttachEventAction::Mouse(mouse)] => *mouse,
_ => panic!("unexpected attach actions for mouse event"),
};
let focus_target = attach_scene_pane_at(&view_state, mouse_event.column, mouse_event.row);
let focused = view_state
.cached_layout_state
.as_ref()
.map(|layout| layout.focused_pane_id);
let in_focused = focus_target.is_some() && focus_target == focused;
let forwarded = attach_mouse_forward_bytes_for_target(
&view_state,
mouse_event,
focus_target,
in_focused,
);
assert!(
forwarded.is_none(),
"mouse move should not forward when pane mouse mode is disabled"
);
let pane_id = focused_attach_pane_id(&view_state).expect("focused pane id");
let buffer = view_state
.pane_buffers
.get_mut(&pane_id)
.expect("pane render buffer");
append_pane_output(buffer, b"\x1b[?1003h\x1b[?1006h");
let forwarded = attach_mouse_forward_bytes_for_target(
&view_state,
mouse_event,
focus_target,
in_focused,
)
.expect("mouse move should forward once pane enables any-motion mode");
assert_eq!(forwarded, b"\x1b[<35;2;2M".to_vec());
}
#[test]
fn attach_mouse_forward_translates_coordinates_to_pane_local() {
let session_id = Uuid::new_v4();
let pane_id = Uuid::new_v4();
let mut view_state = AttachViewState::new(AttachOpenInfo {
context_id: None,
session_id,
can_write: true,
});
let rect = AttachRect {
x: 91,
y: 1,
w: 90,
h: 40,
};
view_state.cached_layout_state = Some(AttachLayoutState {
context_id: None,
session_id,
focused_pane_id: pane_id,
panes: vec![PaneSummary {
id: pane_id,
index: 1,
name: None,
focused: true,
state: PaneState::Running,
state_reason: None,
}],
layout_root: PaneLayoutNode::Leaf { pane_id },
scene: AttachScene {
session_id,
focus: AttachFocusTarget::Pane { pane_id },
surfaces: vec![AttachSurface {
id: Uuid::new_v4(),
kind: AttachSurfaceKind::Pane,
layer: SurfaceLayer::Pane,
z: 0,
pane_id: Some(pane_id),
rect,
content_rect: rect,
interactive_regions: Vec::new(),
opaque: true,
visible: true,
accepts_input: true,
cursor_owner: true,
}],
},
zoomed: false,
});
let buffer = view_state
.pane_buffers
.entry(pane_id)
.or_insert_with(|| PaneRenderBuffer {
terminal_grid: bmux_terminal_grid::TerminalGridStream::new(
90,
40,
bmux_terminal_grid::GridLimits::default(),
)
.expect("test grid dimensions are valid"),
..PaneRenderBuffer::default()
});
append_pane_output(buffer, b"\x1b[?1000h\x1b[?1006h");
let first_cell = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 91,
row: 1,
modifiers: KeyModifiers::NONE,
};
let forwarded =
attach_mouse_forward_bytes_for_target(&view_state, first_cell, Some(pane_id), true)
.expect("forward click at pane origin");
assert_eq!(forwarded, b"\x1b[<0;1;1M".to_vec());
let middle = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 100,
row: 5,
modifiers: KeyModifiers::NONE,
};
let forwarded =
attach_mouse_forward_bytes_for_target(&view_state, middle, Some(pane_id), true)
.expect("forward click inside pane");
assert_eq!(forwarded, b"\x1b[<0;10;5M".to_vec());
let outside = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 5,
row: 1,
modifiers: KeyModifiers::NONE,
};
let forwarded =
attach_mouse_forward_bytes_for_target(&view_state, outside, Some(pane_id), true);
assert!(
forwarded.is_none(),
"clicks outside the pane rect must not be forwarded"
);
}
#[test]
fn attach_mouse_forward_uses_content_rect_not_outer_rect() {
let session_id = Uuid::new_v4();
let pane_id = Uuid::new_v4();
let mut view_state = AttachViewState::new(AttachOpenInfo {
context_id: None,
session_id,
can_write: true,
});
let outer = AttachRect {
x: 91,
y: 1,
w: 90,
h: 40,
};
let content = AttachRect {
x: outer.x + 1,
y: outer.y + 1,
w: outer.w - 2,
h: outer.h - 2,
};
view_state.cached_layout_state = Some(AttachLayoutState {
context_id: None,
session_id,
focused_pane_id: pane_id,
panes: vec![PaneSummary {
id: pane_id,
index: 1,
name: None,
focused: true,
state: PaneState::Running,
state_reason: None,
}],
layout_root: PaneLayoutNode::Leaf { pane_id },
scene: AttachScene {
session_id,
focus: AttachFocusTarget::Pane { pane_id },
surfaces: vec![AttachSurface {
id: Uuid::new_v4(),
kind: AttachSurfaceKind::Pane,
layer: SurfaceLayer::Pane,
z: 0,
pane_id: Some(pane_id),
rect: outer,
content_rect: content,
interactive_regions: Vec::new(),
opaque: true,
visible: true,
accepts_input: true,
cursor_owner: true,
}],
},
zoomed: false,
});
let buffer = view_state
.pane_buffers
.entry(pane_id)
.or_insert_with(|| PaneRenderBuffer {
terminal_grid: bmux_terminal_grid::TerminalGridStream::new(
88,
38,
bmux_terminal_grid::GridLimits::default(),
)
.expect("test grid dimensions are valid"),
..PaneRenderBuffer::default()
});
append_pane_output(buffer, b"\x1b[?1000h\x1b[?1006h");
let first_content_cell = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: content.x,
row: content.y,
modifiers: KeyModifiers::NONE,
};
let forwarded = attach_mouse_forward_bytes_for_target(
&view_state,
first_content_cell,
Some(pane_id),
true,
)
.expect("click on the first content cell should forward");
assert_eq!(
forwarded,
b"\x1b[<0;1;1M".to_vec(),
"click at the visual top-left content cell must encode as SGR (1, 1)"
);
let border_click = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: outer.x + 5,
row: outer.y,
modifiers: KeyModifiers::NONE,
};
let forwarded =
attach_mouse_forward_bytes_for_target(&view_state, border_click, Some(pane_id), true);
assert!(
forwarded.is_none(),
"clicks on the border (outside content_rect) must not forward PTY bytes"
);
}
#[test]
#[allow(clippy::too_many_lines)]
fn attach_mouse_forward_honors_render_extension_content_rect() {
use bmux_plugin::{AttachRenderExtension, ExtensionRect, RenderDamage};
use std::io;
use std::sync::Arc;
struct FixedInsetExtension {
surface_id: Uuid,
override_rect: ExtensionRect,
}
impl AttachRenderExtension for FixedInsetExtension {
#[allow(clippy::unnecessary_literal_bound)]
fn name(&self) -> &str {
"test.fixed_inset"
}
fn render_surface(
&self,
_stdout: &mut dyn io::Write,
_surface_id: Uuid,
_surface_rect: &ExtensionRect,
_damage: &RenderDamage,
) -> io::Result<bool> {
Ok(false)
}
fn content_rect_override(&self, surface_id: Uuid) -> Option<ExtensionRect> {
if surface_id == self.surface_id {
Some(self.override_rect)
} else {
None
}
}
}
let session_id = Uuid::new_v4();
let pane_id = Uuid::new_v4();
let surface_id = Uuid::new_v4();
let mut view_state = AttachViewState::new(AttachOpenInfo {
context_id: None,
session_id,
can_write: true,
});
let outer = AttachRect {
x: 0,
y: 0,
w: 40,
h: 10,
};
let scene_content = AttachRect {
x: 1,
y: 1,
w: 38,
h: 8,
};
view_state.cached_layout_state = Some(AttachLayoutState {
context_id: None,
session_id,
focused_pane_id: pane_id,
panes: vec![PaneSummary {
id: pane_id,
index: 1,
name: None,
focused: true,
state: PaneState::Running,
state_reason: None,
}],
layout_root: PaneLayoutNode::Leaf { pane_id },
scene: AttachScene {
session_id,
focus: AttachFocusTarget::Pane { pane_id },
surfaces: vec![AttachSurface {
id: surface_id,
kind: AttachSurfaceKind::Pane,
layer: SurfaceLayer::Pane,
z: 0,
pane_id: Some(pane_id),
rect: outer,
content_rect: scene_content,
interactive_regions: Vec::new(),
opaque: true,
visible: true,
accepts_input: true,
cursor_owner: true,
}],
},
zoomed: false,
});
let plugin_content = ExtensionRect {
x: 2,
y: 2,
w: 36,
h: 6,
};
bmux_plugin::register_render_extension(Arc::new(FixedInsetExtension {
surface_id,
override_rect: plugin_content,
}));
let buffer = view_state
.pane_buffers
.entry(pane_id)
.or_insert_with(|| PaneRenderBuffer {
terminal_grid: bmux_terminal_grid::TerminalGridStream::new(
36,
6,
bmux_terminal_grid::GridLimits::default(),
)
.expect("test grid dimensions are valid"),
..PaneRenderBuffer::default()
});
append_pane_output(buffer, b"\x1b[?1000h\x1b[?1006h");
let first_cell = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: plugin_content.x,
row: plugin_content.y,
modifiers: KeyModifiers::NONE,
};
let forwarded =
attach_mouse_forward_bytes_for_target(&view_state, first_cell, Some(pane_id), true)
.expect("click on extension's first content cell should forward");
assert_eq!(
forwarded,
b"\x1b[<0;1;1M".to_vec(),
"extension's content_rect_override must take precedence over the scene producer's"
);
let scene_cell = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: scene_content.x,
row: scene_content.y,
modifiers: KeyModifiers::NONE,
};
let forwarded =
attach_mouse_forward_bytes_for_target(&view_state, scene_cell, Some(pane_id), true);
assert!(
forwarded.is_none(),
"clicks under the extension's wider border must not forward bytes"
);
}
#[test]
fn resolve_mouse_gesture_action_parses_plugin_command() {
let mut view_state = AttachViewState::new(AttachOpenInfo {
context_id: None,
session_id: Uuid::new_v4(),
can_write: true,
});
view_state.mouse.config.gesture_actions.insert(
"click_left".to_string(),
"plugin:bmux.tabs:new-tab".to_string(),
);
let resolved = resolve_mouse_gesture_action(&view_state, "click_left");
assert!(matches!(
resolved,
Some(AttachEventAction::PluginCommand {
plugin_id,
command_name,
args,
}) if plugin_id == "bmux.tabs" && command_name == "new-tab" && args.is_empty()
));
}
#[test]
fn attach_scene_pane_at_prefers_topmost_surface() {
let session_id = Uuid::new_v4();
let background_pane = Uuid::new_v4();
let floating_pane = Uuid::new_v4();
let mut view_state = AttachViewState::new(AttachOpenInfo {
context_id: None,
session_id,
can_write: true,
});
view_state.cached_layout_state = Some(AttachLayoutState {
context_id: None,
session_id,
focused_pane_id: background_pane,
panes: Vec::new(),
layout_root: PaneLayoutNode::Leaf {
pane_id: background_pane,
},
scene: AttachScene {
session_id,
focus: AttachFocusTarget::Pane {
pane_id: background_pane,
},
surfaces: vec![
AttachSurface {
id: Uuid::new_v4(),
kind: AttachSurfaceKind::Pane,
layer: SurfaceLayer::Pane,
z: 1,
rect: AttachRect {
x: 0,
y: 0,
w: 20,
h: 10,
},
content_rect: AttachRect {
x: 0,
y: 0,
w: 20,
h: 10,
},
interactive_regions: Vec::new(),
opaque: true,
visible: true,
accepts_input: true,
cursor_owner: true,
pane_id: Some(background_pane),
},
AttachSurface {
id: Uuid::new_v4(),
kind: AttachSurfaceKind::FloatingPane,
layer: SurfaceLayer::FloatingPane,
z: 10,
rect: AttachRect {
x: 2,
y: 2,
w: 8,
h: 5,
},
content_rect: AttachRect {
x: 2,
y: 2,
w: 8,
h: 5,
},
interactive_regions: Vec::new(),
opaque: true,
visible: true,
accepts_input: true,
cursor_owner: false,
pane_id: Some(floating_pane),
},
],
},
zoomed: false,
});
assert_eq!(attach_scene_pane_at(&view_state, 4, 4), Some(floating_pane));
assert_eq!(
attach_scene_pane_at(&view_state, 1, 1),
Some(background_pane)
);
assert_eq!(attach_scene_pane_at(&view_state, 30, 30), None);
}
#[test]
fn floating_drag_hit_testing_uses_border_not_content() {
let session_id = Uuid::new_v4();
let background_pane = Uuid::new_v4();
let floating_pane = Uuid::new_v4();
let mut view_state = AttachViewState::new(AttachOpenInfo {
context_id: None,
session_id,
can_write: true,
});
view_state.cached_layout_state = Some(AttachLayoutState {
context_id: None,
session_id,
focused_pane_id: background_pane,
panes: Vec::new(),
layout_root: PaneLayoutNode::Leaf {
pane_id: background_pane,
},
scene: AttachScene {
session_id,
focus: AttachFocusTarget::Pane {
pane_id: background_pane,
},
surfaces: vec![AttachSurface {
id: Uuid::new_v4(),
kind: AttachSurfaceKind::FloatingPane,
layer: SurfaceLayer::FloatingPane,
z: 1,
rect: AttachRect {
x: 2,
y: 2,
w: 8,
h: 5,
},
content_rect: AttachRect {
x: 3,
y: 3,
w: 6,
h: 3,
},
interactive_regions: Vec::new(),
opaque: true,
visible: true,
accepts_input: true,
cursor_owner: false,
pane_id: Some(floating_pane),
}],
},
zoomed: false,
});
assert_eq!(
attach_scene_floating_drag_surface_at(&view_state, 2, 2)
.and_then(|surface| surface.pane_id),
Some(floating_pane)
);
assert_eq!(
attach_scene_floating_drag_surface_at(&view_state, 4, 4),
None
);
}
#[test]
fn floating_drag_hit_testing_prefers_topmost_surface() {
let session_id = Uuid::new_v4();
let background_pane = Uuid::new_v4();
let low_floating = Uuid::new_v4();
let high_floating = Uuid::new_v4();
let floating_surface = |pane_id, z| AttachSurface {
id: Uuid::new_v4(),
kind: AttachSurfaceKind::FloatingPane,
layer: SurfaceLayer::FloatingPane,
z,
rect: AttachRect {
x: 2,
y: 2,
w: 8,
h: 5,
},
content_rect: AttachRect {
x: 3,
y: 3,
w: 6,
h: 3,
},
interactive_regions: Vec::new(),
opaque: true,
visible: true,
accepts_input: true,
cursor_owner: false,
pane_id: Some(pane_id),
};
let mut view_state = AttachViewState::new(AttachOpenInfo {
context_id: None,
session_id,
can_write: true,
});
view_state.cached_layout_state = Some(AttachLayoutState {
context_id: None,
session_id,
focused_pane_id: background_pane,
panes: Vec::new(),
layout_root: PaneLayoutNode::Leaf {
pane_id: background_pane,
},
scene: AttachScene {
session_id,
focus: AttachFocusTarget::Pane {
pane_id: background_pane,
},
surfaces: vec![
floating_surface(low_floating, 1),
floating_surface(high_floating, 2),
],
},
zoomed: false,
});
assert_eq!(
attach_scene_floating_drag_surface_at(&view_state, 2, 2)
.and_then(|surface| surface.pane_id),
Some(high_floating)
);
}
#[test]
fn floating_drag_position_clamps_top_left_and_applies_delta() {
let drag = AttachMouseFloatingDrag {
pane_id: Uuid::new_v4(),
start_x: 5,
start_y: 4,
width: 8,
height: 5,
scene_max_x: 19,
scene_max_y: 9,
last_x: 5,
last_y: 4,
start_column: 10,
start_row: 10,
};
assert_eq!(floating_drag_position(drag, 12, 11), (7, 5));
assert_eq!(floating_drag_position(drag, 30, 30), (12, 5));
assert_eq!(floating_drag_position(drag, 0, 0), (0, 0));
}
#[test]
fn resize_separator_uses_reserved_final_scene_offsets() {
let session_id = Uuid::new_v4();
let left_pane = Uuid::new_v4();
let right_pane = Uuid::new_v4();
let mut view_state = AttachViewState::new(AttachOpenInfo {
context_id: None,
session_id,
can_write: true,
});
view_state.cached_layout_state = Some(AttachLayoutState {
context_id: None,
session_id,
focused_pane_id: left_pane,
panes: Vec::new(),
layout_root: PaneLayoutNode::Leaf { pane_id: left_pane },
scene: AttachScene {
session_id,
focus: AttachFocusTarget::Pane { pane_id: left_pane },
surfaces: vec![
test_pane_surface(
left_pane,
AttachRect {
x: 11,
y: 2,
w: 20,
h: 20,
},
),
test_pane_surface(
right_pane,
AttachRect {
x: 31,
y: 2,
w: 30,
h: 20,
},
),
],
},
zoomed: false,
});
let drag = attach_scene_resize_separator_at(&view_state, 30, 10, Instant::now())
.expect("offset separator");
assert!(drag.horizontal.is_some());
assert!(attach_scene_resize_separator_at(&view_state, 9, 10, Instant::now()).is_none());
}
#[test]
fn attach_scene_resize_separator_detects_vertical_boundary() {
let session_id = Uuid::new_v4();
let left_pane = Uuid::new_v4();
let right_pane = Uuid::new_v4();
let mut view_state = AttachViewState::new(AttachOpenInfo {
context_id: None,
session_id,
can_write: true,
});
view_state.cached_layout_state = Some(AttachLayoutState {
context_id: None,
session_id,
focused_pane_id: left_pane,
panes: Vec::new(),
layout_root: PaneLayoutNode::Leaf { pane_id: left_pane },
scene: AttachScene {
session_id,
focus: AttachFocusTarget::Pane { pane_id: left_pane },
surfaces: vec![
test_pane_surface(
left_pane,
AttachRect {
x: 0,
y: 0,
w: 10,
h: 8,
},
),
test_pane_surface(
right_pane,
AttachRect {
x: 10,
y: 0,
w: 10,
h: 8,
},
),
],
},
zoomed: false,
});
let now = Instant::now();
let drag = attach_scene_resize_separator_at(&view_state, 9, 3, now)
.expect("separator should be detected");
assert!(drag.vertical.is_none());
let horizontal = drag.horizontal.expect("horizontal drag axis");
assert_eq!(horizontal.positive_target_pane_id, left_pane);
assert_eq!(
horizontal.positive_direction,
bmux_tabs_plugin_api::tabs_commands::PaneResizeDirection::Right
);
assert_eq!(horizontal.negative_target_pane_id, right_pane);
assert_eq!(
horizontal.negative_direction,
bmux_tabs_plugin_api::tabs_commands::PaneResizeDirection::Left
);
assert!(attach_scene_resize_separator_at(&view_state, 4, 3, now).is_none());
let reduction = reduce_attach_mouse_resize_acquisition_before_uncaptured_hook(
&mut view_state,
TerminalMouseEvent {
phase: TerminalMousePhase::Down,
button: Some(TerminalMouseButton::Left),
col: 9,
row: 3,
modifiers: TerminalModifiers::default(),
},
now,
);
assert!(reduction.consumed);
assert_eq!(
view_state.mouse.pointer_owner(),
Some(AttachPointerOwner::Resize)
);
}
#[test]
fn resize_acquisition_precedence_ignores_non_down_events() {
let mut view_state = AttachViewState::new(AttachOpenInfo {
context_id: None,
session_id: Uuid::new_v4(),
can_write: true,
});
let reduction = reduce_attach_mouse_resize_acquisition_before_uncaptured_hook(
&mut view_state,
TerminalMouseEvent {
phase: TerminalMousePhase::Drag,
button: Some(TerminalMouseButton::Left),
col: 9,
row: 3,
modifiers: TerminalModifiers::default(),
},
Instant::now(),
);
assert!(!reduction.consumed);
assert_eq!(view_state.mouse.pointer_owner(), None);
}
#[test]
fn attach_scene_resize_separator_detects_horizontal_boundary() {
let session_id = Uuid::new_v4();
let top_pane = Uuid::new_v4();
let bottom_pane = Uuid::new_v4();
let mut view_state = AttachViewState::new(AttachOpenInfo {
context_id: None,
session_id,
can_write: true,
});
view_state.cached_layout_state = Some(AttachLayoutState {
context_id: None,
session_id,
focused_pane_id: top_pane,
panes: Vec::new(),
layout_root: PaneLayoutNode::Leaf { pane_id: top_pane },
scene: AttachScene {
session_id,
focus: AttachFocusTarget::Pane { pane_id: top_pane },
surfaces: vec![
test_pane_surface(
top_pane,
AttachRect {
x: 0,
y: 0,
w: 20,
h: 5,
},
),
test_pane_surface(
bottom_pane,
AttachRect {
x: 0,
y: 5,
w: 20,
h: 5,
},
),
],
},
zoomed: false,
});
let now = Instant::now();
let drag = attach_scene_resize_separator_at(&view_state, 12, 5, now)
.expect("separator should be detected");
assert!(drag.horizontal.is_none());
let vertical = drag.vertical.expect("vertical drag axis");
assert_eq!(vertical.positive_target_pane_id, top_pane);
assert_eq!(
vertical.positive_direction,
bmux_tabs_plugin_api::tabs_commands::PaneResizeDirection::Down
);
assert_eq!(vertical.negative_target_pane_id, bottom_pane);
assert_eq!(
vertical.negative_direction,
bmux_tabs_plugin_api::tabs_commands::PaneResizeDirection::Up
);
assert!(attach_scene_resize_separator_at(&view_state, 12, 2, now).is_none());
}
#[test]
fn attach_scene_resize_separator_detects_corner_boundary() {
let session_id = Uuid::new_v4();
let top_left = Uuid::new_v4();
let top_right = Uuid::new_v4();
let bottom_left = Uuid::new_v4();
let bottom_right = Uuid::new_v4();
let mut view_state = AttachViewState::new(AttachOpenInfo {
context_id: None,
session_id,
can_write: true,
});
view_state.cached_layout_state = Some(AttachLayoutState {
context_id: None,
session_id,
focused_pane_id: top_left,
panes: Vec::new(),
layout_root: PaneLayoutNode::Leaf { pane_id: top_left },
scene: AttachScene {
session_id,
focus: AttachFocusTarget::Pane { pane_id: top_left },
surfaces: vec![
test_pane_surface(
top_left,
AttachRect {
x: 0,
y: 0,
w: 10,
h: 5,
},
),
test_pane_surface(
top_right,
AttachRect {
x: 10,
y: 0,
w: 10,
h: 5,
},
),
test_pane_surface(
bottom_left,
AttachRect {
x: 0,
y: 5,
w: 10,
h: 5,
},
),
test_pane_surface(
bottom_right,
AttachRect {
x: 10,
y: 5,
w: 10,
h: 5,
},
),
],
},
zoomed: false,
});
let drag = attach_scene_resize_separator_at(&view_state, 9, 4, Instant::now())
.expect("corner separator should be detected");
let horizontal = drag.horizontal.expect("horizontal drag axis");
let vertical = drag.vertical.expect("vertical drag axis");
assert_eq!(horizontal.positive_target_pane_id, top_left);
assert_eq!(horizontal.negative_target_pane_id, top_right);
assert_eq!(vertical.positive_target_pane_id, top_left);
assert_eq!(vertical.negative_target_pane_id, bottom_left);
}
#[test]
fn resize_drag_axis_delta_maps_motion_to_target_direction_and_cells() {
let left_pane = Uuid::new_v4();
let right_pane = Uuid::new_v4();
let drag = AttachMouseResizeAxisDrag {
positive_target_pane_id: left_pane,
positive_direction: bmux_tabs_plugin_api::tabs_commands::PaneResizeDirection::Right,
negative_target_pane_id: right_pane,
negative_direction: bmux_tabs_plugin_api::tabs_commands::PaneResizeDirection::Left,
};
let (target, direction, cells) =
resize_drag_axis_delta(Some(drag), 3).expect("rightward drag should resize");
assert_eq!(target, left_pane);
assert_eq!(
direction,
bmux_tabs_plugin_api::tabs_commands::PaneResizeDirection::Right
);
assert_eq!(cells, 3);
let (target, direction, cells) =
resize_drag_axis_delta(Some(drag), -2).expect("leftward drag should resize");
assert_eq!(target, right_pane);
assert_eq!(
direction,
bmux_tabs_plugin_api::tabs_commands::PaneResizeDirection::Left
);
assert_eq!(cells, 2);
assert!(resize_drag_axis_delta(Some(drag), 0).is_none());
assert!(resize_drag_axis_delta(None, 2).is_none());
}
#[test]
fn resize_drag_pending_delta_tracks_latest_unapplied_position() {
let left_pane = Uuid::new_v4();
let right_pane = Uuid::new_v4();
let mut drag = AttachMouseResizeDrag {
horizontal: Some(AttachMouseResizeAxisDrag {
positive_target_pane_id: left_pane,
positive_direction: bmux_tabs_plugin_api::tabs_commands::PaneResizeDirection::Right,
negative_target_pane_id: right_pane,
negative_direction: bmux_tabs_plugin_api::tabs_commands::PaneResizeDirection::Left,
}),
vertical: None,
last_column: 10,
last_row: 4,
latest_column: 10,
latest_row: 4,
last_applied_at: Instant::now(),
};
assert!(!attach_mouse_resize_drag_has_pending_delta(&drag));
drag.latest_column = 14;
assert!(attach_mouse_resize_drag_has_pending_delta(&drag));
drag.last_column = drag.latest_column;
assert!(!attach_mouse_resize_drag_has_pending_delta(&drag));
}
#[test]
#[cfg(feature = "bundled-plugin-windows")]
#[allow(clippy::too_many_lines)]
fn attach_key_event_action_maps_prefixed_runtime_defaults() {
let mut processor = normal_mode_input_processor();
let new_tab = attach_key_event_actions(
&CrosstermKeyEvent::new_with_kind(
CrosstermKeyCode::Char('c'),
KeyModifiers::NONE,
CrosstermKeyEventKind::Press,
),
&mut processor,
AttachUiMode::Normal,
)
.expect("attach key action should parse");
assert!(matches!(
new_tab.first(),
Some(AttachEventAction::PluginCommand { plugin_id, command_name, args })
if plugin_id == "bmux.tabs" && command_name == "new-tab" && args.is_empty()
));
let next_window = attach_key_event_actions(
&CrosstermKeyEvent::new_with_kind(
CrosstermKeyCode::Char('s'),
KeyModifiers::CONTROL,
CrosstermKeyEventKind::Press,
),
&mut processor,
AttachUiMode::Normal,
)
.expect("attach key action should parse");
assert!(matches!(
next_window.first(),
Some(AttachEventAction::PluginCommand { plugin_id, command_name, args })
if plugin_id == "bmux.tabs" && command_name == "next-tab" && args.is_empty()
));
let previous_window = attach_key_event_actions(
&CrosstermKeyEvent::new_with_kind(
CrosstermKeyCode::Char('h'),
KeyModifiers::CONTROL,
CrosstermKeyEventKind::Press,
),
&mut processor,
AttachUiMode::Normal,
)
.expect("attach key action should parse");
assert!(matches!(
previous_window.first(),
Some(AttachEventAction::PluginCommand { plugin_id, command_name, args })
if plugin_id == "bmux.tabs" && command_name == "prev-tab" && args.is_empty()
));
let last_window = attach_key_event_actions(
&CrosstermKeyEvent::new_with_kind(
CrosstermKeyCode::Char('l'),
KeyModifiers::CONTROL,
CrosstermKeyEventKind::Press,
),
&mut processor,
AttachUiMode::Normal,
)
.expect("attach key action should parse");
assert!(matches!(
last_window.first(),
Some(AttachEventAction::PluginCommand { plugin_id, command_name, args })
if plugin_id == "bmux.tabs" && command_name == "last-tab" && args.is_empty()
));
let split_vertical = attach_key_event_actions(
&CrosstermKeyEvent::new_with_kind(
CrosstermKeyCode::Char('%'),
KeyModifiers::NONE,
CrosstermKeyEventKind::Press,
),
&mut processor,
AttachUiMode::Normal,
)
.expect("attach key action should parse");
assert!(matches!(
split_vertical.first(),
Some(AttachEventAction::PluginCommand { plugin_id, command_name, args })
if plugin_id == "bmux.tabs"
&& command_name == "split-pane"
&& args == &["--direction".to_string(), "vertical".to_string()]
));
let quit = attach_key_event_actions(
&CrosstermKeyEvent::new_with_kind(
CrosstermKeyCode::Char('q'),
KeyModifiers::NONE,
CrosstermKeyEventKind::Press,
),
&mut processor,
AttachUiMode::Normal,
)
.expect("attach key action should parse");
assert!(matches!(
quit.first(),
Some(AttachEventAction::Ui(crate::input::RuntimeAction::Quit))
));
let new_session = attach_key_event_actions(
&CrosstermKeyEvent::new_with_kind(
CrosstermKeyCode::Char('C'),
KeyModifiers::SHIFT,
CrosstermKeyEventKind::Press,
),
&mut processor,
AttachUiMode::Normal,
)
.expect("attach key action should parse");
assert!(matches!(
new_session.first(),
Some(AttachEventAction::PluginCommand {
plugin_id,
command_name,
..
}) if plugin_id == "bmux.sessions" && command_name == "new-session"
));
}
#[test]
fn attach_key_event_action_routes_ctrl_t_as_focus_prev_pane_by_default() {
let mut processor =
InputProcessor::new(attach_keymap_from_config(&BmuxConfig::default()), false);
let actions = attach_key_event_actions(
&CrosstermKeyEvent::new_with_kind(
CrosstermKeyCode::Char('t'),
KeyModifiers::CONTROL,
CrosstermKeyEventKind::Press,
),
&mut processor,
AttachUiMode::Normal,
)
.expect("attach key action should parse");
assert!(matches!(
actions.first(),
Some(AttachEventAction::PluginCommand { plugin_id, command_name, args })
if plugin_id == "bmux.tabs"
&& command_name == "focus-pane-in-direction"
&& args == &["--direction".to_string(), "prev".to_string()]
));
}
#[test]
fn attach_key_event_action_routes_h_to_focus_left_in_normal_mode() {
let mut processor = normal_mode_input_processor();
let normal_actions = attach_key_event_actions(
&CrosstermKeyEvent::new_with_kind(
CrosstermKeyCode::Char('h'),
KeyModifiers::NONE,
CrosstermKeyEventKind::Press,
),
&mut processor,
AttachUiMode::Normal,
)
.expect("attach key action should parse");
assert!(matches!(
normal_actions.first(),
Some(AttachEventAction::PluginCommand { plugin_id, command_name, args })
if plugin_id == "bmux.tabs"
&& command_name == "focus-pane-in-direction"
&& args == &["--direction".to_string(), "left".to_string()]
));
let _ = processor;
}
#[test]
fn global_plugin_command_with_args_maps_to_plugin_action() {
let mut config = BmuxConfig::default();
config.keybindings.global.insert(
"alt+1".to_string(),
"plugin:bmux.tabs:goto-tab 1".to_string(),
);
let mut processor = InputProcessor::new(attach_keymap_from_config(&config), false);
let actions = attach_key_event_actions(
&CrosstermKeyEvent::new_with_kind(
CrosstermKeyCode::Char('1'),
KeyModifiers::ALT,
CrosstermKeyEventKind::Press,
),
&mut processor,
AttachUiMode::Normal,
)
.expect("attach key action should parse");
assert!(
matches!(
actions.first(),
Some(AttachEventAction::PluginCommand {
plugin_id,
command_name,
args,
}) if plugin_id == "bmux.tabs"
&& command_name == "goto-tab"
&& args == &["1".to_string()]
),
"global alt+1 should map to PluginCommand with args"
);
}
#[test]
fn attach_key_event_action_routes_enter_scroll_mode_to_ui() {
let mut processor = normal_mode_input_processor();
let _ = attach_key_event_actions(
&CrosstermKeyEvent::new_with_kind(
CrosstermKeyCode::Char('a'),
KeyModifiers::CONTROL,
CrosstermKeyEventKind::Press,
),
&mut processor,
AttachUiMode::Normal,
)
.expect("attach key action should parse");
let actions = attach_key_event_actions(
&CrosstermKeyEvent::new_with_kind(
CrosstermKeyCode::Char('['),
KeyModifiers::NONE,
CrosstermKeyEventKind::Press,
),
&mut processor,
AttachUiMode::Normal,
)
.expect("attach key action should parse");
assert!(matches!(
actions.first(),
Some(AttachEventAction::Ui(
crate::input::RuntimeAction::EnterScrollMode
))
));
}
#[test]
fn attach_key_event_action_routes_alt_h_as_focus_left() {
let mut processor =
InputProcessor::new(attach_keymap_from_config(&BmuxConfig::default()), false);
let actions = attach_key_event_actions(
&CrosstermKeyEvent::new_with_kind(
CrosstermKeyCode::Char('h'),
KeyModifiers::ALT,
CrosstermKeyEventKind::Press,
),
&mut processor,
AttachUiMode::Normal,
)
.expect("attach key action should parse");
assert!(matches!(
actions.first(),
Some(AttachEventAction::PluginCommand { plugin_id, command_name, args })
if plugin_id == "bmux.tabs"
&& command_name == "focus-pane-in-direction"
&& args == &["--direction".to_string(), "left".to_string()]
));
}
#[test]
fn attach_key_event_action_routes_n_to_pane_in_normal_mode() {
let mut processor = normal_mode_input_processor();
let normal_actions = attach_key_event_actions(
&CrosstermKeyEvent::new_with_kind(
CrosstermKeyCode::Char('n'),
KeyModifiers::NONE,
CrosstermKeyEventKind::Press,
),
&mut processor,
AttachUiMode::Normal,
)
.expect("attach key action should parse");
assert!(normal_actions.is_empty());
}
#[test]
fn attach_keybindings_allow_global_override_of_default_session_key() {
let mut config = BmuxConfig::default();
config.keybindings.global.insert(
"ctrl+t".to_string(),
"plugin:bmux.sessions:new-session".to_string(),
);
let mut processor = InputProcessor::new(attach_keymap_from_config(&config), false);
let actions = attach_key_event_actions(
&CrosstermKeyEvent::new_with_kind(
CrosstermKeyCode::Char('t'),
KeyModifiers::CONTROL,
CrosstermKeyEventKind::Press,
),
&mut processor,
AttachUiMode::Normal,
)
.expect("attach key action should parse");
assert!(matches!(
actions.first(),
Some(AttachEventAction::PluginCommand {
plugin_id,
command_name,
..
}) if plugin_id == "bmux.sessions" && command_name == "new-session"
));
}
#[test]
fn adjust_attach_scrollback_offset_clamps_within_bounds() {
assert_eq!(adjust_attach_scrollback_offset(0, -1, 4), 1);
assert_eq!(adjust_attach_scrollback_offset(3, -10, 4), 4);
assert_eq!(adjust_attach_scrollback_offset(4, 1, 4), 3);
assert_eq!(adjust_attach_scrollback_offset(1, 50, 4), 0);
}
#[test]
fn adjust_scrollback_cursor_component_clamps_within_bounds() {
assert_eq!(adjust_scrollback_cursor_component(0, -1, 5), 0);
assert_eq!(adjust_scrollback_cursor_component(2, -1, 5), 1);
assert_eq!(adjust_scrollback_cursor_component(2, 10, 5), 5);
}
#[test]
fn enter_attach_scrollback_initializes_cursor_from_live_position() {
let mut view_state = attach_view_state_with_scrollback_fixture();
assert!(enter_attach_scrollback(&mut view_state));
assert!(view_state.scrollback_active());
assert_eq!(test_scrollback_offset(&view_state), 0);
assert_eq!(
test_scrollback_cursor(&view_state),
Some(AttachScrollbackCursor { row: 3, col: 2 })
);
}
#[test]
fn move_attach_scrollback_cursor_vertical_scrolls_at_viewport_edges() {
let mut view_state = attach_view_state_with_scrollback_fixture();
assert!(enter_attach_scrollback(&mut view_state));
move_attach_scrollback_cursor_vertical(&mut view_state, -1);
assert_eq!(
test_scrollback_cursor(&view_state),
Some(AttachScrollbackCursor { row: 2, col: 2 })
);
assert_eq!(test_scrollback_offset(&view_state), 0);
move_attach_scrollback_cursor_vertical(&mut view_state, -3);
assert_eq!(
test_scrollback_cursor(&view_state),
Some(AttachScrollbackCursor { row: 0, col: 2 })
);
assert_eq!(test_scrollback_offset(&view_state), 1);
move_attach_scrollback_cursor_vertical(&mut view_state, 1);
assert_eq!(
test_scrollback_cursor(&view_state),
Some(AttachScrollbackCursor { row: 1, col: 2 })
);
assert_eq!(test_scrollback_offset(&view_state), 1);
}
#[test]
fn move_attach_scrollback_cursor_horizontal_updates_column() {
let mut view_state = attach_view_state_with_scrollback_fixture();
assert!(enter_attach_scrollback(&mut view_state));
move_attach_scrollback_cursor_horizontal(&mut view_state, 3);
assert_eq!(
test_scrollback_cursor(&view_state),
Some(AttachScrollbackCursor { row: 3, col: 5 })
);
move_attach_scrollback_cursor_horizontal(&mut view_state, -10);
assert_eq!(
test_scrollback_cursor(&view_state),
Some(AttachScrollbackCursor { row: 3, col: 0 })
);
}
#[test]
fn auto_unfreeze_selects_only_expired_pins_and_zero_disables_it() {
let (mut view_state, left_pane, right_pane) = attach_view_state_with_two_panes_fixture();
assert!(enter_attach_scrollback_for(&mut view_state, left_pane));
assert!(enter_attach_scrollback_for(&mut view_state, right_pane));
let now = 100_u64;
if let Some(view) = view_state.scrollback_for_mut(left_pane) {
view.pin = Some(ScrollbackPin {
capture: None,
pin_id: 1,
total_scrolled_rows: 10,
max_scrollback_offset: 8,
stream_end: 42,
created_epoch_secs: now - 10,
});
}
if let Some(view) = view_state.scrollback_for_mut(right_pane) {
view.pin = Some(ScrollbackPin {
capture: None,
pin_id: 2,
total_scrolled_rows: 10,
max_scrollback_offset: 8,
stream_end: 42,
created_epoch_secs: now - 2,
});
}
assert!(expired_scrollback_pins(&view_state, now, 0).is_empty());
assert_eq!(
expired_scrollback_pins(&view_state, now, 5)
.into_iter()
.map(|(pane_id, _)| pane_id)
.collect::<Vec<_>>(),
vec![left_pane]
);
}
#[test]
fn output_batches_exclude_only_frozen_panes() {
let (mut view_state, left_pane, right_pane) = attach_view_state_with_two_panes_fixture();
assert!(enter_attach_scrollback_for(&mut view_state, left_pane));
if let Some(view) = view_state.scrollback_for_mut(left_pane) {
view.pin = Some(ScrollbackPin {
capture: None,
pin_id: 1,
total_scrolled_rows: 10,
max_scrollback_offset: 8,
stream_end: 42,
created_epoch_secs: 100,
});
}
let scene = &view_state
.cached_layout_state
.as_ref()
.expect("layout state")
.scene;
assert_eq!(output_batch_pane_ids(scene, &view_state), vec![right_pane]);
}
#[test]
fn pane_rules_use_and_semantics_and_first_match_wins() {
let metadata = crate::pane_runtime_client::PaneScrollbackRuleMetadata {
name: Some("logs-main".to_string()),
shell: "/bin/fish".to_string(),
active_command: Some("journalctl -f".to_string()),
};
let rules = vec![
bmux_config::ScrollbackPaneRule {
match_command: Some("journalctl*".to_string()),
match_name: Some("other*".to_string()),
match_shell: None,
mode: bmux_config::ScrollbackMode::Live,
},
bmux_config::ScrollbackPaneRule {
match_command: Some("journalctl*".to_string()),
match_name: Some("logs*".to_string()),
match_shell: Some("*/fish".to_string()),
mode: bmux_config::ScrollbackMode::Frozen,
},
bmux_config::ScrollbackPaneRule {
match_command: None,
match_name: None,
match_shell: None,
mode: bmux_config::ScrollbackMode::Live,
},
];
assert_eq!(
scrollback_rule_mode(&rules, &metadata),
Some(bmux_config::ScrollbackMode::Frozen)
);
}
#[test]
fn wildcard_matching_supports_star_and_question_mark() {
assert!(wildcard_matches("journal*", "journalctl -f"));
assert!(wildcard_matches("logs-?", "logs-a"));
assert!(!wildcard_matches("logs-?", "logs-main"));
}
#[test]
fn begin_attach_selection_anchors_absolute_history_line() {
let mut view_state = attach_view_state_with_scrollback_fixture();
assert!(enter_attach_scrollback(&mut view_state));
test_set_scrollback_offset(&mut view_state, 1);
assert!(begin_attach_selection(&mut view_state));
assert_eq!(
test_selection_anchor(&view_state),
Some(AttachScrollbackPosition { line: 3, col: 2 })
);
}
#[test]
fn clear_attach_selection_removes_anchor() {
let mut view_state = attach_view_state_with_scrollback_fixture();
assert!(enter_attach_scrollback(&mut view_state));
assert!(begin_attach_selection(&mut view_state));
clear_attach_selection_at(&mut view_state, false, Instant::now());
assert_eq!(test_selection_anchor(&view_state), None);
}
#[test]
fn selected_attach_text_extracts_multiline_range() {
let mut view_state = attach_view_state_with_scrollback_fixture();
assert!(enter_attach_scrollback(&mut view_state));
if let Some(view) = view_state.focused_scrollback_mut() {
view.selection_anchor = Some(AttachScrollbackPosition { line: 2, col: 2 });
view.cursor = AttachScrollbackCursor { row: 2, col: 4 };
view.offset = 0;
}
assert_eq!(
selected_attach_text(&mut view_state),
Some(" five\n six".to_string())
);
}
#[test]
fn selected_attach_text_copies_bottom_viewport_row_not_rows_above() {
let mut view_state = attach_view_state_with_scrollback_fixture();
assert!(enter_attach_scrollback(&mut view_state));
if let Some(view) = view_state.focused_scrollback_mut() {
view.selection_anchor = Some(AttachScrollbackPosition { line: 3, col: 0 });
view.cursor = AttachScrollbackCursor { row: 2, col: 4 };
view.offset = 0;
}
assert_eq!(
selected_attach_text(&mut view_state),
Some(" six".to_string())
);
}
#[test]
fn selected_attach_text_is_stable_across_scrollback_steps() {
let mut view_state = attach_view_state_with_scrollback_fixture();
assert!(enter_attach_scrollback(&mut view_state));
if let Some(view) = view_state.focused_scrollback_mut() {
view.selection_anchor = Some(AttachScrollbackPosition { line: 2, col: 0 });
view.cursor = AttachScrollbackCursor { row: 1, col: 8 };
view.offset = 0;
}
let before = selected_attach_text(&mut view_state);
assert_eq!(before, Some(" five".to_string()));
step_attach_scrollback(&mut view_state, -1);
assert_eq!(test_scrollback_offset(&view_state), 0);
assert_eq!(
view_state.requested_scroll_offset(view_state.focused_pane_id().unwrap()),
Some(1)
);
assert_eq!(selected_attach_text(&mut view_state), before);
}
#[test]
fn publishing_first_scrollback_window_rebases_selection_only_once() {
let mut state = attach_view_state_with_scrollback_fixture();
let pane = focused_attach_pane_id(&state).unwrap();
assert!(enter_attach_scrollback(&mut state));
let base =
state.pane_buffers[&pane].scrollback_viewport_base(state.scrollback_for(pane).as_ref());
let view = state.scrollback_for_mut(pane).unwrap();
view.selection_anchor = Some(AttachScrollbackPosition {
line: base.line_for_viewport_row(1),
col: 2,
});
view.cursor = AttachScrollbackCursor { row: 2, col: 4 };
let window = || PaneScrollbackWindow {
images: Vec::new(),
projection_width: 0,
row_anchors: Vec::new(),
palette: bmux_terminal_grid::StylePalette::default(),
scrollback_offset: 3,
max_scrollback_offset: 100,
total_scrolled_rows: 100,
rows: Vec::new(),
};
publish_scrollback_window(&mut state, pane, window());
let expected = bmux_attach_pipeline::ScrollbackViewportBase::from_scrolled_rows(100, 3)
.line_for_viewport_row(1);
assert_eq!(
state.scrollback_for(pane).unwrap().selection_anchor,
Some(AttachScrollbackPosition {
line: expected,
col: 2
})
);
publish_scrollback_window(&mut state, pane, window());
assert_eq!(
state.scrollback_for(pane).unwrap().selection_anchor,
Some(AttachScrollbackPosition {
line: expected,
col: 2
})
);
}
#[test]
fn wheel_exit_and_reentry_discard_previous_capture_projection() {
let mut state = attach_view_state_with_scrollback_fixture();
let pane = state.focused_pane_id().unwrap();
state.mouse.config.scroll_scrollback = true;
state.mouse.config.exit_scrollback_on_bottom = true;
state.mouse.config.scroll_lines_per_tick = 1;
assert!(enter_attach_scrollback(&mut state));
let capture = Uuid::new_v4();
publish_scrollback_window(
&mut state,
pane,
PaneScrollbackWindow {
images: Vec::new(),
projection_width: 80,
row_anchors: vec![bmux_attach_pipeline::CapturedHistoryAnchor {
capture_id: capture,
line_index: 0,
column: 0,
}],
palette: bmux_terminal_grid::StylePalette::default(),
scrollback_offset: 1,
max_scrollback_offset: 100,
total_scrolled_rows: 100,
rows: vec![bmux_terminal_grid::PhysicalRow::default()],
},
);
assert!(handle_attach_mouse_scrollback_for(
&mut state,
pane,
MouseEventKind::ScrollDown
));
assert!(!state.scrollback_active_for(pane));
assert!(state.pane_buffers[&pane].scrollback_window.is_none());
assert!(state.scrollback_cache.get(pane, None, 1, 80, 1).is_none());
assert!(handle_attach_mouse_scrollback_for(
&mut state,
pane,
MouseEventKind::ScrollUp
));
assert!(state.scrollback_active_for(pane));
assert!(state.pane_buffers[&pane].scrollback_window.is_none());
assert_eq!(state.requested_scroll_offset(pane), Some(1));
}
#[test]
fn selected_attach_text_reads_server_scrollback_window_rows() {
let mut view_state = attach_view_state_with_scrollback_fixture();
let pane_id = focused_attach_pane_id(&view_state).expect("focused pane");
assert!(enter_attach_scrollback(&mut view_state));
let window_rows = {
let mut source = PaneRenderBuffer {
terminal_grid: bmux_terminal_grid::TerminalGridStream::new(
20,
4,
bmux_terminal_grid::GridLimits::default(),
)
.expect("test grid dimensions are valid"),
..PaneRenderBuffer::default()
};
append_pane_output(&mut source, b"WINDOW-A\r\nWINDOW-B\r\n");
source.terminal_grid.grid().display_rows(0, 4)
};
if let Some(view) = view_state.scrollback_for_mut(pane_id) {
view.offset = 3;
view.cursor = AttachScrollbackCursor { row: 1, col: 7 };
}
if let Some(buffer) = view_state.pane_buffers.get_mut(&pane_id) {
buffer.scrollback_window = Some(PaneScrollbackWindow {
images: Vec::new(),
projection_width: 0,
row_anchors: Vec::new(),
palette: bmux_terminal_grid::StylePalette::default(),
scrollback_offset: 3,
max_scrollback_offset: 9,
total_scrolled_rows: 12,
rows: window_rows,
});
}
if let Some(view) = view_state.scrollback_for_mut(pane_id) {
view.selection_anchor = Some(AttachScrollbackPosition { line: 10, col: 0 });
}
assert_eq!(
selected_attach_text(&mut view_state),
Some("WINDOW-B".to_string())
);
}
#[test]
fn padded_content_rect_controls_scrollback_viewport_and_copy_selection() {
let mut view_state = attach_view_state_with_scrollback_fixture();
let pane_id = focused_attach_pane_id(&view_state).expect("focused pane");
let surface = view_state
.cached_layout_state
.as_mut()
.expect("layout")
.scene
.surfaces
.first_mut()
.expect("pane surface");
surface.rect = AttachRect {
x: 0,
y: 0,
w: 30,
h: 12,
};
surface.content_rect = AttachRect {
x: 8,
y: 3,
w: 7,
h: 4,
};
assert_eq!(focused_attach_pane_inner_size(&view_state), Some((7, 4)));
assert!(enter_attach_scrollback(&mut view_state));
let base = attach_scrollback_viewport_base(&view_state, pane_id).expect("scrollback base");
if let Some(view) = view_state.scrollback_for_mut(pane_id) {
view.offset = 0;
view.selection_anchor = Some(AttachScrollbackPosition {
line: base.line_for_viewport_row(0),
col: 0,
});
view.cursor = AttachScrollbackCursor { row: 0, col: 2 };
}
assert_eq!(
selected_attach_text(&mut view_state),
Some(" f".to_string())
);
}
#[test]
fn selected_attach_text_matches_rendered_selection_highlight() {
for (anchor_row, anchor_col, head_row, head_col) in [
(0_usize, 0_usize, 0_usize, 3_usize),
(0, 2, 1, 4),
(1, 0, 2, 4),
(2, 1, 2, 3),
(0, 0, 2, 4),
] {
let mut view_state = attach_view_state_with_scrollback_fixture();
let pane_id = focused_attach_pane_id(&view_state).expect("focused pane");
assert!(enter_attach_scrollback(&mut view_state));
let base = attach_scrollback_viewport_base(&view_state, pane_id).expect("base");
if let Some(view) = view_state.scrollback_for_mut(pane_id) {
view.offset = 0;
view.selection_anchor = Some(AttachScrollbackPosition {
line: base.line_for_viewport_row(anchor_row),
col: anchor_col,
});
view.cursor = AttachScrollbackCursor {
row: head_row,
col: head_col,
};
}
let (start, end) = attach_selection_bounds(&view_state).expect("selection bounds");
let copied = selected_attach_text(&mut view_state).expect("selected text");
let (rows, base) = {
let view = view_state.scrollback_for(pane_id);
let buffer = view_state
.pane_buffers
.get(&pane_id)
.expect("pane render buffer");
buffer.scrollback_render_window(view.as_ref(), 4)
};
let width = 20_usize;
let mut expected_lines = Vec::new();
for (row_index, row) in rows.iter().enumerate() {
let line = base.line_for_viewport_row(row_index);
if line < start.line || line > end.line {
continue;
}
let start_col = if line == start.line { start.col } else { 0 };
let end_col = if line == end.line {
end.col.saturating_add(1)
} else {
width
};
expected_lines.push(
grid_row_text_range(row, width, start_col, end_col)
.trim_end()
.to_string(),
);
}
assert_eq!(
copied,
expected_lines.join("\n"),
"copied text must match highlighted cells for \
anchor ({anchor_row},{anchor_col}) head ({head_row},{head_col})"
);
}
}
#[test]
fn selected_attach_text_joins_soft_wrapped_rows_into_logical_lines() {
let mut view_state = attach_view_state_with_scrollback_fixture();
let pane_id = focused_attach_pane_id(&view_state).expect("focused pane");
{
let buffer = view_state
.pane_buffers
.get_mut(&pane_id)
.expect("pane render buffer");
append_pane_output(
buffer,
b"\x1b[2J\x1b[H0123456789abcdefghijABCDEFGHIJklmnopqrstUVWXY\r\nnext",
);
}
assert!(enter_attach_scrollback(&mut view_state));
let base = attach_scrollback_viewport_base(&view_state, pane_id).expect("base");
let (rows, _) = {
let view = view_state.scrollback_for(pane_id);
let buffer = view_state
.pane_buffers
.get(&pane_id)
.expect("pane render buffer");
buffer.scrollback_render_window(view.as_ref(), 4)
};
assert!(rows[0].wrapped(), "first physical row soft-wraps");
assert!(rows[1].wrapped(), "second physical row soft-wraps");
assert!(!rows[2].wrapped(), "third row ends with a hard newline");
if let Some(view) = view_state.scrollback_for_mut(pane_id) {
view.offset = 0;
view.selection_anchor = Some(AttachScrollbackPosition {
line: base.line_for_viewport_row(0),
col: 0,
});
view.cursor = AttachScrollbackCursor { row: 3, col: 3 };
}
assert_eq!(
selected_attach_text(&mut view_state),
Some("0123456789abcdefghijABCDEFGHIJklmnopqrstUVWXY\nnext".to_string())
);
if let Some(view) = view_state.scrollback_for_mut(pane_id) {
view.selection_anchor = Some(AttachScrollbackPosition {
line: base.line_for_viewport_row(0),
col: 15,
});
view.cursor = AttachScrollbackCursor { row: 1, col: 4 };
}
assert_eq!(
selected_attach_text(&mut view_state),
Some("fghijABCDE".to_string())
);
}
#[test]
fn resize_preserves_pinned_selection_but_invalidates_live_selection() {
let mut state = attach_view_state_with_scrollback_fixture();
let pane = focused_attach_pane_id(&state).unwrap();
assert!(enter_attach_scrollback(&mut state));
let endpoint = AttachScrollbackPosition { line: 2, col: 1 };
let view = state.scrollback_for_mut(pane).unwrap();
view.selection_anchor = Some(endpoint);
view.pin = Some(bmux_attach_pipeline::ScrollbackPin {
capture: None,
pin_id: 1,
total_scrolled_rows: 10,
max_scrollback_offset: 10,
stream_end: 0,
created_epoch_secs: 0,
});
prepare_attach_selections_for_resize(&mut state);
assert_eq!(
state.scrollback_for(pane).unwrap().selection_anchor,
Some(endpoint)
);
state.scrollback_for_mut(pane).unwrap().pin = None;
prepare_attach_selections_for_resize(&mut state);
assert!(
state
.scrollback_for(pane)
.unwrap()
.selection_anchor
.is_none()
);
}
#[test]
fn clear_all_attach_selection_anchors_drops_every_pane_anchor() {
let (mut view_state, left_pane, right_pane) = attach_view_state_with_two_panes_fixture();
assert!(enter_attach_scrollback_for(&mut view_state, left_pane));
assert!(enter_attach_scrollback_for(&mut view_state, right_pane));
assert!(begin_attach_selection_for(&mut view_state, left_pane));
assert!(begin_attach_selection_for(&mut view_state, right_pane));
prepare_attach_selections_for_resize(&mut view_state);
assert!(view_state.scrollback_active_for(left_pane));
assert!(view_state.scrollback_active_for(right_pane));
assert!(!view_state.selection_active_for(left_pane));
assert!(!view_state.selection_active_for(right_pane));
}
#[test]
fn confirm_attach_scrollback_exits_when_no_selection() {
let mut view_state = attach_view_state_with_scrollback_fixture();
assert!(enter_attach_scrollback(&mut view_state));
confirm_attach_scrollback_at(&mut view_state, Instant::now());
assert!(!view_state.scrollback_active());
}
#[test]
fn manual_alternate_copy_retains_screen_while_live_output_changes() {
let mut state = attach_view_state_with_scrollback_fixture();
let pane = state.focused_pane_id().unwrap();
state
.pane_buffers
.get_mut(&pane)
.unwrap()
.terminal_grid
.process(b"\x1b[?1049h\x1b[2J\x1b[HALT-COPY");
assert!(enter_manual_copy_mode(&mut state));
assert!(state.captured_alternate.contains(&pane));
assert_eq!(max_attach_scrollback_for(&mut state, pane), 0);
let before = state.pane_buffers[&pane]
.scrollback_window
.as_ref()
.unwrap()
.rows
.clone();
assert!(
before
.iter()
.any(|row| bmux_terminal_grid::row_text(row, 80).contains("ALT-COPY"))
);
state
.pane_buffers
.get_mut(&pane)
.unwrap()
.terminal_grid
.process(b"\x1b[HCHANGED\x1b[?1049l");
assert_eq!(
state.pane_buffers[&pane]
.scrollback_window
.as_ref()
.unwrap()
.rows,
before
);
let base =
state.pane_buffers[&pane].scrollback_viewport_base(state.scrollback_for(pane).as_ref());
let view = state.scrollback_for_mut(pane).unwrap();
view.selection_anchor = Some(AttachScrollbackPosition {
line: base.line_for_viewport_row(0),
col: 0,
});
view.cursor = AttachScrollbackCursor { row: 0, col: 7 };
assert_eq!(
selected_attach_text(&mut state).as_deref(),
Some("ALT-COPY")
);
assert!(state.exit_scrollback_for(pane));
assert!(!state.pane_buffers[&pane].retained_screen_copy);
assert!(!state.captured_alternate.contains(&pane));
}
#[test]
fn mouse_scroll_up_enters_scrollback_and_steps_by_configured_lines() {
let mut view_state = attach_view_state_with_scrollback_fixture();
view_state.mouse.config.scroll_lines_per_tick = 1;
view_state.mouse.config.scroll_scrollback = true;
assert!(handle_attach_mouse_scrollback(
&mut view_state,
MouseEventKind::ScrollUp,
));
assert!(view_state.scrollback_active());
assert_eq!(test_scrollback_offset(&view_state), 0);
assert_eq!(
view_state.requested_scroll_offset(view_state.focused_pane_id().unwrap()),
Some(1)
);
}
#[test]
fn mouse_scroll_down_exits_scrollback_at_bottom_when_enabled() {
let mut view_state = attach_view_state_with_scrollback_fixture();
view_state.mouse.config.scroll_lines_per_tick = 1;
view_state.mouse.config.scroll_scrollback = true;
view_state.mouse.config.exit_scrollback_on_bottom = true;
assert!(enter_attach_scrollback(&mut view_state));
test_set_scrollback_offset(&mut view_state, 1);
assert!(handle_attach_mouse_scrollback(
&mut view_state,
MouseEventKind::ScrollDown,
));
assert!(!view_state.scrollback_active());
assert_eq!(test_scrollback_offset(&view_state), 0);
}
#[test]
fn focused_attach_pane_inner_size_reads_content_rect_not_outer_rect() {
let session_id = Uuid::new_v4();
let pane_id = Uuid::new_v4();
let mut view_state = AttachViewState::new(AttachOpenInfo {
context_id: None,
session_id,
can_write: true,
});
view_state.cached_layout_state = Some(AttachLayoutState {
context_id: None,
session_id,
focused_pane_id: pane_id,
panes: vec![PaneSummary {
id: pane_id,
index: 1,
name: None,
focused: true,
state: PaneState::Running,
state_reason: None,
}],
layout_root: PaneLayoutNode::Leaf { pane_id },
scene: AttachScene {
session_id,
focus: AttachFocusTarget::Pane { pane_id },
surfaces: vec![AttachSurface {
id: Uuid::new_v4(),
kind: AttachSurfaceKind::Pane,
layer: SurfaceLayer::Pane,
z: 0,
pane_id: Some(pane_id),
rect: AttachRect {
x: 0,
y: 0,
w: 20,
h: 10,
},
content_rect: AttachRect {
x: 2,
y: 3,
w: 15,
h: 4,
},
interactive_regions: Vec::new(),
opaque: true,
visible: true,
accepts_input: true,
cursor_owner: true,
}],
},
zoomed: false,
});
assert_eq!(
focused_attach_pane_inner_size(&view_state),
Some((15, 4)),
"inner size must equal the scene's content_rect dims, not rect - 2"
);
}
#[test]
fn attach_keybindings_keep_focus_next_pane_binding() {
let (runtime, _global, _scroll) = filtered_attach_keybindings(&BmuxConfig::default());
assert_eq!(
runtime.get("o"),
Some(&"plugin:bmux.tabs:focus-pane-in-direction --direction next".to_string())
);
}
#[test]
fn attach_key_event_action_maps_show_help_to_ui() {
let mut processor = normal_mode_input_processor();
let _ = attach_key_event_actions(
&CrosstermKeyEvent::new_with_kind(
CrosstermKeyCode::Char('a'),
KeyModifiers::CONTROL,
CrosstermKeyEventKind::Press,
),
&mut processor,
AttachUiMode::Normal,
)
.expect("attach key action should parse");
let help_question = attach_key_event_actions(
&CrosstermKeyEvent::new_with_kind(
CrosstermKeyCode::Char('?'),
KeyModifiers::NONE,
CrosstermKeyEventKind::Press,
),
&mut processor,
AttachUiMode::Normal,
)
.expect("attach key action should parse");
let _ = attach_key_event_actions(
&CrosstermKeyEvent::new_with_kind(
CrosstermKeyCode::Char('a'),
KeyModifiers::CONTROL,
CrosstermKeyEventKind::Press,
),
&mut processor,
AttachUiMode::Normal,
)
.expect("attach key action should parse");
let help_shift_slash = attach_key_event_actions(
&CrosstermKeyEvent::new_with_kind(
CrosstermKeyCode::Char('/'),
KeyModifiers::SHIFT,
CrosstermKeyEventKind::Press,
),
&mut processor,
AttachUiMode::Normal,
)
.expect("attach key action should parse");
assert!(matches!(
help_question.first().or_else(|| help_shift_slash.first()),
Some(AttachEventAction::Ui(crate::input::RuntimeAction::ShowHelp))
));
}
#[test]
fn effective_attach_keybindings_include_scope_and_canonical_action_names() {
let entries = effective_attach_keybindings(&BmuxConfig::default());
assert!(entries.iter().any(|entry| {
entry.scope == AttachKeybindingScope::Runtime
&& entry.chord == "o"
&& entry.action_name == "plugin:bmux.tabs:focus-pane-in-direction --direction next"
&& entry.action == focus_action("next")
}));
assert!(entries.iter().any(|entry| {
entry.scope == AttachKeybindingScope::Global
&& entry.chord == "alt+h"
&& entry.action_name == "plugin:bmux.tabs:focus-pane-in-direction --direction left"
&& entry.action == focus_action("left")
}));
}
#[test]
fn help_overlay_render_ops_match_component_golden() {
let surface = help_overlay_surface(
&[
"scope chord action".to_owned(),
"normal Ctrl-A Help".to_owned(),
],
TerminalGeometry { cols: 48, rows: 12 },
)
.expect("help surface");
let ops = help_overlay_render_ops(
&surface,
&[
"scope chord action".to_owned(),
"normal Ctrl-A Help".to_owned(),
],
0,
&RuntimeAppearance::default(),
);
let rows = ops
.iter()
.filter_map(|op| match op {
RenderOp::TextRun { y, text, .. } => Some((*y, text.clone())),
_ => None,
})
.fold(BTreeMap::<u16, String>::new(), |mut rows, (y, text)| {
rows.entry(y).or_default().push_str(&text);
rows
});
let top = rows.get(&surface.rect.y).expect("top row");
assert!(top.starts_with('â•') && top.ends_with('â•®'), "{top:?}");
assert!(
rows.values()
.any(|row| row.contains("scope chord action"))
);
assert!(rows.values().any(|row| row.contains("↑↓")));
}
#[test]
fn adjust_help_overlay_scroll_clamps_to_bounds() {
assert_eq!(adjust_help_overlay_scroll(0, -10, 20, 5), 0);
assert_eq!(adjust_help_overlay_scroll(0, 3, 20, 5), 3);
assert_eq!(adjust_help_overlay_scroll(17, 10, 20, 5), 15);
assert_eq!(adjust_help_overlay_scroll(4, -2, 20, 5), 2);
assert_eq!(adjust_help_overlay_scroll(0, 4, 0, 5), 0);
}
#[test]
fn help_overlay_repeat_navigation_is_handled() {
let mut view_state = AttachViewState::new(bmux_client::AttachOpenInfo {
context_id: None,
session_id: uuid::Uuid::new_v4(),
can_write: true,
});
view_state.help_overlay_open = true;
view_state.dirty.full_pane_redraw = false;
view_state.dirty.overlay_needs_redraw = false;
let lines = (0..200)
.map(|idx| format!("line {idx}"))
.collect::<Vec<_>>();
let handled = handle_help_overlay_key_event(
&CrosstermKeyEvent::new_with_kind(
CrosstermKeyCode::Down,
KeyModifiers::NONE,
CrosstermKeyEventKind::Repeat,
),
&lines,
&mut view_state,
TerminalGeometry { cols: 80, rows: 24 },
);
assert!(handled);
assert!(view_state.help_overlay_scroll > 0);
assert!(view_state.dirty.overlay_needs_redraw);
assert!(!view_state.dirty.full_pane_redraw);
}
#[test]
fn help_overlay_release_is_ignored() {
let mut view_state = AttachViewState::new(bmux_client::AttachOpenInfo {
context_id: None,
session_id: uuid::Uuid::new_v4(),
can_write: true,
});
view_state.help_overlay_open = true;
view_state.help_overlay_scroll = 5;
let lines = (0..200)
.map(|idx| format!("line {idx}"))
.collect::<Vec<_>>();
let handled = handle_help_overlay_key_event(
&CrosstermKeyEvent::new_with_kind(
CrosstermKeyCode::Down,
KeyModifiers::NONE,
CrosstermKeyEventKind::Release,
),
&lines,
&mut view_state,
TerminalGeometry { cols: 80, rows: 24 },
);
assert!(!handled);
assert_eq!(view_state.help_overlay_scroll, 5);
}
#[test]
fn help_overlay_close_marks_overlay_redraw_without_full_pane_redraw() {
let mut view_state = AttachViewState::new(bmux_client::AttachOpenInfo {
context_id: None,
session_id: uuid::Uuid::new_v4(),
can_write: true,
});
view_state.help_overlay_open = true;
view_state.help_overlay_scroll = 3;
view_state.dirty.full_pane_redraw = false;
view_state.dirty.overlay_needs_redraw = false;
let handled = handle_help_overlay_key_event(
&CrosstermKeyEvent::new_with_kind(
CrosstermKeyCode::Esc,
KeyModifiers::NONE,
CrosstermKeyEventKind::Press,
),
&[],
&mut view_state,
TerminalGeometry { cols: 80, rows: 24 },
);
assert!(handled);
assert!(!view_state.help_overlay_open);
assert_eq!(view_state.help_overlay_scroll, 0);
assert!(view_state.dirty.overlay_needs_redraw);
assert!(!view_state.dirty.full_pane_redraw);
}
#[test]
fn retained_help_overlay_surface_uses_render_ops_payload() {
let surface = AttachSurface {
id: HELP_OVERLAY_SURFACE_ID,
kind: AttachSurfaceKind::Overlay,
layer: SurfaceLayer::Overlay,
z: i32::MAX,
rect: AttachRect {
x: 2,
y: 3,
w: 20,
h: 6,
},
content_rect: AttachRect {
x: 2,
y: 3,
w: 20,
h: 6,
},
interactive_regions: Vec::new(),
opaque: true,
visible: true,
accepts_input: true,
cursor_owner: true,
pane_id: None,
};
let retained = retained_help_overlay_surface(
&surface,
&["line".to_owned()],
0,
&RuntimeAppearance::default(),
);
assert_eq!(retained.id, HELP_OVERLAY_SURFACE_ID);
assert_eq!(retained.rect, DamageRect::new(2, 3, 20, 6));
assert_eq!(retained.layer, retained_layer_order(SurfaceLayer::Overlay));
let RetainedSurfacePayload::RenderOps(ops) = retained.payload else {
panic!("help overlay should lower to render ops");
};
assert!(ops.iter().any(|op| {
matches!(op, RenderOp::TextRun { text, .. } if text.contains('â•') || text.contains('│'))
}));
assert!(!ops.iter().any(|op| matches!(op, RenderOp::Border { .. })));
}
#[test]
fn retained_damage_overlay_surface_uses_transparent_render_ops_payload() {
let view_state = attach_view_state_with_scrollback_fixture();
let layout_state = view_state.cached_layout_state.expect("layout state");
let pane_id = layout_state.focused_pane_id;
let mut damage = bmux_attach_pipeline::FrameDamage::default();
damage.mark_content_surface_rect(
pane_id,
DamageRect::new(0, 0, 2, 1),
(10, 5),
DamageCoalescingPolicy::default(),
);
let surface = retained_damage_overlay_surface(&layout_state.scene, &damage, (80, 24), 0, 1)
.expect("damage overlay surface");
assert_eq!(surface.id, DAMAGE_OVERLAY_SURFACE_ID);
assert_eq!(surface.opacity, RetainedOpacity::Transparent);
let RetainedSurfacePayload::RenderOps(ops) = surface.payload else {
panic!("damage overlay should lower to render ops");
};
assert!(ops.iter().any(|op| matches!(op, RenderOp::Border { .. })));
}
#[test]
fn frame_uses_synchronized_update_only_for_scene_or_overlay_damage() {
let mut overlay = bmux_attach_pipeline::FrameDamage::default();
overlay.mark_overlay();
assert!(frame_uses_synchronized_update(&overlay));
let mut content = bmux_attach_pipeline::FrameDamage::default();
content.mark_content_surface(Uuid::from_u128(1));
assert!(frame_uses_synchronized_update(&content));
}
#[test]
fn build_attach_help_lines_groups_entries_by_category() {
let lines = build_attach_help_lines(&BmuxConfig::default());
assert_eq!(lines.first().map(String::as_str), Some("Attach Help"));
assert!(lines[1].contains("Modal keybindings are active"));
assert!(
lines
.iter()
.any(|line| line.contains("restart an exited pane in place"))
);
assert!(
lines
.iter()
.any(|line| line.contains("opens a confirmation prompt before closing"))
);
assert!(lines.iter().any(|line| line == "-- Session --"));
assert!(lines.iter().any(|line| line == "-- Pane --"));
assert!(lines.iter().any(|line| line == "-- Mode --"));
}
#[test]
fn attach_exit_message_suppresses_normal_detach_and_formats_stream_close() {
assert_eq!(attach_exit_message(AttachExitReason::Detached), None);
assert_eq!(attach_exit_message(AttachExitReason::Quit), None);
assert_eq!(
attach_exit_message(AttachExitReason::StreamClosed),
Some("attach ended unexpectedly: server stream closed")
);
}
#[test]
fn resize_attach_grids_applies_layout_size_before_snapshot_bytes() {
let pane_id = uuid::Uuid::new_v4();
let scene = AttachScene {
session_id: uuid::Uuid::new_v4(),
focus: AttachFocusTarget::Pane { pane_id },
surfaces: vec![AttachSurface {
id: pane_id,
kind: AttachSurfaceKind::Pane,
layer: SurfaceLayer::Pane,
z: 0,
rect: AttachRect {
x: 0,
y: 1,
w: 120,
h: 49,
},
content_rect: AttachRect {
x: 1,
y: 2,
w: 118,
h: 47,
},
interactive_regions: Vec::new(),
opaque: true,
visible: true,
accepts_input: true,
cursor_owner: true,
pane_id: Some(pane_id),
}],
};
let mut pane_buffers = BTreeMap::new();
pane_buffers.insert(pane_id, PaneRenderBuffer::default());
resize_attach_grids_for_scene_with_size(&mut pane_buffers, &scene, 120, 50);
let buffer = pane_buffers
.get_mut(&pane_id)
.expect("pane buffer should exist");
append_pane_output(&mut *buffer, b"\x1b[999;999H");
let cursor = buffer.terminal_grid.grid().cursor();
assert_eq!(
cursor.row, 46,
"cursor row should clamp to pane inner height"
);
assert_eq!(
cursor.col, 117,
"cursor col should clamp to pane inner width"
);
}
#[test]
fn keymap_compiles_when_user_config_uses_arrow_aliases() {
let mut config = BmuxConfig::default();
config
.keybindings
.runtime
.insert("shift+left".to_string(), "resize_left".to_string());
config
.keybindings
.runtime
.insert("left".to_string(), "focus_left_pane".to_string());
let _keymap = attach_keymap_from_config(&config);
}
#[test]
fn image_only_event_schedules_fetch_and_frame_for_its_attachment() {
let session_id = Uuid::new_v4();
let pane_id = Uuid::new_v4();
let mut view = AttachViewState::new(AttachOpenInfo {
context_id: None,
session_id,
can_write: true,
});
view.dirty.clear_frame_damage();
let mut output_pending = false;
let outcome = handle_pane_runtime_plugin_event(
pane_events::PaneEvent::ImageAvailable {
session_id,
pane_id,
},
&mut view,
&mut output_pending,
);
assert!(outcome.image_fetch_requested);
assert!(view.dirty.pane_dirty_ids.contains(&pane_id));
assert!(!output_pending);
view.dirty.clear_frame_damage();
let outcome = handle_pane_runtime_plugin_event(
pane_events::PaneEvent::ImageAvailable {
session_id: Uuid::new_v4(),
pane_id,
},
&mut view,
&mut output_pending,
);
assert!(!outcome.image_fetch_requested);
assert!(view.dirty.pane_dirty_ids.is_empty());
}
#[test]
fn sync_attach_active_mode_marks_full_frame_on_mode_change() {
let keymap = attach_keymap_from_config(&BmuxConfig::default());
let mut view_state = AttachViewState::new(AttachOpenInfo {
context_id: None,
session_id: Uuid::new_v4(),
can_write: true,
});
view_state.dirty.clear_frame_damage();
sync_attach_active_mode_from_processor(&mut view_state, &keymap, Some("inspect"));
assert_eq!(view_state.active_mode_id, "inspect");
assert!(view_state.dirty.full_pane_redraw);
}
#[test]
fn apply_attach_profile_switch_rolls_back_on_resolution_failure() {
let temp_path = std::env::temp_dir().join(format!(
"bmux-switch-profile-rollback-{}-{}.toml",
std::process::id(),
Uuid::new_v4()
));
let initial_config = r#"
[composition]
active_profile = "good"
layer_order = ["defaults", "profile:active", "config"]
[composition.profiles.good.patch.general]
server_timeout = 1234
"#;
std::fs::write(&temp_path, initial_config).expect("write temp config");
let mut processor =
InputProcessor::new(attach_keymap_from_config(&BmuxConfig::default()), false);
let mut view_state = AttachViewState::new(AttachOpenInfo {
context_id: None,
session_id: Uuid::new_v4(),
can_write: true,
});
let original_mode = processor.active_mode_id().map(ToString::to_string);
view_state.mouse.selection_drag = Some(AttachMouseSelectionDrag {
pane_id: Uuid::from_u128(2),
anchor: AttachScrollbackPosition { line: 0, col: 0 },
active: false,
});
let error = apply_attach_profile_switch_with_path(
"missing_profile",
&mut processor,
&mut view_state,
&temp_path,
)
.expect_err("missing profile should fail and rollback");
assert!(error.to_string().contains("rolled back profile switch"));
let after = std::fs::read_to_string(&temp_path).expect("read temp config");
assert_eq!(after, initial_config);
assert_eq!(processor.active_mode_id(), original_mode.as_deref());
assert_eq!(
view_state.mouse.pointer_owner(),
Some(AttachPointerOwner::Selection)
);
}
#[test]
fn apply_attach_profile_switch_cancels_pointer_owner_on_success() {
let temp_path = std::env::temp_dir().join(format!(
"bmux-switch-profile-success-{}-{}.toml",
std::process::id(),
Uuid::new_v4()
));
let config = r#"
[composition]
active_profile = "one"
layer_order = ["defaults", "profile:active", "config"]
[composition.profiles.one.patch.general]
server_timeout = 1234
[composition.profiles.two.patch.general]
server_timeout = 2345
"#;
std::fs::write(&temp_path, config).expect("write temp config");
let mut processor =
InputProcessor::new(attach_keymap_from_config(&BmuxConfig::default()), false);
let mut view_state = AttachViewState::new(AttachOpenInfo {
context_id: None,
session_id: Uuid::new_v4(),
can_write: true,
});
view_state.mouse.selection_drag = Some(AttachMouseSelectionDrag {
pane_id: Uuid::from_u128(2),
anchor: AttachScrollbackPosition { line: 0, col: 0 },
active: false,
});
apply_attach_profile_switch_with_path("two", &mut processor, &mut view_state, &temp_path)
.expect("profile switch should succeed");
assert_eq!(view_state.mouse.pointer_owner(), None);
let _ = std::fs::remove_file(temp_path);
}
}