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::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,
PaneScrollbackWindow, RetainedOpacity, RetainedRepaintSurface, RetainedSurface,
RetainedSurfacePayload, frame_damage_from_retained_repaint_plan, merge_retained_damages,
retained_damage_from_absolute_rects, retained_frame_damage_from_frame_damage,
retained_layer_order, retained_surfaces_from_attach_scene, update_protocol_hints_from_state,
};
use bmux_attach_view_protocol::AttachViewComponent;
use bmux_client::{
AttachLayoutState, AttachPaneSnapshotState, AttachSnapshotState, ClientError,
StreamingBmuxClient,
};
use bmux_config::{BmuxConfig, ConfigPaths, PaneRestoreMethod, ResolvedTimeout, StatusPosition};
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::{
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 crossterm::cursor::{Hide, MoveTo, SavePosition, Show};
use crossterm::event::{
DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers,
MouseButton, MouseEvent, MouseEventKind,
};
use crossterm::queue;
use crossterm::style::Print;
use crossterm::terminal;
use crossterm::terminal::{BeginSynchronizedUpdate, Clear, ClearType, EndSynchronizedUpdate};
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
use futures::StreamExt;
use std::collections::{BTreeMap, BTreeSet};
use std::future::Future;
use std::io::{self, Write};
use std::path::Path;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tracing::{debug, trace, warn};
use uuid::Uuid;
pub type AttachTerminalEventFuture<'a> =
Pin<Box<dyn Future<Output = Result<Option<Event>>> + Send + 'a>>;
pub trait AttachTerminal: Write {
fn geometry(&self) -> TerminalGeometry;
fn enter_attach_mode(
&mut self,
kitty_keyboard_enabled: bool,
mouse_capture_enabled: bool,
) -> Result<bool>;
fn next_event(&mut self) -> AttachTerminalEventFuture<'_>;
fn restore_after_attach_ui(&mut self) -> Result<()>;
#[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 {
stdout: io::Stdout,
event_stream: Option<crossterm::event::EventStream>,
raw_mode_guard: Option<RawModeGuard>,
}
impl RealAttachTerminal {
#[must_use]
pub fn new() -> Self {
Self {
stdout: io::stdout(),
event_stream: None,
raw_mode_guard: None,
}
}
}
impl Default for RealAttachTerminal {
fn default() -> Self {
Self::new()
}
}
impl Write for RealAttachTerminal {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.stdout.write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.stdout.flush()
}
}
#[derive(Clone)]
pub struct HeadlessAttachTerminalHandle {
event_tx: tokio::sync::mpsc::UnboundedSender<Event>,
geometry: Arc<Mutex<TerminalGeometry>>,
output: Arc<Mutex<Vec<u8>>>,
}
impl HeadlessAttachTerminalHandle {
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<()> {
*self
.geometry
.lock()
.map_err(|_| anyhow::anyhow!("headless attach terminal geometry lock poisoned"))? =
TerminalGeometry { cols, rows };
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.clone())
}
}
pub struct HeadlessAttachTerminal {
geometry: Arc<Mutex<TerminalGeometry>>,
event_rx: tokio::sync::mpsc::UnboundedReceiver<Event>,
output: Arc<Mutex<Vec<u8>>>,
}
impl HeadlessAttachTerminal {
#[must_use]
pub fn new(cols: u16, rows: u16) -> (Self, HeadlessAttachTerminalHandle) {
let (event_tx, event_rx) = tokio::sync::mpsc::unbounded_channel();
let geometry = Arc::new(Mutex::new(TerminalGeometry { cols, rows }));
let output = Arc::new(Mutex::new(Vec::new()));
(
Self {
geometry: Arc::clone(&geometry),
event_rx,
output: Arc::clone(&output),
},
HeadlessAttachTerminalHandle {
event_tx,
geometry,
output,
},
)
}
}
impl Write for HeadlessAttachTerminal {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.output
.lock()
.map_err(|_| io::Error::other("headless attach terminal output lock poisoned"))?
.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
impl AttachTerminal for HeadlessAttachTerminal {
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,
) -> 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(())
}
#[cfg(any(
feature = "image-sixel",
feature = "image-kitty",
feature = "image-iterm2"
))]
fn detect_image_capabilities(&mut self) -> bmux_image::host_caps::HostImageCapabilities {
bmux_image::host_caps::HostImageCapabilities::default()
}
}
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,
) -> Result<bool> {
let guard = RawModeGuard::enable(kitty_keyboard_enabled, mouse_capture_enabled)
.context("failed to enable raw mode for attach")?;
let keyboard_enhanced = guard.keyboard_enhanced;
self.raw_mode_guard = Some(guard);
self.event_stream = Some(crossterm::event::EventStream::new());
Ok(keyboard_enhanced)
}
fn next_event(&mut self) -> AttachTerminalEventFuture<'_> {
Box::pin(async move {
let Some(stream) = self.event_stream.as_mut() else {
anyhow::bail!("attach terminal event stream was not initialized");
};
match stream.next().await {
Some(result) => Ok(Some(result.context("failed reading terminal event")?)),
None => Ok(None),
}
})
}
fn restore_after_attach_ui(&mut self) -> Result<()> {
self.event_stream = None;
self.raw_mode_guard = None;
restore_terminal_after_attach_ui()
}
#[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;
caps
}
}
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, ATTACH_WELCOME_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, plugin_command_policy_hints, plugin_host_metadata,
recording, 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::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,
collect_visual_projection_updates, frame_damage_overlay_rects, frame_damage_overlay_render_ops,
opaque_row_text, queue_render_ops, render_attach_scene_with_stats_and_trace_with_capabilities,
visible_scene_pane_ids,
};
use super::state::{
AttachDirtyFlags, AttachDirtySource, AttachEventAction, AttachExitReason,
AttachInputHookCapture, AttachMouseFloatingDrag, AttachMouseResizeAxisDrag,
AttachMouseResizeDrag, AttachMouseSelectionDrag, AttachMouseTabDrag, AttachScrollbackCursor,
AttachScrollbackPosition, AttachTabDropPlacement, AttachTabDropTarget, AttachUiEffect,
AttachUiMode, AttachUiReduction, AttachViewState, PaneRenderBuffer,
};
use crate::pane_runtime_client::{
BmuxPaneRuntimeClientExt, PaneGridWindowRequest, StreamingAttachInputExt,
attach_pane_grid_delta_state_streaming, attach_pane_grid_snapshot_state_streaming,
attach_pane_grid_window_state_streaming,
};
use crate::status::{AttachStatusLine, AttachTab, build_attach_status_line};
use bmux_plugin::{BorderGlyphs, RenderDamage, RenderOp, RenderStyle};
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_TAB_DRAG_THRESHOLD_CELLS: u16 = 1;
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 STATUS_SURFACE_ID: Uuid = Uuid::from_u128(3);
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_windows_plugin_api::{windows_commands, windows_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) -> windows_commands::Selector {
match selector {
SessionSelector::ById(id) => windows_commands::Selector {
id: Some(id),
name: None,
index: None,
},
SessionSelector::ByName(name) => windows_commands::Selector {
id: None,
name: Some(name),
index: None,
},
}
}
#[must_use]
const fn pane_id_windows_selector(id: Uuid) -> windows_commands::Selector {
windows_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,
> {
let args = sessions_commands::client::KillSessionRequest {
selector: ipc_to_session_selector(selector),
force_local,
};
let payload = bmux_codec::to_vec(&args).map_err(|error| ClientError::ServerError {
code: bmux_ipc::ErrorCode::Internal,
message: format!("encoding kill-session args: {error}"),
})?;
let response_bytes = client
.invoke_service_raw(
sessions_commands::client::KillSessionEndpoint::CAPABILITY.as_str(),
sessions_commands::client::KillSessionEndpoint::KIND,
sessions_commands::client::KillSessionEndpoint::INTERFACE_ID.as_str(),
sessions_commands::client::KillSessionEndpoint::OPERATION.as_str(),
payload,
)
.await?;
bmux_codec::from_bytes(&response_bytes).map_err(|error| ClientError::ServerError {
code: bmux_ipc::ErrorCode::Internal,
message: format!("decoding kill-session response: {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<windows_state::WindowEntry>, ClientError> {
windows_state::client::list_windows(client, None)
.await
.map_err(|error| typed_client_error(&error))
}
async fn typed_active_runtime_appearance_attach(
client: &mut StreamingBmuxClient,
) -> std::result::Result<RuntimeAppearance, ClientError> {
let payload = bmux_codec::to_vec(&()).map_err(|error| ClientError::ServerError {
code: bmux_ipc::ErrorCode::Internal,
message: format!("encoding active-appearance args: {error}"),
})?;
let response_bytes = client
.invoke_service_raw(
"bmux.theme.read",
InvokeServiceKind::Query,
"theme-state",
"active-appearance",
payload,
)
.await?;
bmux_codec::from_bytes(&response_bytes).map_err(|error| ClientError::ServerError {
code: bmux_ipc::ErrorCode::Internal,
message: format!("decoding active runtime appearance response: {error}"),
})
}
const fn empty_decoration_scene() -> bmux_scene_protocol::scene_protocol::DecorationScene {
bmux_scene_protocol::scene_protocol::DecorationScene {
revision: 0,
surfaces: BTreeMap::new(),
animation: None,
input_hooks: Vec::new(),
visual_adapters: Vec::new(),
}
}
fn publish_decoration_scene_locally(scene: bmux_scene_protocol::scene_protocol::DecorationScene) {
if bmux_plugin::global_event_bus()
.publish_state(
&bmux_scene_protocol::scene_protocol::STATE_KIND,
scene.clone(),
)
.is_ok()
{
return;
}
let _ = bmux_plugin::global_event_bus()
.register_state_channel::<bmux_scene_protocol::scene_protocol::DecorationScene>(
bmux_scene_protocol::scene_protocol::STATE_KIND,
empty_decoration_scene(),
);
let _ = bmux_plugin::global_event_bus()
.publish_state(&bmux_scene_protocol::scene_protocol::STATE_KIND, scene);
}
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_codec::to_vec(args).map_err(|error| ClientError::ServerError {
code: bmux_ipc::ErrorCode::Internal,
message: format!("encoding {operation}: {error}"),
})?;
let response_bytes = client
.invoke_service_raw(
windows_commands::client::FocusPaneEndpoint::CAPABILITY.as_str(),
InvokeServiceKind::Command,
windows_commands::INTERFACE_ID.as_str(),
operation,
payload,
)
.await?;
bmux_codec::from_bytes::<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, recording::DisplayCaptureWriter>,
}
impl DisplayCaptureFanout {
fn open_target(&mut self, target: &RecordingCaptureTarget, client_id: Uuid) {
if self.writers.contains_key(&target.recording_id) {
return;
}
match recording::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_bytes(&mut self, data: &[u8]) {
let mut failed = Vec::new();
for (id, writer) in &mut self.writers {
if writer.record_frame_bytes(data).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 record_cursor_snapshot(&mut self, cursor_state: Option<super::state::AttachCursorState>) {
let mut failed = Vec::new();
for (id, writer) in &mut self.writers {
if writer.record_cursor_snapshot(cursor_state).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)]
#[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);
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,
WindowList,
}
#[derive(Debug, Clone, Copy)]
struct AttachDirtyReasons {
bits: u8,
}
impl AttachDirtyReasons {
const STATUS: u8 = 1 << 0;
const FULL_PANE: u8 = 1 << 1;
const OVERLAY: u8 = 1 << 2;
const PANE_DIRTY: u8 = 1 << 3;
const LAYOUT: u8 = 1 << 4;
const SCENE_HYDRATED: u8 = 1 << 5;
const EXTENSION: u8 = 1 << 6;
const PRECISE_DAMAGE: u8 = 1 << 7;
fn from_view_state(view_state: &AttachViewState, layout: bool, scene_hydrated: bool) -> Self {
let mut bits = 0;
if view_state.dirty.status_needs_redraw {
bits |= Self::STATUS;
}
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::STATUS) {
labels.push("status");
}
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,
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,
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,
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,
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.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.pane_rows_examined = self
.pane_rows_examined
.saturating_add(stats.scene_render.pane_rows_examined);
self.pane_rows_emitted = self
.pane_rows_emitted
.saturating_add(stats.scene_render.pane_rows_emitted);
self.pane_row_segments_emitted = self
.pane_row_segments_emitted
.saturating_add(stats.scene_render.pane_row_segments_emitted);
self.pane_rows_cached_skipped = self
.pane_rows_cached_skipped
.saturating_add(stats.scene_render.pane_rows_cached_skipped);
self.pane_rows_sync_deferred = self
.pane_rows_sync_deferred
.saturating_add(stats.scene_render.pane_rows_sync_deferred);
self.pane_cells_emitted = self
.pane_cells_emitted
.saturating_add(stats.scene_render.pane_cells_emitted);
self.extension_render_calls = self
.extension_render_calls
.saturating_add(stats.scene_render.extension_render_calls);
self.extension_render_op_calls = self
.extension_render_op_calls
.saturating_add(stats.scene_render.extension_render_op_calls);
self.extension_imperative_calls = self
.extension_imperative_calls
.saturating_add(stats.scene_render.extension_imperative_calls);
self.extension_cache_hits = self
.extension_cache_hits
.saturating_add(stats.scene_render.extension_cache_hits);
self.extension_full_surface_calls = self
.extension_full_surface_calls
.saturating_add(stats.scene_render.extension_full_surface_calls);
aggregate_extension_render_stats(
&mut self.extension_stats,
&stats.scene_render.extension_stats,
);
self.terminal_graphic_transmits = self
.terminal_graphic_transmits
.saturating_add(stats.scene_render.terminal_graphic_transmits);
self.terminal_graphic_places = self
.terminal_graphic_places
.saturating_add(stats.scene_render.terminal_graphic_places);
self.terminal_graphic_deletes = self
.terminal_graphic_deletes
.saturating_add(stats.scene_render.terminal_graphic_deletes);
self.terminal_graphic_bytes = self
.terminal_graphic_bytes
.saturating_add(stats.scene_render.terminal_graphic_bytes);
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);
}
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::WindowList => {
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::STATUS) {
self.dirty_status_frames = self.dirty_status_frames.saturating_add(1);
}
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,
_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, &[]).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, &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,
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 _ =
bmux_plugin::global_event_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);
}
}
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,
}),
)
})
.collect(),
)
}
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_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(),
);
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(),
);
if !window.extension_stats.is_empty() {
object.insert(
"extension_stats".to_string(),
extension_render_stats_payload(&window.extension_stats),
);
}
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(
"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 recording::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(recording::PerfCaptureLevel::Detailed),
perf_emitter.level_at_least(recording::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 {
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,
})
}
#[allow(clippy::too_many_arguments)] async fn maybe_emit_attach_frame_perf(
perf_emitter: &mut recording::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(recording::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(recording::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(recording::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() {
#[cfg(feature = "bundled-plugin-decoration")]
{
bmux_decoration_plugin_renderer::install();
}
#[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 mut terminal = RealAttachTerminal::new();
run_session_attach_with_terminal(
client,
target,
follow,
global,
kernel_client_factory,
&mut terminal,
)
.await
}
#[allow(clippy::too_many_lines)] pub async fn run_session_attach_with_terminal<T: AttachTerminal + ?Sized>(
mut client: BmuxClient,
target: Option<&str>,
follow: Option<&str>,
global: bool,
kernel_client_factory: Option<KernelClientFactory>,
terminal: &mut T,
) -> 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"
);
install_bundled_render_extensions();
let follow_target_id = match follow {
Some(follow_target) => Some(parse_uuid_value(follow_target, "follow target client id")?),
None => None,
};
let attach_config = 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 = recording::PerfEventEmitter::new(
recording::PerfCaptureSettings::from_config(&attach_config),
);
if let Ok(settings) = performance_state::client::get_settings(&mut client).await {
perf_emitter.update_settings(recording::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 (initial_appearance, appearance_rx_raw) = bmux_plugin::global_event_bus()
.subscribe_state::<RuntimeAppearance>(&RUNTIME_APPEARANCE_STATE_KIND)
.unwrap_or_else(|_| {
let _ = bmux_plugin::global_event_bus().register_state_channel::<RuntimeAppearance>(
RUNTIME_APPEARANCE_STATE_KIND,
RuntimeAppearance::default(),
);
bmux_plugin::global_event_bus()
.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)?
};
if let Some(leader_client_id) = follow_target_id {
writeln!(
terminal,
"attached to session: {} (following {}{})",
attach_info.session_id,
leader_client_id,
if global { ", global" } else { "" }
)?;
} else {
writeln!(terminal, "attached to session: {}", attach_info.session_id)?;
}
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 _ = bmux_plugin::global_event_bus()
.publish_state(&RUNTIME_APPEARANCE_STATE_KIND, appearance);
}
Err(error) => {
tracing::warn!(%error, "attach failed to seed runtime appearance from theme plugin");
}
}
let mut display_capture = DisplayCaptureFanout::default();
for target in &capture_targets {
display_capture.open_target(target, self_client_id);
}
let mut view_state = AttachViewState::new(attach_info);
view_state.self_client_id = Some(self_client_id);
view_state.mouse.config = attach_config.attach_mouse_config();
view_state.mouse.tab_drag_enabled = status_tab_drag_enabled(&attach_config.status_bar);
view_state.status_position = if attach_config.status_bar.enabled {
attach_config.appearance.status_position
} else {
StatusPosition::Off
};
let _ = bmux_plugin::global_event_bus().register_state_channel::<AttachLayoutSnapshot>(
ATTACH_LAYOUT_STATE_KIND,
AttachLayoutSnapshot {
surfaces: Vec::new(),
revision: 0,
},
);
let _ = bmux_plugin::global_event_bus()
.register_state_channel::<bmux_windows_plugin_api::windows_list::WindowListSnapshot>(
bmux_windows_plugin_api::windows_list::STATE_KIND,
bmux_windows_plugin_api::windows_list::WindowListSnapshot {
windows: Vec::new(),
revision: 0,
},
);
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);
}
}
update_attach_viewport_with_geometry(
&mut client,
view_state.attached_id,
view_state.status_position,
terminal.geometry(),
)
.await?;
hydrate_attach_state_from_snapshot(&mut client, &mut view_state).await?;
let _ = notify_extensions_of_layout(&mut client, 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);
view_state.set_transient_status(
initial_attach_status(
&attach_keymap,
&view_state.active_mode_id,
view_state.can_write,
),
Instant::now(),
ATTACH_WELCOME_STATUS_TTL,
);
if !view_state.can_write {
writeln!(terminal, "read-only attach: input disabled")?;
}
if let Some(detach_key) = attach_keymap.primary_binding_for_action(&RuntimeAction::Detach) {
writeln!(terminal, "press {detach_key} to detach")?;
} else {
writeln!(terminal, "detach is unbound in current keymap")?;
}
let keyboard_enhanced = terminal.enter_attach_mode(
attach_config.behavior.kitty_keyboard,
attach_config.attach_mouse_config().enabled,
)?;
let mut attach_input_processor = InputProcessor::new(attach_keymap.clone(), keyboard_enhanced);
let (prompt_host_tx, mut prompt_host_rx) = tokio::sync::mpsc::unbounded_channel();
let _prompt_host_guard = prompt::register_host(prompt_host_tx);
let (action_dispatch_tx, mut action_dispatch_rx) = tokio::sync::mpsc::unbounded_channel();
let _action_dispatch_guard = action_dispatch::register_host(action_dispatch_tx);
#[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();
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 = false;
let (mut last_scene_revision, mut scene_event_rx) =
match bmux_plugin::global_event_bus()
.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 bmux_plugin::global_event_bus()
.subscribe_state::<bmux_windows_plugin_api::windows_list::WindowListSnapshot>(
&bmux_windows_plugin_api::windows_list::STATE_KIND,
) {
Ok((initial, rx)) => {
let revision = initial.revision;
view_state.cached_window_list = Some(initial);
(revision, Some(rx))
}
Err(_) => (0, None),
};
if let Ok(entries) = typed_list_windows_attach(&mut client).await {
let windows: Vec<bmux_windows_plugin_api::windows_list::WindowListEntry> = entries
.into_iter()
.filter_map(|entry| {
let id = Uuid::parse_str(&entry.id).ok()?;
Some(bmux_windows_plugin_api::windows_list::WindowListEntry {
id,
name: entry.name,
active: entry.active,
})
})
.collect();
let snapshot = bmux_windows_plugin_api::windows_list::WindowListSnapshot {
windows,
revision: 0,
};
last_window_list_revision = snapshot.revision;
view_state.cached_window_list = Some(std::sync::Arc::new(snapshot.clone()));
let _ = bmux_plugin::global_event_bus()
.publish_state(&bmux_windows_plugin_api::windows_list::STATE_KIND, snapshot);
}
loop {
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 = 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);
}
match 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?
{
AttachLoopControl::Continue => {}
AttachLoopControl::Break(reason) => {
exit_reason = reason;
break;
}
}
}
prompt_request = prompt_host_rx.recv() => {
if let Some(prompt_request) = prompt_request {
perf_window.record_wake(AttachWakeSource::Prompt);
view_state.prompt.enqueue_external(prompt_request);
view_state
.dirty
.mark_status_dirty(AttachDirtySource::PromptOverlay);
view_state
.dirty
.mark_overlay_dirty(AttachDirtySource::PromptOverlay);
}
}
dispatch_request = action_dispatch_rx.recv() => {
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;
}
}
}
}
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.cached_status_line = None;
view_state
.dirty
.mark_status_dirty(AttachDirtySource::AppearanceChanged);
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;
}
}
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::WindowList);
if snapshot.revision != last_window_list_revision {
last_window_list_revision = snapshot.revision;
view_state.cached_window_list = Some(snapshot);
view_state
.dirty
.mark_status_dirty(AttachDirtySource::StatusChanged);
}
} 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 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 {
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,
previous_layout.as_ref(),
view_state.cached_layout_state.as_ref(),
)
.await;
drop(current_pane_ids);
view_state.dirty.layout_needs_refresh = false;
#[cfg(any(
feature = "image-sixel",
feature = "image-kitty",
feature = "image-iterm2"
))]
{
view_state.image_sequences.clear();
view_state.pane_images.clear();
view_state.kitty_host_state.transmitted.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 client,
&mut view_state,
&layout_state,
&attach_config.status_bar,
&runtime_appearance,
follow_target_id,
global,
&attach_keymap,
&attach_help_lines,
help_scroll,
&attach_config.behavior.damage,
attach_config.logs.client.slow_terminal_write_ms,
&mut display_capture,
render_started_at,
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 {
let pane_ids = visible_scene_pane_ids(&layout_state.scene);
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.scrollback_active
&& let Err(error) =
ensure_focused_scrollback_window(&mut client, &mut view_state, true).await
{
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.host_image_caps.any_supported()
&& !view_state.dirty.pane_dirty_ids.is_empty()
{
image_fetch_pending = false;
let dirty_panes: Vec<Uuid> = 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 client,
&mut view_state,
&layout_state,
&attach_config.status_bar,
&runtime_appearance,
follow_target_id,
global,
&attach_keymap,
&attach_help_lines,
help_scroll,
&attach_config.behavior.damage,
attach_config.logs.client.slow_terminal_write_ms,
&mut display_capture,
render_started_at,
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(recording::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(recording::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"
);
terminal.restore_after_attach_ui()?;
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,
})
}
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 recording::PerfEventEmitter,
pane_output_pending: &mut bool,
last_scene_revision: &mut u64,
) -> Result<AttachServerEventHandling> {
let mut image_fetch_requested = false;
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(
recording::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,
);
view_state
.dirty
.mark_status_dirty(AttachDirtySource::PluginCommand);
}
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,
);
view_state
.dirty
.mark_status_dirty(AttachDirtySource::PluginCommand);
}
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,
);
view_state
.dirty
.mark_status_dirty(AttachDirtySource::PluginCommand);
}
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,
);
view_state
.dirty
.mark_status_dirty(AttachDirtySource::PluginCommand);
}
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,
);
view_state
.dirty
.mark_status_dirty(AttachDirtySource::PluginCommand);
}
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,
);
view_state
.dirty
.mark_status_dirty(AttachDirtySource::PluginCommand);
}
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;
view_state
.dirty
.mark_status_dirty(AttachDirtySource::StatusChanged);
}
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;
publish_decoration_scene_locally(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_windows_plugin_api::windows_list::STATE_KIND.as_str() {
match serde_json::from_slice::<bmux_windows_plugin_api::windows_list::WindowListSnapshot>(
payload,
) {
Ok(snapshot) => {
let _ = bmux_plugin::global_event_bus().publish_state(
&bmux_windows_plugin_api::windows_list::STATE_KIND,
snapshot,
);
}
Err(error) => {
tracing::warn!(
kind = %kind,
error = %error,
"decoding forwarded windows-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 _ = bmux_plugin::global_event_bus()
.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) = bmux_plugin::global_event_bus().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 { .. } => PaneRuntimePluginEventOutcome {
image_fetch_requested: true,
},
pane_events::PaneEvent::AttachViewChanged {
context_id,
session_id,
components,
..
} if attach_view_event_matches_target(view_state, context_id, session_id) => {
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);
view_state
.dirty
.mark_status_dirty(AttachDirtySource::PaneLifecycle);
PaneRuntimePluginEventOutcome::default()
}
pane_events::PaneEvent::Restarted {
session_id,
pane_id,
} if session_id == view_state.attached_id => {
view_state.set_transient_status(
format!("pane {} restarted", short_uuid(pane_id)),
Instant::now(),
ATTACH_TRANSIENT_STATUS_TTL,
);
view_state
.dirty
.mark_status_dirty(AttachDirtySource::PaneLifecycle);
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,
pane_events::AttachViewComponent::Status => AttachViewComponent::Status,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AttachRunOutcome {
pub status_code: u8,
pub exit_reason: AttachExitReason,
}
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;
view_state
.dirty
.mark_status_dirty(AttachDirtySource::StatusChanged);
}
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> {
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 = current_attach_terminal_geometry();
let (status_top_inset, status_bottom_inset) =
status_insets_for_position(view_state.status_position);
let attach_info = client
.retarget_attach_context_with_insets(
context_id,
geometry.cols,
geometry.rows,
status_top_inset,
status_bottom_inset,
)
.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.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;
let status = attach_context_status_from_catalog(view_state);
set_attach_context_status(
view_state,
status,
Instant::now(),
ATTACH_TRANSIENT_STATUS_TTL,
);
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> {
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 => run_attach_plugin_command_local(
plugin_id,
command_name,
args,
kernel_client_factory,
view_state.self_client_id,
active_keybindings,
),
};
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.set_transient_status(
format!("plugin action: {plugin_id}:{command_name} (fallback retarget)"),
Instant::now(),
ATTACH_TRANSIENT_STATUS_TTL,
);
view_state
.dirty
.mark_layout_refresh_and_status_dirty(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.set_transient_status(
format!("plugin action: {plugin_id}:{command_name} (new context retarget)"),
Instant::now(),
ATTACH_TRANSIENT_STATUS_TTL,
);
view_state
.dirty
.mark_layout_refresh_and_status_dirty(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(());
}
view_state.set_transient_status(
outcome_status_message
.unwrap_or_else(|| format!("plugin action: {plugin_id}:{command_name}")),
Instant::now(),
ATTACH_TRANSIENT_STATUS_TTL,
);
view_state
.dirty
.mark_layout_refresh_and_status_dirty(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_window_list.as_ref()?;
let candidates = windows
.windows
.iter()
.filter(|entry| Some(entry.id) != current_context)
.filter(|entry| {
context_session_id(&view_state.cached_context_session_bindings, entry.id)
!= Some(view_state.attached_id)
})
.collect::<Vec<_>>();
if candidates.is_empty() {
return None;
}
let current_index =
current_context.and_then(|id| windows.windows.iter().position(|w| w.id == id));
let selected = current_index
.and_then(|index| {
windows.windows[index.saturating_add(1)..]
.iter()
.find(|entry| candidates.iter().any(|candidate| candidate.id == entry.id))
.or_else(|| {
windows.windows[..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 enqueue_final_pane_action_prompt(
view_state: &mut AttachViewState,
pane_id: Uuid,
session_id: Uuid,
) {
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_attach_scrollback(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_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 => {
view_state.scrollback_offset = max_attach_scrollback(view_state);
clamp_attach_scrollback_cursor(view_state);
}
RuntimeAction::ScrollBottom if view_state.scrollback_active => {
view_state.scrollback_offset = 0;
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.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.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.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 enter_attach_scrollback(view_state: &mut AttachViewState) -> bool {
let Some((inner_w, inner_h)) = focused_attach_pane_inner_size(view_state) else {
return false;
};
let Some(buffer) = focused_attach_pane_buffer(view_state) else {
return false;
};
let cursor = buffer.terminal_grid.grid().cursor();
view_state.scrollback_active = true;
view_state.scrollback_offset = 0;
view_state.scrollback_cursor = Some(AttachScrollbackCursor {
row: cursor.row.min(inner_h.saturating_sub(1)),
col: cursor.col.min(inner_w.saturating_sub(1)),
});
view_state.selection_anchor = None;
true
}
pub fn begin_attach_selection(view_state: &mut AttachViewState) -> bool {
if !view_state.scrollback_active {
return false;
}
view_state.selection_anchor = attach_scrollback_cursor_absolute_position(view_state);
view_state.selection_anchor.is_some()
}
pub fn clear_attach_selection_at(
view_state: &mut AttachViewState,
show_status: bool,
now: Instant,
) {
view_state.selection_anchor = None;
if show_status {
view_state.set_transient_status(
ATTACH_SELECTION_CLEARED_STATUS,
now,
ATTACH_TRANSIENT_STATUS_TTL,
);
}
}
pub fn attach_scrollback_cursor_absolute_position(
view_state: &AttachViewState,
) -> Option<AttachScrollbackPosition> {
let cursor = view_state.scrollback_cursor?;
Some(AttachScrollbackPosition {
row: view_state.scrollback_offset.saturating_add(cursor.row),
col: cursor.col,
})
}
pub fn attach_selection_bounds(
view_state: &AttachViewState,
) -> Option<(AttachScrollbackPosition, AttachScrollbackPosition)> {
let anchor = view_state.selection_anchor?;
let head = attach_scrollback_cursor_absolute_position(view_state)?;
Some(if anchor <= head {
(anchor, head)
} else {
(head, anchor)
})
}
pub fn step_attach_scrollback(view_state: &mut AttachViewState, delta: isize) {
if !view_state.scrollback_active {
return;
}
let max_offset = max_attach_scrollback(view_state);
view_state.scrollback_offset =
adjust_attach_scrollback_offset(view_state.scrollback_offset, delta, max_offset);
clamp_attach_scrollback_cursor(view_state);
}
pub fn move_attach_scrollback_cursor_horizontal(view_state: &mut AttachViewState, delta: isize) {
if !view_state.scrollback_active {
return;
}
let Some((inner_w, _)) = focused_attach_pane_inner_size(view_state) else {
return;
};
let Some(cursor) = view_state.scrollback_cursor.as_mut() else {
return;
};
cursor.col = adjust_scrollback_cursor_component(cursor.col, delta, inner_w.saturating_sub(1));
}
pub fn move_attach_scrollback_cursor_vertical(view_state: &mut AttachViewState, delta: isize) {
if !view_state.scrollback_active || delta == 0 {
return;
}
let Some((_, inner_h)) = focused_attach_pane_inner_size(view_state) else {
return;
};
let max_offset = max_attach_scrollback(view_state);
let Some(cursor) = view_state.scrollback_cursor.as_mut() else {
return;
};
if delta < 0 {
for _ in 0..delta.unsigned_abs() {
if cursor.row > 0 {
cursor.row -= 1;
} else if view_state.scrollback_offset < max_offset {
view_state.scrollback_offset += 1;
}
}
} else {
for _ in 0..(delta.cast_unsigned()) {
if cursor.row + 1 < inner_h {
cursor.row += 1;
} else if view_state.scrollback_offset > 0 {
view_state.scrollback_offset -= 1;
}
}
}
clamp_attach_scrollback_cursor(view_state);
}
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_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_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,
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 buffer = focused_attach_pane_buffer(view_state)?;
let grid = buffer.terminal_grid.grid();
let width = grid.width();
if width == 0 {
return Some(String::new());
}
let selected_rows = end.row.saturating_sub(start.row).saturating_add(1);
let main_row_count = grid.main_row_count();
if main_row_count == 0 {
return Some(String::new());
}
let display_end = main_row_count.saturating_sub(start.row.min(main_row_count));
let display_start = display_end.saturating_sub(grid.height());
let rows = grid.display_rows(start.row, grid.height());
let rows = &rows[rows
.len()
.saturating_sub(display_end.saturating_sub(display_start))..];
if rows.is_empty() {
return Some(String::new());
}
let end_row = selected_rows
.saturating_sub(1)
.min(rows.len().saturating_sub(1));
let mut lines = Vec::with_capacity(end_row.saturating_add(1));
for (row_index, row) in rows.iter().enumerate().take(end_row.saturating_add(1)) {
let start_col = if row_index == 0 { start.col } else { 0 };
let end_col = if row_index == end_row {
end.col.saturating_add(1)
} else {
width
};
lines.push(grid_row_text_range(row, width, start_col, end_col));
}
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.trim_end().to_string()
}
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(view_state: &mut AttachViewState) -> usize {
let Some(buffer) = focused_attach_pane_buffer(view_state) 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 clamp_attach_scrollback_cursor(view_state: &mut AttachViewState) {
let Some((inner_w, inner_h)) = focused_attach_pane_inner_size(view_state) else {
view_state.scrollback_cursor = None;
return;
};
let Some(cursor) = view_state.scrollback_cursor.as_mut() else {
return;
};
cursor.row = cursor.row.min(inner_h.saturating_sub(1));
cursor.col = cursor.col.min(inner_w.saturating_sub(1));
}
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)
}
fn attach_view_floating_summary(view_state: &AttachViewState) -> (usize, bool, bool) {
let Some(layout_state) = view_state.cached_layout_state.as_ref() else {
return (0, false, false);
};
let focused_pane_id = focused_attach_pane_id(view_state);
let mut has_tiled = false;
let mut focused_floating = false;
let mut floating_panes = BTreeSet::new();
for surface in &layout_state.scene.surfaces {
if !surface.visible || surface.pane_id.is_none() {
continue;
}
match surface.kind {
AttachSurfaceKind::Pane => has_tiled = true,
AttachSurfaceKind::FloatingPane => {
if let Some(pane_id) = surface.pane_id {
floating_panes.insert(pane_id);
focused_floating |= Some(pane_id) == focused_pane_id;
}
}
AttachSurfaceKind::Modal | AttachSurfaceKind::Overlay | AttachSurfaceKind::Tooltip => {}
}
}
(
floating_panes.len(),
!has_tiled && !floating_panes.is_empty(),
focused_floating,
)
}
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)),
)
})
}
#[allow(
clippy::too_many_arguments,
clippy::too_many_lines,
clippy::fn_params_excessive_bools,
clippy::needless_pass_by_ref_mut
)]
pub fn build_attach_status_line_for_draw(
_client: &mut StreamingBmuxClient,
view_state: &mut AttachViewState,
status_config: &bmux_config::StatusBarConfig,
runtime_appearance: &RuntimeAppearance,
context_id: Option<Uuid>,
session_id: Uuid,
can_write: bool,
ui_mode: AttachUiMode,
scrollback_active: bool,
follow_target_id: Option<Uuid>,
follow_global: bool,
prompt_active: bool,
prompt_hint: Option<&str>,
help_overlay_open: bool,
transient_status: Option<&str>,
keymap: &Keymap,
geometry: TerminalGeometry,
) -> AttachStatusLine {
if geometry.cols == 0 {
return AttachStatusLine {
rendered: String::new(),
spans: Vec::new(),
tab_hitboxes: Vec::new(),
drag_marker_col: None,
};
}
let cached_contexts = view_state.cached_contexts.clone();
let cached_sessions = view_state.cached_sessions.clone();
let cached_context_session_bindings = view_state.cached_context_session_bindings.clone();
let tabs = build_attach_tabs_from_catalog(
&cached_contexts,
&cached_context_session_bindings,
view_state,
status_config,
context_id,
session_id,
);
let (session_label, session_count) =
resolve_attach_session_label_and_count_from_catalog(&cached_sessions, session_id);
let current_context_label = resolve_attach_context_label_from_catalog(
&cached_contexts,
&cached_context_session_bindings,
context_id,
session_id,
);
let tab_position_label = tabs
.iter()
.position(|tab| tab.active)
.map(|active_index| format!("tab:{}/{}", active_index + 1, tabs.len()));
let zoomed = view_state
.cached_layout_state
.as_ref()
.is_some_and(|s| s.zoomed);
let mode_label = if help_overlay_open {
"HELP"
} else if prompt_active {
"PROMPT"
} else if scrollback_active {
"SCROLL"
} else if zoomed {
"ZOOM"
} else {
view_state.active_mode_label.as_str()
};
let appearance_mode_id = if help_overlay_open {
"help"
} else if prompt_active {
"prompt"
} else if scrollback_active {
"scroll"
} else if zoomed {
"zoom"
} else {
view_state.active_mode_id.as_str()
};
let runtime_appearance = runtime_appearance.for_mode(appearance_mode_id);
let role_label = if can_write { "write" } else { "read-only" };
let follow_label = follow_target_id.map(|id| {
if follow_global {
format!("following {} (global)", short_uuid(id))
} else {
format!("following {}", short_uuid(id))
}
});
let hint = if prompt_active {
prompt_hint.unwrap_or("Prompt active").to_string()
} else if help_overlay_open {
"Help overlay open | ? toggles | Esc/Enter close".to_string()
} else if let Some(status) = transient_status {
status.to_string()
} else if scrollback_active {
attach_scrollback_hint(keymap)
} else {
let (floating_count, floating_only, focused_floating) =
attach_view_floating_summary(view_state);
if floating_only {
format!("{floating_count} floating pane(s) remain; close them before closing this tab")
} else if focused_floating {
format!(
"floating pane focused ({floating_count} visible) | {}",
attach_mode_hint(&view_state.active_mode_id, ui_mode, keymap)
)
} else {
attach_mode_hint(&view_state.active_mode_id, ui_mode, keymap)
}
};
let mut status_line = build_attach_status_line(
geometry.cols,
status_config,
&runtime_appearance,
&session_label,
session_count,
¤t_context_label,
&tabs,
tab_position_label.as_deref(),
mode_label,
role_label,
follow_label.as_deref(),
&hint,
);
status_line.drag_marker_col = view_state
.mouse
.tab_drag
.as_ref()
.and_then(|drag| drag.drop_target)
.and_then(|target| attach_tab_drop_marker_col(&status_line, target, geometry.cols));
status_line
}
pub fn attach_mode_hint(mode_id: &str, _ui_mode: AttachUiMode, keymap: &Keymap) -> String {
let detach = key_hint_or_unbound(keymap, mode_id, &RuntimeAction::Detach);
let quit = key_hint_or_unbound(keymap, mode_id, &RuntimeAction::Quit);
let help = key_hint_or_unbound(keymap, mode_id, &RuntimeAction::ShowHelp);
let restart = key_hint_or_unbound(
keymap,
mode_id,
&RuntimeAction::PluginCommand {
plugin_id: "bmux.windows".to_string(),
command_name: "restart-pane".to_string(),
args: Vec::new(),
},
);
let prev = key_hint_or_unbound(
keymap,
mode_id,
&RuntimeAction::PluginCommand {
plugin_id: "bmux.windows".to_string(),
command_name: "prev-window".to_string(),
args: Vec::new(),
},
);
let next = key_hint_or_unbound(
keymap,
mode_id,
&RuntimeAction::PluginCommand {
plugin_id: "bmux.windows".to_string(),
command_name: "next-window".to_string(),
args: Vec::new(),
},
);
format!(
"{prev}/{next} tabs | {detach} detach | {restart} restart pane | {quit} quit | {help} help"
)
}
fn windows_close_active_pane_action() -> RuntimeAction {
RuntimeAction::PluginCommand {
plugin_id: "bmux.windows".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.windows" && 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)
)
}
pub fn initial_attach_status(keymap: &Keymap, mode_id: &str, can_write: bool) -> String {
let help = key_hint_or_unbound(keymap, mode_id, &RuntimeAction::ShowHelp);
if can_write {
format!("{help} help | modal input enabled")
} else {
format!("read-only attach | {help} help")
}
}
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 attach_scrollback_hint(keymap: &Keymap) -> String {
let exit = scroll_key_hint_or_unbound(keymap, &RuntimeAction::ExitScrollMode);
let confirm = scroll_key_hint_or_unbound(keymap, &RuntimeAction::ConfirmScrollback);
let left = scroll_key_hint_or_unbound(keymap, &RuntimeAction::MoveCursorLeft);
let right = scroll_key_hint_or_unbound(keymap, &RuntimeAction::MoveCursorRight);
let up = scroll_key_hint_or_unbound(keymap, &RuntimeAction::MoveCursorUp);
let down = scroll_key_hint_or_unbound(keymap, &RuntimeAction::MoveCursorDown);
let page_up = scroll_key_hint_or_unbound(keymap, &RuntimeAction::ScrollUpPage);
let page_down = scroll_key_hint_or_unbound(keymap, &RuntimeAction::ScrollDownPage);
let top = scroll_key_hint_or_unbound(keymap, &RuntimeAction::ScrollTop);
let bottom = scroll_key_hint_or_unbound(keymap, &RuntimeAction::ScrollBottom);
let select = scroll_key_hint_or_unbound(keymap, &RuntimeAction::BeginSelection);
let copy = scroll_key_hint_or_unbound(keymap, &RuntimeAction::CopyScrollback);
format!(
"{up}/{down} line | {left}/{right} col | {page_up}/{page_down} page | {top}/{bottom} top/bottom | {select} select | {copy} copy | {confirm} copy+exit | {exit} cancel/exit scroll"
)
}
pub fn scroll_key_hint_or_unbound(keymap: &Keymap, action: &RuntimeAction) -> String {
keymap
.primary_scroll_binding_for_action(action)
.unwrap_or_else(|| "unbound".to_string())
}
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_label = keymap
.mode_label(&mode_id)
.map_or_else(|| mode_id.to_ascii_uppercase(), ToString::to_string);
let mode_changed = view_state.active_mode_id != mode_id;
let label_changed = view_state.active_mode_label != mode_label;
view_state.active_mode_id = mode_id;
view_state.active_mode_label = mode_label;
if mode_changed {
view_state
.dirty
.mark_full_frame(AttachDirtySource::ActionDispatch);
view_state
.dirty
.mark_status_dirty(AttachDirtySource::ActionDispatch);
} else if label_changed {
view_state
.dirty
.mark_status_dirty(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();
let previous_tab_drag_enabled = view_state.mouse.tab_drag_enabled;
let previous_status_position = view_state.status_position;
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.status_position = if resolved_config.status_bar.enabled {
resolved_config.appearance.status_position
} else {
StatusPosition::Off
};
view_state.mouse.config = resolved_config.attach_mouse_config();
view_state.mouse.tab_drag_enabled = status_tab_drag_enabled(&resolved_config.status_bar);
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;
view_state.mouse.tab_drag_enabled = previous_tab_drag_enabled;
view_state.status_position = previous_status_position;
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(())
}
pub const fn status_insets_for_position(status_position: StatusPosition) -> (u16, u16) {
match status_position {
StatusPosition::Top => (1, 0),
StatusPosition::Bottom => (0, 1),
StatusPosition::Off => (0, 0),
}
}
pub const fn status_row_for_position(status_position: StatusPosition, rows: u16) -> Option<u16> {
if rows == 0 {
return None;
}
match status_position {
StatusPosition::Top => Some(0),
StatusPosition::Bottom => Some(rows.saturating_sub(1)),
StatusPosition::Off => None,
}
}
fn status_line_render_ops(
status_line: &AttachStatusLine,
status_row: u16,
cols: u16,
) -> Vec<RenderOp> {
let mut ops = Vec::new();
if status_line.spans.is_empty() {
ops.push(RenderOp::text_run(
0,
status_row,
&status_line.rendered,
RenderStyle::new(),
));
} else {
ops.push(RenderOp::styled_text(
0,
status_row,
status_line.spans.clone(),
));
}
if let Some(marker_col) = status_line.drag_marker_col {
ops.push(RenderOp::text_run(
marker_col.min(cols.saturating_sub(1)),
status_row,
"│",
RenderStyle::new(),
));
}
ops
}
fn retained_status_surface(
status_line: &AttachStatusLine,
status_position: StatusPosition,
terminal_size: (u16, u16),
) -> Option<RetainedSurface> {
let (cols, rows) = terminal_size;
if cols == 0 || rows == 0 {
return None;
}
let status_row = status_row_for_position(status_position, rows)?;
Some(
RetainedSurface::builder(STATUS_SURFACE_ID, DamageRect::new(0, status_row, cols, 1))
.layer(retained_layer_order(SurfaceLayer::Status))
.z(i32::MAX)
.opaque()
.render_ops(status_line_render_ops(status_line, status_row, cols))
.build(),
)
}
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_status_dirty(AttachDirtySource::HelpOverlay);
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,
) -> Vec<RenderOp> {
let width = usize::from(surface_meta.rect.w);
let height = usize::from(surface_meta.rect.h);
let x = usize::from(surface_meta.rect.x);
let y = usize::from(surface_meta.rect.y);
let body_rows = height.saturating_sub(4).max(1);
let text_width = width.saturating_sub(4);
let style = RenderStyle::new();
let rect = ExtensionRect::new(
surface_meta.rect.x,
surface_meta.rect.y,
surface_meta.rect.w,
surface_meta.rect.h,
);
let interior = ExtensionRect::new(
surface_meta.rect.x.saturating_add(1),
surface_meta.rect.y.saturating_add(1),
surface_meta.rect.w.saturating_sub(2),
surface_meta.rect.h.saturating_sub(2),
);
let mut ops = vec![
RenderOp::clear_rect(interior, style),
RenderOp::border(rect, BorderGlyphs::ascii(), style),
];
let title = " bmux help ";
let title_x = x + ((width.saturating_sub(title.len())) / 2);
ops.push(RenderOp::text_run(title_x as u16, y as u16, title, style));
let header = "scope chord action";
ops.push(RenderOp::text_run(
(x + 2) as u16,
(y + 1) as u16,
opaque_row_text(header, text_width),
style,
));
let start = scroll.min(lines.len().saturating_sub(body_rows));
let end = (start + body_rows).min(lines.len());
for (idx, line) in lines.iter().skip(start).take(body_rows).enumerate() {
let row = y + 2 + idx;
if row >= y + height - 1 {
break;
}
ops.push(RenderOp::text_run(
(x + 2) as u16,
row as u16,
opaque_row_text(line, text_width),
style,
));
}
let footer = format!(
"j/k or ↑/↓ scroll | PgUp/PgDn | Esc close | {}-{} / {}",
if lines.is_empty() { 0 } else { start + 1 },
end,
lines.len()
);
ops.push(RenderOp::text_run(
(x + 2) as u16,
(y + height - 2) as u16,
opaque_row_text(&footer, text_width),
style,
));
ops
}
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,
) -> RetainedSurface {
retained_surface_from_attach_surface(
surface,
RetainedOpacity::Opaque,
help_overlay_render_ops(surface, lines, scroll),
)
}
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),
status_top_inset: u16,
status_bottom_inset: u16,
) -> Option<RetainedSurface> {
let ops = frame_damage_overlay_render_ops(
scene,
frame_damage,
terminal_size,
status_top_inset,
status_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,
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_render_ops(
stdout: &mut impl Write,
surface: &RetainedSurface,
repaint: &RetainedRepaintSurface,
) -> 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(stdout, surface_rect, &damage, ops)
.context("failed queueing retained render ops")
}
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()
}
}
#[allow(clippy::too_many_lines, clippy::too_many_arguments)]
pub fn render_attach_frame_to_writer<W: Write + ?Sized>(
terminal_writer: &mut W,
client: &mut StreamingBmuxClient,
view_state: &mut AttachViewState,
layout_state: &AttachLayoutState,
status_config: &bmux_config::StatusBarConfig,
runtime_appearance: &RuntimeAppearance,
follow_target_id: Option<Uuid>,
follow_global: bool,
keymap: &crate::input::Keymap,
help_lines: &[String],
help_scroll: usize,
damage_config: &bmux_config::DamageBehaviorConfig,
slow_terminal_write_ms: u64,
display_capture: &mut DisplayCaptureFanout,
now: Instant,
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 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);
if view_state.dirty.status_needs_redraw {
let transient_status = view_state.transient_status_text(now).map(str::to_owned);
view_state.cached_status_line = Some(build_attach_status_line_for_draw(
client,
view_state,
status_config,
runtime_appearance,
view_state.attached_context_id,
view_state.attached_id,
view_state.can_write,
view_state.ui_mode,
view_state.scrollback_active,
follow_target_id,
follow_global,
view_state.prompt.is_active(),
view_state.prompt.active_hint(),
view_state.help_overlay_open,
transient_status.as_deref(),
keymap,
geometry,
));
view_state.dirty.status_needs_redraw = false;
}
let (status_top_inset, status_bottom_inset) =
status_insets_for_position(view_state.status_position);
let terminal_size = (geometry.cols, geometry.rows);
let viewport = DamageRect::new(0, 0, terminal_size.0, terminal_size.1);
let status_surface = view_state
.cached_status_line
.as_ref()
.and_then(|status_line| {
retained_status_surface(status_line, view_state.status_position, terminal_size)
});
let help_retained_surface = current_help_overlay_surface
.as_ref()
.map(|surface| retained_help_overlay_surface(surface, help_lines, help_scroll));
let prompt_overlay_render = if view_state.prompt.is_active() {
view_state.prompt.attach_prompt_overlay_render(geometry)
} else {
None
};
let prompt_retained_surface = prompt_overlay_render
.as_ref()
.map(retained_prompt_overlay_surface);
let retained_frame_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.status_damaged()
&& let Some(surface) = status_surface.as_ref()
{
explicit_ui_damage_rects.push(surface.rect);
}
if frame_damage.overlay_damaged() {
if let Some(surface) = help_retained_surface.as_ref() {
explicit_ui_damage_rects.push(surface.rect);
}
if let Some(surface) = prompt_retained_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);
retained_surfaces.extend(status_surface.iter().cloned());
retained_surfaces.extend(help_retained_surface.iter().cloned());
retained_surfaces.extend(prompt_retained_surface.iter().cloned());
let retained_graph_damage = view_state.retained_compositor.replace_surfaces(
retained_surfaces.clone(),
viewport,
damage_policy,
);
let retained_damage = merge_retained_damages(
[
retained_frame_damage,
explicit_ui_damage,
retained_graph_damage,
],
viewport,
damage_policy,
);
let retained_repaint_plan = view_state
.retained_compositor
.repaint_plan(&retained_damage);
frame_damage.merge_from(&frame_damage_from_retained_repaint_plan(
&layout_state.scene,
&retained_repaint_plan,
damage_policy,
));
let damage_stats = frame_damage.stats();
let render_scene = frame_damage.scene_damaged();
let retained_repaint_by_id = retained_repaint_plan
.iter()
.map(|surface| (surface.surface_id, surface))
.collect::<BTreeMap<_, _>>();
let use_synchronized_update =
frame_uses_synchronized_update(&frame_damage) || damage_config.visualize;
let mut frame_bytes = Vec::new();
if use_synchronized_update {
queue!(frame_bytes, BeginSynchronizedUpdate)
.context("failed queuing begin synchronized update")?;
}
queue!(frame_bytes, SavePosition).context("failed queuing cursor save for attach frame")?;
queue!(frame_bytes, Hide).context("failed queuing cursor hide for attach frame")?;
let cursor_state_before_forced_hide = view_state.last_cursor_state;
if let Some(ref mut cs) = view_state.last_cursor_state {
cs.visible = false;
}
let status_rendered = if let Some((surface, repaint)) = status_surface
.as_ref()
.zip(retained_repaint_by_id.get(&STATUS_SURFACE_ID))
{
queue_retained_render_ops(&mut frame_bytes, surface, repaint)?
} else {
false
};
if status_rendered
&& let Some(trace) = render_trace.as_deref_mut()
&& let Some(row) = status_row_for_position(view_state.status_position, terminal_size.1)
{
trace.push(AttachRenderTraceOp::StatusLine {
row,
cells: terminal_size.0,
});
}
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 active_runtime_appearance = runtime_appearance.for_mode(appearance_mode_id);
let mut scene_render_stats = AttachSceneRenderStats::default();
let cursor_state = if render_scene {
let extensions = bmux_plugin::registered_render_extensions();
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(
&mut frame_bytes,
&layout_state.scene,
&layout_state.panes,
&mut view_state.pane_buffers,
&mut view_state.terminal_graphics_cache,
&frame_damage,
status_top_inset,
status_bottom_inset,
view_state.scrollback_active,
view_state.scrollback_offset,
view_state.scrollback_cursor,
view_state.selection_anchor,
layout_state.zoomed,
terminal_size,
&active_runtime_appearance,
damage_policy,
&extensions,
terminal_render_capabilities,
render_trace.as_deref_mut(),
)?;
scene_render_stats = stats;
cursor_state
} else {
view_state.last_cursor_state
};
#[cfg(any(
feature = "image-sixel",
feature = "image-kitty",
feature = "image-iterm2"
))]
if 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;
};
if let Some(images) = view_state.pane_images.get(&pane_id)
&& !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 decode_mode = view_state.image_decode_mode;
let _ = bmux_image::compositor::render_pane_images(
&mut frame_bytes,
&pane_images,
pane_rect,
&view_state.host_image_caps,
decode_mode,
&mut view_state.kitty_host_state,
);
}
}
}
let previous_cursor_state = view_state.last_cursor_state;
let mut overlay_rendered = false;
let mut overlay_cursor_state = None;
if let Some(help_surface) = help_retained_surface.as_ref()
&& let Some(repaint) = retained_repaint_by_id.get(&help_surface.id)
&& queue_retained_render_ops(&mut frame_bytes, help_surface, repaint)?
{
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;
}
if let Some(prompt_surface) = prompt_retained_surface.as_ref()
&& let Some(repaint) = retained_repaint_by_id.get(&prompt_surface.id)
&& queue_retained_render_ops(&mut frame_bytes, prompt_surface, repaint)?
{
overlay_cursor_state = 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;
} 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,
status_top_inset,
status_bottom_inset,
)
{
let repaint = retained_full_surface_repaint(&damage_surface);
if queue_retained_render_ops(&mut frame_bytes, &damage_surface, &repaint)? {
let rects = frame_damage_overlay_rects(
&layout_state.scene,
&frame_damage,
terminal_size,
status_top_inset,
status_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() {
apply_attach_cursor_state(
&mut frame_bytes,
overlay_cursor_state,
&mut view_state.last_cursor_state,
false,
)?;
} else {
let force_cursor_move = std::mem::take(&mut view_state.force_cursor_move_next_frame);
apply_attach_cursor_state(
&mut frame_bytes,
cursor_state,
&mut view_state.last_cursor_state,
force_cursor_move,
)?;
}
display_capture.record_frame_bytes(&frame_bytes);
display_capture.record_activity(DisplayActivityKind::Output);
display_capture.record_cursor_snapshot(view_state.last_cursor_state);
if previous_cursor_state != view_state.last_cursor_state {
display_capture.record_activity(DisplayActivityKind::Cursor);
}
#[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;
};
if let Some(images) = view_state.pane_images.get(&pane_id) {
for img in images {
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 use_synchronized_update {
queue!(frame_bytes, EndSynchronizedUpdate)
.context("failed queuing end synchronized update")?;
}
let terminal_write_started_at = Instant::now();
terminal_writer
.write_all(&frame_bytes)
.context("failed writing attach frame")?;
terminal_writer
.flush()
.context("failed flushing attach frame")?;
let terminal_write_ms = duration_millis_u64(terminal_write_started_at.elapsed());
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: damage_stats.rect_count,
damage_area_cells: damage_stats.rect_area_cells,
full_surface_fallbacks: damage_stats.full_surface_count,
full_frame_fallback: damage_stats.full_frame,
scene_render: scene_render_stats,
status_rendered,
overlay_rendered,
synchronized_update: 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 const fn status_tab_drag_enabled(status_config: &bmux_config::StatusBarConfig) -> bool {
!matches!(status_config.tab_scope, bmux_config::StatusTabScope::Mru)
&& !matches!(status_config.tab_order, bmux_config::StatusTabOrder::Mru)
}
pub fn build_attach_tabs_from_catalog(
contexts: &[ContextRow],
bindings: &[ContextSessionBinding],
view_state: &AttachViewState,
status_config: &bmux_config::StatusBarConfig,
context_id: Option<Uuid>,
session_id: Uuid,
) -> Vec<AttachTab> {
let use_mru = matches!(status_config.tab_scope, bmux_config::StatusTabScope::Mru)
|| matches!(status_config.tab_order, bmux_config::StatusTabOrder::Mru);
if !use_mru
&& let Some(snapshot) = view_state.cached_window_list.as_ref()
&& !snapshot.windows.is_empty()
{
return build_attach_tabs_from_plugin_snapshot(
snapshot,
contexts,
bindings,
status_config,
context_id,
session_id,
);
}
build_attach_tabs_from_raw_contexts(contexts, bindings, status_config, context_id, session_id)
}
fn build_attach_tabs_from_plugin_snapshot(
snapshot: &bmux_windows_plugin_api::windows_list::WindowListSnapshot,
contexts: &[ContextRow],
bindings: &[ContextSessionBinding],
status_config: &bmux_config::StatusBarConfig,
context_id: Option<Uuid>,
session_id: Uuid,
) -> Vec<AttachTab> {
let filtered: Vec<&bmux_windows_plugin_api::windows_list::WindowListEntry> = if matches!(
status_config.tab_scope,
bmux_config::StatusTabScope::SessionContexts
) {
let session_match: Vec<_> = snapshot
.windows
.iter()
.filter(|entry| {
contexts
.iter()
.find(|context| context.id == entry.id)
.is_some_and(|context| {
context_session_id(bindings, context.id) == Some(session_id)
})
})
.collect();
if session_match.is_empty() {
snapshot.windows.iter().collect()
} else {
session_match
}
} else {
snapshot.windows.iter().collect()
};
let current_id = context_id.or_else(|| {
contexts
.iter()
.find(|context| context_session_id(bindings, context.id) == Some(session_id))
.map(|context| context.id)
});
filtered
.into_iter()
.map(|entry| AttachTab {
label: entry.name.clone(),
active: current_id.map_or(entry.active, |id| id == entry.id),
context_id: Some(entry.id),
})
.collect()
}
fn build_attach_tabs_from_raw_contexts(
contexts: &[ContextRow],
bindings: &[ContextSessionBinding],
status_config: &bmux_config::StatusBarConfig,
context_id: Option<Uuid>,
session_id: Uuid,
) -> Vec<AttachTab> {
if contexts.is_empty() {
return vec![AttachTab {
label: "terminal".to_string(),
active: true,
context_id: None,
}];
}
let tab_contexts = match status_config.tab_scope {
bmux_config::StatusTabScope::AllContexts | bmux_config::StatusTabScope::Mru => {
contexts.to_vec()
}
bmux_config::StatusTabScope::SessionContexts => {
let filtered = contexts
.iter()
.filter(|context| context_session_id(bindings, context.id) == Some(session_id))
.cloned()
.collect::<Vec<_>>();
if filtered.is_empty() {
contexts.to_vec()
} else {
filtered
}
}
};
let current_context_id = context_id.or_else(|| {
tab_contexts
.iter()
.find(|context| context_session_id(bindings, context.id) == Some(session_id))
.map(|context| context.id)
});
tab_contexts
.into_iter()
.enumerate()
.map(|(index, context)| AttachTab {
label: context_summary_label(&context, Some(index.saturating_add(1))),
active: current_context_id == Some(context.id),
context_id: Some(context.id),
})
.collect()
}
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 attach_context_status_from_catalog(view_state: &AttachViewState) -> String {
let (session_label, _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,
);
format!("session: {session_label} | context: {context_label}")
}
pub fn set_attach_context_status(
view_state: &mut AttachViewState,
status: String,
now: Instant,
ttl: Duration,
) {
view_state.set_transient_status(status, now, ttl);
}
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.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.attached_id, view_state.status_position).await?;
hydrate_attach_state_from_snapshot(client, view_state).await?;
view_state.ui_mode = AttachUiMode::Normal;
let status = attach_context_status_from_catalog(view_state);
set_attach_context_status(
view_state,
status,
Instant::now(),
ATTACH_TRANSIENT_STATUS_TTL,
);
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.windows".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.windows" && command_name == "focus-pane-in-direction" => "Pane",
RuntimeAction::PluginCommand {
plugin_id,
command_name,
..
} if plugin_id == "bmux.windows" && command_name == "resize-pane" => "Pane",
RuntimeAction::PluginCommand {
plugin_id,
command_name,
..
} if plugin_id == "bmux.windows"
&& 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)"),
}
}
pub struct RawModeGuard {
keyboard_enhanced: bool,
mouse_capture_enabled: bool,
}
impl RawModeGuard {
fn enable(kitty_keyboard_enabled: bool, mouse_capture_enabled: bool) -> Result<Self> {
enable_raw_mode().context("failed enabling raw mode")?;
#[cfg(feature = "kitty-keyboard")]
let keyboard_enhanced = kitty_keyboard_enabled
&& crossterm::terminal::supports_keyboard_enhancement().unwrap_or(false);
#[cfg(not(feature = "kitty-keyboard"))]
let keyboard_enhanced = false;
let _ = kitty_keyboard_enabled;
let mut stdout = io::stdout();
if keyboard_enhanced {
use crossterm::event::{KeyboardEnhancementFlags, PushKeyboardEnhancementFlags};
queue!(
stdout,
PushKeyboardEnhancementFlags(
KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
| KeyboardEnhancementFlags::REPORT_EVENT_TYPES
)
)
.context("failed to push keyboard enhancement flags")?;
stdout
.flush()
.context("failed to flush after pushing keyboard flags")?;
}
if mouse_capture_enabled {
queue!(stdout, EnableMouseCapture).context("failed to enable mouse capture")?;
stdout
.flush()
.context("failed to flush after enabling mouse capture")?;
}
Ok(Self {
keyboard_enhanced,
mouse_capture_enabled,
})
}
}
impl Drop for RawModeGuard {
fn drop(&mut self) {
if self.mouse_capture_enabled {
let mut stdout = io::stdout();
let _ = queue!(stdout, DisableMouseCapture);
let _ = stdout.flush();
}
if self.keyboard_enhanced {
use crossterm::event::PopKeyboardEnhancementFlags;
let mut stdout = io::stdout();
let _ = queue!(stdout, PopKeyboardEnhancementFlags);
let _ = stdout.flush();
}
let _ = disable_raw_mode();
}
}
pub async fn update_attach_viewport(
client: &mut StreamingBmuxClient,
session_id: Uuid,
status_position: StatusPosition,
) -> std::result::Result<(), ClientError> {
update_attach_viewport_with_geometry(
client,
session_id,
status_position,
current_attach_terminal_geometry(),
)
.await
}
pub async fn update_attach_viewport_with_geometry(
client: &mut StreamingBmuxClient,
session_id: Uuid,
status_position: StatusPosition,
geometry: TerminalGeometry,
) -> std::result::Result<(), ClientError> {
if geometry.cols == 0 || geometry.rows == 0 {
return Ok(());
}
let (status_top_inset, status_bottom_inset) = status_insets_for_position(status_position);
client
.attach_set_viewport_with_insets(
session_id,
geometry.cols,
geometry.rows,
status_top_inset,
status_bottom_inset,
)
.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);
buffer.scrollback_window = None;
buffer.prev_rows.clear();
hydrated.insert(snapshot.pane_id);
}
Ok(hydrated)
}
async fn ensure_focused_scrollback_window(
client: &mut StreamingBmuxClient,
view_state: &mut AttachViewState,
force_refresh: bool,
) -> std::result::Result<(), ClientError> {
if !view_state.scrollback_active {
return Ok(());
}
let Some(pane_id) = focused_attach_pane_id(view_state) else {
return Ok(());
};
let Some((_, rows)) = focused_attach_pane_inner_size(view_state) else {
return Ok(());
};
let scrollback_offset = view_state.scrollback_offset;
let cached_window = view_state
.pane_buffers
.get(&pane_id)
.and_then(|buffer| buffer.scrollback_window.as_ref());
if !force_refresh
&& cached_window.is_some_and(|window| window.scrollback_offset == scrollback_offset)
{
return Ok(());
}
let anchor_total_scrolled_rows = cached_window.map(|window| window.total_scrolled_rows);
let windows = attach_pane_grid_window_state_streaming(
client,
view_state.attached_id,
vec![PaneGridWindowRequest {
pane_id,
scrollback_offset,
rows,
anchor_total_scrolled_rows,
}],
)
.await?;
let Some(window) = windows.into_iter().find(|window| window.pane_id == pane_id) else {
return Ok(());
};
let decoded = serde_json::from_slice::<bmux_terminal_grid::GridSnapshot>(&window.encoded)
.map_err(|error| ClientError::ServerError {
code: bmux_ipc::ErrorCode::Internal,
message: format!("decoding structured scrollback window: {error}"),
})?;
let grid = bmux_terminal_grid::TerminalGrid::from_snapshot(
&decoded,
bmux_terminal_grid::GridLimits::default(),
)
.map_err(|error| ClientError::ServerError {
code: bmux_ipc::ErrorCode::Internal,
message: format!("hydrating structured scrollback window: {error}"),
})?;
let adjusted_offset = window.scrollback_offset;
let offset_delta = adjusted_offset.saturating_sub(scrollback_offset);
if offset_delta > 0 {
view_state.scrollback_offset = adjusted_offset;
if let Some(anchor) = view_state.selection_anchor.as_mut() {
anchor.row = anchor.row.saturating_add(offset_delta);
}
} else if adjusted_offset < scrollback_offset {
let delta = scrollback_offset - adjusted_offset;
view_state.scrollback_offset = adjusted_offset;
if let Some(anchor) = view_state.selection_anchor.as_mut() {
anchor.row = anchor.row.saturating_sub(delta);
}
}
if let Some(buffer) = view_state.pane_buffers.get_mut(&pane_id) {
buffer.scrollback_window = Some(PaneScrollbackWindow {
scrollback_offset: adjusted_offset,
max_scrollback_offset: window.max_scrollback_offset,
total_scrolled_rows: window.total_scrolled_rows,
rows: grid.viewport_rows(),
});
}
Ok(())
}
async fn handle_attach_mouse_scrollback_with_window(
client: &mut StreamingBmuxClient,
view_state: &mut AttachViewState,
kind: MouseEventKind,
) -> std::result::Result<bool, ClientError> {
let was_active = view_state.scrollback_active;
let before_offset = view_state.scrollback_offset;
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(view_state, kind);
if !consumed {
return Ok(false);
}
ensure_focused_scrollback_window(client, view_state, false).await?;
if matches!(kind, MouseEventKind::ScrollUp)
&& !was_active
&& view_state.scrollback_active
&& view_state.scrollback_offset == before_offset
{
step_attach_scrollback(view_state, -scroll_lines);
ensure_focused_scrollback_window(client, view_state, false).await?;
}
Ok(true)
}
async fn handle_attach_ui_action_with_scrollback(
client: &mut StreamingBmuxClient,
action: &RuntimeAction,
view_state: &mut AttachViewState,
now: Instant,
) -> std::result::Result<(), ClientError> {
let needs_max_before = matches!(
action,
RuntimeAction::ScrollUpLine
| RuntimeAction::ScrollUpPage
| RuntimeAction::ScrollTop
| RuntimeAction::MoveCursorUp
) && view_state.scrollback_active;
if needs_max_before {
ensure_focused_scrollback_window(client, view_state, false).await?;
}
handle_attach_ui_action_at(action, view_state, now);
if view_state.scrollback_active {
ensure_focused_scrollback_window(client, view_state, false).await?;
}
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();
} 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));
}
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() {
resize_attach_grids_for_scene(&mut view_state.pane_buffers, &layout_state.scene);
}
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);
view_state
.dirty
.mark_status_dirty(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());
}
resize_attach_grids_for_scene(&mut view_state.pane_buffers, &layout_state.scene);
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 current_attach_terminal_geometry() -> TerminalGeometry {
let (cols, rows) = terminal::size().unwrap_or((0, 0));
TerminalGeometry { cols, rows }
}
pub fn resize_attach_grids_for_scene(
pane_buffers: &mut std::collections::BTreeMap<Uuid, attach::state::PaneRenderBuffer>,
scene: &AttachScene,
) {
let geometry = current_attach_terminal_geometry();
resize_attach_grids_for_scene_with_size(pane_buffers, scene, geometry.cols, geometry.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,
)
.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,
);
}
}
view_state
.dirty
.mark_status_dirty(AttachDirtySource::ControlCatalogChanged);
}
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.attached_context_id = attach_info.context_id.or(context_id);
view_state.can_write = attach_info.can_write;
update_attach_viewport(client, view_state.attached_id, view_state.status_position)
.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;
let status = attach_context_status_from_catalog(view_state);
set_attach_context_status(
view_state,
status,
Instant::now(),
ATTACH_TRANSIENT_STATUS_TTL,
);
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_and_status_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,
);
view_state
.dirty
.mark_status_dirty(AttachDirtySource::FollowTargetChanged);
}
_ => {}
}
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_and_status_dirty(AttachDirtySource::SceneChanged);
}
AttachViewComponent::SurfaceContent => {
view_state
.dirty
.mark_layout_refresh(AttachDirtySource::SceneChanged);
}
AttachViewComponent::Status => {
view_state
.dirty
.mark_status_dirty(AttachDirtySource::StatusChanged);
}
}
}
}
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.attached_context_id = attach_info.context_id;
view_state.can_write = attach_info.can_write;
update_attach_viewport(client, view_state.attached_id, view_state.status_position).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;
let status = attach_context_status_from_catalog(view_state);
set_attach_context_status(
view_state,
status,
Instant::now(),
ATTACH_TRANSIENT_STATUS_TTL,
);
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
}
#[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;
let normalized_event = terminal_event.normalized;
let raw_event = terminal_event.raw;
if matches!(&raw_event, Event::Resize(_, _)) {
update_attach_viewport_with_geometry(
client,
view_state.attached_id,
view_state.status_position,
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::Key(_) | TerminalInputEvent::Bytes(_)) => {
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::Key(_) | Event::Paste(_) => 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::Key(key)) = &normalized_event {
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::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_pane(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_focused_scrollback_window(client, view_state, false).await
{
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_and_status_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.help_overlay_scroll = 0;
}
view_state
.dirty
.mark_overlay_dirty(AttachDirtySource::HelpOverlay);
view_state
.dirty
.mark_status_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_status_dirty(AttachDirtySource::HelpOverlay);
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);
view_state
.dirty
.mark_status_dirty(AttachDirtySource::UserAction);
}
AttachEventAction::Redraw => {
view_state
.dirty
.mark_status_dirty(AttachDirtySource::ManualRedraw);
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.attached_context_id = attach_info.context_id;
view_state.can_write = attach_info.can_write;
update_attach_viewport(client, view_state.attached_id, view_state.status_position).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;
let status = attach_context_status_from_catalog(view_state);
set_attach_context_status(
view_state,
status,
Instant::now(),
ATTACH_TRANSIENT_STATUS_TTL,
);
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_windows_plugin_api::windows_commands::PaneAck = invoke_windows_command(
client,
"focus-pane",
&windows_commands::client::FocusPaneRequest { id: pane_id },
)
.await?;
let _ack: bmux_windows_plugin_api::windows_commands::PaneAck = invoke_windows_command(
client,
"close-pane",
&windows_commands::client::ClosePaneRequest { id: pane_id },
)
.await?;
Ok(())
}
async fn close_last_pane_after_retarget(
client: &mut StreamingBmuxClient,
view_state: &mut AttachViewState,
old_session_id: Uuid,
target: AttachCloseFallbackTarget,
) -> std::result::Result<(), ClientError> {
match 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?;
}
}
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: bmux_windows_plugin_api::windows_commands::WindowAck = invoke_windows_command(
client,
"new-window",
&windows_commands::client::NewWindowRequest { name: None },
)
.await?;
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-window 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> {
let _ack: bmux_windows_plugin_api::windows_commands::PaneAck = invoke_windows_command(
client,
"split-pane",
&windows_commands::client::SplitPaneRequest {
session: Some(ipc_to_windows_selector(SessionSelector::ById(session_id))),
target: Some(pane_id_windows_selector(pane_id)),
direction: windows_commands::PaneDirection::Horizontal,
ratio_pct: None,
},
)
.await?;
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_status_dirty(AttachDirtySource::PromptOverlay);
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>,
) -> Result<AttachLoopControl> {
let now = SystemAttachClock.now();
let action_str = &dispatch_request.action;
let action = match parse_action(action_str) {
Ok(action) => action,
Err(error) => {
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_pane(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
{
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);
view_state
.dirty
.mark_status_dirty(AttachDirtySource::ActionDispatch);
}
AttachEventAction::Redraw => {
view_state
.dirty
.mark_layout_frame_and_status_dirty(AttachDirtySource::ManualRedraw);
}
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);
}
fn attach_input_hooks() -> Vec<AttachInputHook> {
let mut hooks = Vec::new();
for extension in bmux_plugin::registered_render_extensions() {
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_codec::to_vec(&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_codec::from_bytes::<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 result.release_capture {
view_state.mouse.input_capture = None;
return;
}
if result.capture_pointer || !result.capture_keyboard.is_empty() {
view_state.mouse.input_capture = Some(AttachInputHookCapture {
hook,
pointer: result.capture_pointer,
keyboard_keys: result.capture_keyboard.clone(),
});
}
}
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::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_pane_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_throttled(view_state: &mut AttachViewState, hook: &AttachInputHook, now: Instant) -> bool {
let min_interval = Duration::from_millis(u64::from(hook.filter.min_interval_ms));
if min_interval.is_zero() {
view_state
.mouse
.input_hook_last_dispatched_at
.insert(hook.id.clone(), now);
return false;
}
if view_state
.mouse
.input_hook_last_dispatched_at
.get(&hook.id)
.is_some_and(|last| now.duration_since(*last) < min_interval)
{
return true;
}
view_state
.mouse
.input_hook_last_dispatched_at
.insert(hook.id.clone(), now);
false
}
async fn try_handle_attach_input_hook_mouse(
client: &mut StreamingBmuxClient,
view_state: &mut AttachViewState,
mouse_event: MouseEvent,
now: Instant,
capture_only: bool,
) -> std::result::Result<bool, ClientError> {
let (phase, button) = attach_input_mouse_phase(mouse_event.kind);
let focused = attach_input_focused_pane_context(view_state);
let hovered =
attach_input_hovered_pane_context(view_state, mouse_event.column, mouse_event.row);
let hooks = if let Some(capture) = view_state.mouse.input_capture.clone() {
if capture.pointer {
vec![capture.hook]
} else if capture_only {
return Ok(false);
} else {
attach_input_hooks()
}
} else if capture_only {
return Ok(false);
} else {
attach_input_hooks()
};
for hook in hooks {
if !capture_only && !hook_matches_mouse(&hook, phase, focused.as_ref(), hovered.as_ref()) {
continue;
}
if matches!(phase, "drag" | "move") && hook_throttled(view_state, &hook, now) {
return Ok(true);
}
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),
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?;
apply_attach_input_result(view_state, hook, &result);
if phase == "up"
&& let Some(capture) = view_state.mouse.input_capture.as_mut()
{
capture.pointer = false;
if capture.keyboard_keys.is_empty() {
view_state.mouse.input_capture = None;
}
}
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 == &key_name) || key_name == "esc" {
vec![capture.hook]
} else {
attach_input_hooks()
}
} else {
attach_input_hooks()
};
let focused = attach_input_focused_pane_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,
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)
}
#[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.resize_drag = None;
view_state.mouse.floating_drag = None;
view_state.mouse.tab_drag = None;
view_state.mouse.input_capture = None;
return Ok(());
}
if view_state.help_overlay_open || view_state.prompt.is_active() {
view_state.mouse.resize_drag = None;
view_state.mouse.floating_drag = None;
view_state.mouse.tab_drag = None;
view_state.mouse.input_capture = None;
return Ok(());
}
if try_handle_attach_input_hook_mouse(client, view_state, mouse_event, now, true).await? {
return Ok(());
}
if handle_attach_status_tab_mouse_event_at(
client,
view_state,
mouse_event,
kernel_client_factory,
now,
geometry,
)
.await?
{
return Ok(());
}
if try_handle_attach_input_hook_mouse(client, view_state, mouse_event, now, false).await? {
return Ok(());
}
if !view_state.can_write {
view_state.mouse.resize_drag = None;
view_state.mouse.floating_drag = None;
view_state.mouse.tab_drag = None;
return Ok(());
}
let resize_reduction =
reduce_attach_mouse_resize_event(view_state, TerminalMouseEvent::from(mouse_event), now);
if resize_reduction.consumed {
execute_attach_ui_effects(
client,
view_state,
resize_reduction.effects,
kernel_client_factory,
now,
)
.await?;
return Ok(());
}
let floating_reduction =
reduce_attach_mouse_floating_drag_event(view_state, TerminalMouseEvent::from(mouse_event));
if floating_reduction.consumed {
execute_attach_ui_effects(
client,
view_state,
floating_reduction.effects,
kernel_client_factory,
now,
)
.await?;
return Ok(());
}
let target_pane = attach_scene_pane_at(view_state, mouse_event.column, mouse_event.row);
let focused_pane = view_state
.cached_layout_state
.as_ref()
.map(|layout| layout.focused_pane_id);
let in_focused_pane = target_pane.is_some() && target_pane == focused_pane;
if matches!(
mouse_event.kind,
MouseEventKind::Drag(MouseButton::Left) | MouseEventKind::Up(MouseButton::Left)
) && handle_attach_mouse_selection_drag_at(view_state, mouse_event, now)
{
return Ok(());
}
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
) {
match view_state.mouse.config.effective_wheel_propagation() {
bmux_config::MouseWheelPropagation::Auto => {
let _ = handle_attach_mouse_wheel_auto(
client,
view_state,
mouse_event,
target_pane,
in_focused_pane,
)
.await?;
return Ok(());
}
bmux_config::MouseWheelPropagation::ForwardOnly => {
let _ = maybe_forward_attach_mouse_event(
client,
view_state,
mouse_event,
target_pane,
in_focused_pane,
false,
)
.await?;
return Ok(());
}
bmux_config::MouseWheelPropagation::ScrollbackOnly => {
let _ = handle_attach_mouse_scrollback_with_window(
client,
view_state,
mouse_event.kind,
)
.await?;
return Ok(());
}
bmux_config::MouseWheelPropagation::ForwardAndScrollback => {
let _ = maybe_forward_attach_mouse_event(
client,
view_state,
mouse_event,
target_pane,
in_focused_pane,
false,
)
.await?;
let _ = handle_attach_mouse_scrollback_with_window(
client,
view_state,
mouse_event.kind,
)
.await?;
return Ok(());
}
}
}
match mouse_event.kind {
MouseEventKind::Down(MouseButton::Left) => {
let target = target_pane;
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,
in_focused_pane,
false,
)
.await?;
}
bmux_config::MouseClickPropagation::FocusAndForward => {
let _ = maybe_forward_attach_mouse_event(
client,
view_state,
mouse_event,
target,
in_focused_pane,
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_pane,
in_focused_pane,
false,
)
.await?;
}
}
MouseEventKind::Moved => {
let _ = maybe_forward_attach_mouse_event(
client,
view_state,
mouse_event,
target_pane,
in_focused_pane,
false,
)
.await?;
if view_state.mouse.config.focus_on_hover {
let target = target_pane;
if target != view_state.mouse.hovered_pane_id {
view_state.mouse.hovered_pane_id = target;
view_state.mouse.hover_started_at = Some(now);
return Ok(());
}
let Some(pane_id) = target else {
view_state.mouse.hover_started_at = None;
return Ok(());
};
if view_state.mouse.last_focused_pane_id == Some(pane_id) {
return Ok(());
}
let Some(hover_started_at) = view_state.mouse.hover_started_at else {
view_state.mouse.hover_started_at = Some(now);
return Ok(());
};
if now.duration_since(hover_started_at)
>= Duration::from_millis(view_state.mouse.config.hover_delay_ms)
{
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?;
}
view_state.mouse.hover_started_at = Some(now);
}
}
}
MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight => {
let _ = maybe_forward_attach_mouse_event(
client,
view_state,
mouse_event,
target_pane,
in_focused_pane,
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.selection_drag = None;
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::FocusPane { 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::MoveFloatingPane {
pane_id: drag.pane_id,
x,
y,
})
}
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();
};
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::ResizePane {
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::ResizePane {
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_windows_plugin_api::windows_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 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,
}
});
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,
});
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,
}),
(Some(structured), None) => Some(structured),
(None, Some(hint)) => Some(hint),
(None, None) => None,
}
}
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,
target_pane: Option<Uuid>,
in_focused_pane: bool,
focus_before_forward: bool,
) -> std::result::Result<bool, ClientError> {
let Some(target_pane) = target_pane else {
return Ok(false);
};
if focus_before_forward && !in_focused_pane {
focus_attach_pane(client, view_state, target_pane).await?;
} else if !in_focused_pane {
return Ok(false);
}
let Some(bytes) = attach_mouse_forward_bytes_for_target(
view_state,
mouse_event,
Some(target_pane),
in_focused_pane || focus_before_forward,
) else {
return Ok(false);
};
client
.send_one_way_pane_direct_input(view_state.attached_id, target_pane, bytes)
.await?;
Ok(true)
}
pub fn attach_mouse_forward_bytes_for_target(
view_state: &AttachViewState,
mouse_event: MouseEvent,
target_pane: Option<Uuid>,
in_focused_pane: bool,
) -> Option<Vec<u8>> {
if !in_focused_pane {
return None;
}
let target_pane = target_pane?;
let protocol = attach_pane_mouse_protocol(view_state, target_pane)?;
let pane_content_rect = attach_scene_pane_content_rect(view_state, target_pane)?;
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 bmux_plugin::registered_render_extensions() {
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 handle_attach_status_tab_mouse_event_at(
client: &mut StreamingBmuxClient,
view_state: &mut AttachViewState,
mouse_event: MouseEvent,
kernel_client_factory: Option<&KernelClientFactory>,
now: Instant,
geometry: TerminalGeometry,
) -> std::result::Result<bool, ClientError> {
let reduction = reduce_attach_status_tab_mouse_event(
view_state,
TerminalMouseEvent::from(mouse_event),
geometry,
);
if !reduction.consumed {
return Ok(false);
}
execute_attach_ui_effects(
client,
view_state,
reduction.effects,
kernel_client_factory,
now,
)
.await?;
Ok(true)
}
pub fn reduce_attach_status_tab_mouse_event(
view_state: &mut AttachViewState,
mouse_event: TerminalMouseEvent,
geometry: TerminalGeometry,
) -> AttachUiReduction {
match (mouse_event.phase, mouse_event.button) {
(TerminalMousePhase::Down, Some(TerminalMouseButton::Left)) => {
let Some(source_context_id) =
attach_status_tab_context_at(view_state, mouse_event, geometry)
else {
view_state.mouse.tab_drag = None;
return AttachUiReduction::ignored();
};
if !view_state.mouse.tab_drag_enabled {
trace!("attach.status_tab_drag.disabled.mru_order");
return AttachUiReduction::with_effect(AttachUiEffect::SwitchWindow {
target_context_id: source_context_id,
});
}
let drop_target = view_state
.cached_status_line
.as_ref()
.and_then(|status_line| {
resolve_attach_tab_drop_target(status_line, mouse_event.col)
});
view_state.mouse.tab_drag = Some(AttachMouseTabDrag {
source_context_id,
started_col: mouse_event.col,
started_row: mouse_event.row,
active: false,
drop_target,
});
view_state.dirty.mark_status_dirty(AttachDirtySource::Mouse);
AttachUiReduction::consumed()
}
(TerminalMousePhase::Drag | TerminalMousePhase::Move, _) => {
if view_state.mouse.tab_drag.is_none() {
return AttachUiReduction::ignored();
}
let drop_target = if attach_status_mouse_row_matches(view_state, mouse_event, geometry)
{
view_state
.cached_status_line
.as_ref()
.and_then(|status_line| {
resolve_attach_tab_drop_target(status_line, mouse_event.col)
})
} else {
None
};
if let Some(drag) = view_state.mouse.tab_drag.as_mut() {
drag.active |= attach_tab_drag_motion_is_active(drag, mouse_event, drop_target);
drag.drop_target = drop_target;
}
view_state.dirty.mark_status_dirty(AttachDirtySource::Mouse);
AttachUiReduction::consumed()
}
(TerminalMousePhase::Up, Some(TerminalMouseButton::Left)) => {
let Some(mut drag) = view_state.mouse.tab_drag.take() else {
return AttachUiReduction::ignored();
};
let drop_target = if attach_status_mouse_row_matches(view_state, mouse_event, geometry)
{
view_state
.cached_status_line
.as_ref()
.and_then(|status_line| {
resolve_attach_tab_drop_target(status_line, mouse_event.col)
})
} else {
None
}
.or_else(|| drag.drop_target.take());
let drag_active = attach_tab_drag_motion_is_active(&drag, mouse_event, drop_target);
view_state
.dirty
.mark_layout_frame_and_status_dirty(AttachDirtySource::Mouse);
if let (true, Some(target)) = (drag_active, drop_target) {
if !view_state.can_write {
AttachUiReduction::with_effect(AttachUiEffect::ShowTransientStatus {
message: "tab reorder unavailable in read-only attach".to_string(),
})
} else if target.context_id == drag.source_context_id {
AttachUiReduction::consumed()
} else {
AttachUiReduction::with_effect(AttachUiEffect::MoveWindow {
source_context_id: drag.source_context_id,
target_context_id: target.context_id,
placement: target.placement,
})
}
} else if drag_active {
AttachUiReduction::consumed()
} else {
AttachUiReduction::with_effect(AttachUiEffect::SwitchWindow {
target_context_id: drag.source_context_id,
})
}
}
_ => AttachUiReduction::ignored(),
}
}
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::SwitchWindow { target_context_id } => {
switch_attach_status_tab(
client,
view_state,
target_context_id,
kernel_client_factory,
)
.await?;
view_state
.dirty
.mark_layout_frame_and_status_dirty(AttachDirtySource::Mouse);
}
AttachUiEffect::MoveWindow {
source_context_id,
target_context_id,
placement,
} => {
move_attach_status_tab(client, source_context_id, target_context_id, placement)
.await?;
optimistically_reorder_attach_window_list(
view_state,
source_context_id,
target_context_id,
placement,
);
}
AttachUiEffect::ResizePane {
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::FocusPane { pane_id } => {
focus_attach_pane(client, view_state, pane_id).await?;
}
AttachUiEffect::MoveFloatingPane { pane_id, x, y } => {
move_attach_floating_pane(client, view_state, pane_id, x, y).await?;
}
AttachUiEffect::ShowTransientStatus { message } => {
view_state.set_transient_status(message, now, ATTACH_TRANSIENT_STATUS_TTL);
}
}
}
Ok(())
}
const fn attach_status_mouse_row_matches(
view_state: &AttachViewState,
mouse_event: TerminalMouseEvent,
geometry: TerminalGeometry,
) -> bool {
let Some(status_row) = status_row_for_position(view_state.status_position, geometry.rows)
else {
return false;
};
status_row_matches_mouse(status_row, mouse_event.row, geometry.rows)
}
fn attach_status_tab_context_at(
view_state: &AttachViewState,
mouse_event: TerminalMouseEvent,
geometry: TerminalGeometry,
) -> Option<Uuid> {
if !attach_status_mouse_row_matches(view_state, mouse_event, geometry) {
return None;
}
view_state
.cached_status_line
.as_ref()
.and_then(|status_line| {
status_line
.tab_hitboxes
.iter()
.find(|hitbox| {
mouse_event.col >= hitbox.start_col && mouse_event.col <= hitbox.end_col
})
.map(|hitbox| hitbox.context_id)
})
}
pub fn resolve_attach_tab_drop_target(
status_line: &AttachStatusLine,
column: u16,
) -> Option<AttachTabDropTarget> {
let mut hitboxes = status_line.tab_hitboxes.iter().collect::<Vec<_>>();
hitboxes.sort_by_key(|hitbox| hitbox.start_col);
let first = hitboxes.first()?;
if column < first.start_col {
return Some(AttachTabDropTarget {
context_id: first.context_id,
placement: AttachTabDropPlacement::Before,
});
}
for hitbox in &hitboxes {
if column < hitbox.start_col {
return Some(AttachTabDropTarget {
context_id: hitbox.context_id,
placement: AttachTabDropPlacement::Before,
});
}
if column <= hitbox.end_col {
let width = hitbox
.end_col
.saturating_sub(hitbox.start_col)
.saturating_add(1);
let offset = column.saturating_sub(hitbox.start_col);
let placement = if u32::from(offset).saturating_mul(2) < u32::from(width) {
AttachTabDropPlacement::Before
} else {
AttachTabDropPlacement::After
};
return Some(AttachTabDropTarget {
context_id: hitbox.context_id,
placement,
});
}
}
hitboxes.last().map(|hitbox| AttachTabDropTarget {
context_id: hitbox.context_id,
placement: AttachTabDropPlacement::After,
})
}
pub(super) fn attach_tab_drop_marker_col(
status_line: &AttachStatusLine,
target: AttachTabDropTarget,
cols: u16,
) -> Option<u16> {
let hitbox = status_line
.tab_hitboxes
.iter()
.find(|hitbox| hitbox.context_id == target.context_id)?;
let col = match target.placement {
AttachTabDropPlacement::Before => hitbox.start_col,
AttachTabDropPlacement::After => hitbox.end_col.saturating_add(1),
};
Some(col.min(cols.saturating_sub(1)))
}
fn attach_tab_drag_motion_is_active(
drag: &AttachMouseTabDrag,
mouse_event: TerminalMouseEvent,
drop_target: Option<AttachTabDropTarget>,
) -> bool {
drag.active
|| drop_target.is_some_and(|target| target.context_id != drag.source_context_id)
|| mouse_event
.col
.abs_diff(drag.started_col)
.max(mouse_event.row.abs_diff(drag.started_row))
> ATTACH_TAB_DRAG_THRESHOLD_CELLS
}
const fn attach_tab_drop_placement_api(
placement: AttachTabDropPlacement,
) -> windows_commands::WindowMovePlacement {
match placement {
AttachTabDropPlacement::Before => windows_commands::WindowMovePlacement::Before,
AttachTabDropPlacement::After => windows_commands::WindowMovePlacement::After,
}
}
async fn move_attach_status_tab(
client: &mut StreamingBmuxClient,
source_context_id: Uuid,
target_context_id: Uuid,
placement: AttachTabDropPlacement,
) -> std::result::Result<(), ClientError> {
let _ack: windows_commands::WindowAck = invoke_windows_command(
client,
"move-window",
&windows_commands::client::MoveWindowRequest {
source: source_context_id,
target: target_context_id,
placement: attach_tab_drop_placement_api(placement),
},
)
.await?;
Ok(())
}
fn optimistically_reorder_attach_window_list(
view_state: &mut AttachViewState,
source_context_id: Uuid,
target_context_id: Uuid,
placement: AttachTabDropPlacement,
) {
if source_context_id == target_context_id {
return;
}
let Some(snapshot) = view_state.cached_window_list.as_ref() else {
return;
};
let mut next = snapshot.as_ref().clone();
let Some(source_index) = next
.windows
.iter()
.position(|entry| entry.id == source_context_id)
else {
return;
};
let source = next.windows.remove(source_index);
let Some(target_index) = next
.windows
.iter()
.position(|entry| entry.id == target_context_id)
else {
return;
};
let insert_index = match placement {
AttachTabDropPlacement::Before => target_index,
AttachTabDropPlacement::After => target_index.saturating_add(1),
};
next.windows
.insert(insert_index.min(next.windows.len()), source);
next.revision = next.revision.saturating_add(1);
view_state.cached_window_list = Some(std::sync::Arc::new(next));
}
async fn switch_attach_status_tab(
client: &mut StreamingBmuxClient,
view_state: &mut AttachViewState,
target_context_id: Uuid,
kernel_client_factory: Option<&KernelClientFactory>,
) -> std::result::Result<(), ClientError> {
debug!(target_context_id = %target_context_id, "attach.status_click.retarget");
handle_attach_plugin_command_action(
client,
"bmux.windows",
"switch-window",
&[target_context_id.to_string()],
view_state,
kernel_client_factory,
Vec::new(),
)
.await
}
pub const fn status_row_matches_mouse(status_row: u16, mouse_row: u16, rows: u16) -> bool {
if mouse_row == status_row {
return true;
}
if mouse_row > 0 && mouse_row.saturating_sub(1) == status_row {
return true;
}
rows > 0 && mouse_row == rows && status_row == rows.saturating_sub(1)
}
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_and_status_dirty(AttachDirtySource::Mouse);
Ok(true)
}
AttachEventAction::Ignore => Ok(true),
AttachEventAction::Detach
| AttachEventAction::Send(_)
| 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,
target_pane: Option<Uuid>,
in_focused_pane: bool,
) -> std::result::Result<bool, ClientError> {
if pane_mouse_protocol_reports_event(view_state, target_pane, mouse_event.kind)
&& maybe_forward_attach_mouse_event(
client,
view_state,
mouse_event,
target_pane,
in_focused_pane,
false,
)
.await?
{
return Ok(true);
}
if !in_focused_pane {
return Ok(false);
}
let Some(target_pane) = target_pane else {
return Ok(false);
};
if attach_pane_uses_alternate_screen(view_state, target_pane) {
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(target_pane),
in_focused_pane,
false,
)
.await
}
bmux_config::AlternateScreenWheelBehavior::ScrollbackOnly => {
handle_attach_mouse_scrollback_with_window(client, view_state, mouse_event.kind)
.await
}
};
}
handle_attach_mouse_scrollback_with_window(client, view_state, mouse_event.kind).await
}
pub fn pane_mouse_protocol_reports_event(
view_state: &AttachViewState,
target_pane: Option<Uuid>,
kind: MouseEventKind,
) -> bool {
let Some(target_pane) = target_pane else {
return false;
};
let Some(protocol) = attach_pane_mouse_protocol(view_state, target_pane) 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,
target_pane: 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(target_pane) = target_pane else {
return false;
};
if focused_attach_pane_id(view_state) != Some(target_pane) {
return false;
}
if pane_mouse_protocol_reports_event(view_state, Some(target_pane), mouse_event.kind)
|| attach_pane_uses_alternate_screen(view_state, target_pane)
{
return false;
}
let Some(anchor) = attach_mouse_scrollback_position_for_event(
view_state,
target_pane,
mouse_event.column,
mouse_event.row,
false,
) else {
return false;
};
view_state.mouse.selection_drag = Some(AttachMouseSelectionDrag {
pane_id: target_pane,
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 && !enter_attach_scrollback(view_state) {
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;
};
view_state.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);
view_state
.dirty
.mark_status_dirty(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);
view_state
.dirty
.mark_status_dirty(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));
Some(AttachScrollbackPosition {
row: view_state
.scrollback_offset
.saturating_add(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((inner_w, inner_h)) = focused_attach_pane_inner_size(view_state) else {
return false;
};
if inner_w == 0 || inner_h == 0 {
return false;
}
view_state.scrollback_cursor = Some(AttachScrollbackCursor {
row: position
.row
.saturating_sub(view_state.scrollback_offset)
.min(inner_h.saturating_sub(1)),
col: position.col.min(inner_w.saturating_sub(1)),
});
true
}
pub fn handle_attach_mouse_scrollback(
view_state: &mut AttachViewState,
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 && !enter_attach_scrollback(view_state) {
return false;
}
step_attach_scrollback(view_state, -lines);
view_state
.dirty
.mark_full_frame(AttachDirtySource::Scrollback);
view_state
.dirty
.mark_status_dirty(AttachDirtySource::Scrollback);
true
}
MouseEventKind::ScrollDown => {
if !view_state.scrollback_active {
return false;
}
step_attach_scrollback(view_state, lines);
if view_state.mouse.config.exit_scrollback_on_bottom
&& view_state.scrollback_offset == 0
&& !view_state.selection_active()
{
view_state.exit_scrollback();
}
view_state
.dirty
.mark_full_frame(AttachDirtySource::Scrollback);
view_state
.dirty
.mark_status_dirty(AttachDirtySource::Scrollback);
true
}
_ => false,
}
}
async fn send_attach_bytes_to_focused_pane(
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_windows_plugin_api::windows_commands::PaneAck = invoke_windows_command(
client,
"focus-floating-pane",
&windows_commands::client::FocusFloatingPaneRequest {
session: Some(ipc_to_windows_selector(selector)),
target: pane_id_windows_selector(pane_id),
},
)
.await?;
} else {
let _ack: bmux_windows_plugin_api::windows_commands::PaneAck = invoke_windows_command(
client,
"focus-pane-by-selector",
&windows_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_and_status_dirty(AttachDirtySource::FocusChanged);
Ok(())
}
async fn resize_attach_pane(
client: &mut StreamingBmuxClient,
view_state: &mut AttachViewState,
pane_id: Uuid,
direction: bmux_windows_plugin_api::windows_commands::PaneResizeDirection,
cells: u16,
) -> std::result::Result<(), ClientError> {
let selector = attached_session_selector(view_state);
let _ack: bmux_windows_plugin_api::windows_commands::PaneAck = invoke_windows_command(
client,
"resize-pane",
&windows_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_and_status_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_windows_plugin_api::windows_commands::PaneAck = invoke_windows_command(
client,
"move-floating-pane",
&windows_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_and_status_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_windows_plugin_api::windows_commands::PaneResizeDirection::Right,
negative_target_pane_id: right_pane,
negative_direction: bmux_windows_plugin_api::windows_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_windows_plugin_api::windows_commands::PaneResizeDirection::Down,
negative_target_pane_id: bottom_pane,
negative_direction: bmux_windows_plugin_api::windows_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!(stdout, DisableMouseCapture);
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::FocusGained | Event::FocusLost | Event::Paste(_) => {
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 { .. } => Ok(vec![AttachEventAction::Redraw]),
TerminalInputEvent::Bytes(_) => Ok(vec![AttachEventAction::Ignore]),
}
}
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,
code = ?key.code,
modifiers = ?key.modifiers,
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,
code = ?key.code,
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 {
#[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, AttachUiEffect, AttachUiMode, AttachViewState, PaneRenderBuffer,
};
use crate::status::AttachStatusTabHitbox;
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 crossterm::event::{
Event as CrosstermEvent, KeyCode as CrosstermKeyCode, KeyEvent as CrosstermKeyEvent,
KeyEventKind as CrosstermKeyEventKind, KeyModifiers, MouseButton, MouseEvent,
MouseEventKind,
};
use std::collections::{BTreeMap, BTreeSet};
use uuid::Uuid;
#[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));
}
fn focus_action(direction: &str) -> RuntimeAction {
RuntimeAction::PluginCommand {
plugin_id: "bmux.windows".to_string(),
command_name: "focus-pane-in-direction".to_string(),
args: vec!["--direction".to_string(), direction.to_string()],
}
}
fn tab_status_line(hitboxes: Vec<AttachStatusTabHitbox>) -> AttachStatusLine {
AttachStatusLine {
rendered: String::new(),
spans: Vec::new(),
tab_hitboxes: hitboxes,
drag_marker_col: None,
}
}
#[test]
fn attach_output_batch_unexpected_response_after_detach_is_stream_closed() {
let error = ClientError::ServerError {
code: bmux_ipc::ErrorCode::Internal,
message: "attach-pane-output-batch typed dispatch failed: unexpected response invoking attach-runtime-state/attach-pane-output-batch: expected service invoked".to_string(),
};
assert!(is_attach_not_attached_runtime_error(&error));
}
#[test]
fn tab_drop_target_uses_tab_halves_and_gaps() {
let first = Uuid::from_u128(1);
let second = Uuid::from_u128(2);
let third = Uuid::from_u128(3);
let status = tab_status_line(vec![
AttachStatusTabHitbox {
start_col: 2,
end_col: 5,
context_id: first,
},
AttachStatusTabHitbox {
start_col: 8,
end_col: 11,
context_id: second,
},
AttachStatusTabHitbox {
start_col: 14,
end_col: 17,
context_id: third,
},
]);
assert_eq!(
resolve_attach_tab_drop_target(&status, 1),
Some(AttachTabDropTarget {
context_id: first,
placement: AttachTabDropPlacement::Before,
})
);
assert_eq!(
resolve_attach_tab_drop_target(&status, 3),
Some(AttachTabDropTarget {
context_id: first,
placement: AttachTabDropPlacement::Before,
})
);
assert_eq!(
resolve_attach_tab_drop_target(&status, 4),
Some(AttachTabDropTarget {
context_id: first,
placement: AttachTabDropPlacement::After,
})
);
assert_eq!(
resolve_attach_tab_drop_target(&status, 7),
Some(AttachTabDropTarget {
context_id: second,
placement: AttachTabDropPlacement::Before,
})
);
assert_eq!(
resolve_attach_tab_drop_target(&status, 99),
Some(AttachTabDropTarget {
context_id: third,
placement: AttachTabDropPlacement::After,
})
);
assert_eq!(
attach_tab_drop_marker_col(
&status,
AttachTabDropTarget {
context_id: second,
placement: AttachTabDropPlacement::Before,
},
80,
),
Some(8)
);
assert_eq!(
attach_tab_drop_marker_col(
&status,
AttachTabDropTarget {
context_id: second,
placement: AttachTabDropPlacement::After,
},
80,
),
Some(12)
);
}
#[test]
fn tab_drag_motion_is_active_for_move_events_and_cross_tab_release() {
let first = Uuid::from_u128(1);
let second = Uuid::from_u128(2);
let drag = AttachMouseTabDrag {
source_context_id: first,
started_col: 4,
started_row: 23,
active: false,
drop_target: Some(AttachTabDropTarget {
context_id: first,
placement: AttachTabDropPlacement::Before,
}),
};
let same_cell = MouseEvent {
kind: MouseEventKind::Moved,
column: 4,
row: 23,
modifiers: KeyModifiers::empty(),
};
assert!(!attach_tab_drag_motion_is_active(
&drag,
same_cell.into(),
drag.drop_target
));
let moved_cell = MouseEvent {
kind: MouseEventKind::Moved,
column: 6,
row: 23,
modifiers: KeyModifiers::empty(),
};
assert!(attach_tab_drag_motion_is_active(
&drag,
moved_cell.into(),
drag.drop_target
));
assert!(attach_tab_drag_motion_is_active(
&drag,
same_cell.into(),
Some(AttachTabDropTarget {
context_id: second,
placement: AttachTabDropPlacement::After,
})
));
}
fn tab_reducer_view_state() -> AttachViewState {
let first = Uuid::from_u128(1);
let second = Uuid::from_u128(2);
let third = Uuid::from_u128(3);
let mut view_state = AttachViewState::new(AttachOpenInfo {
context_id: None,
session_id: Uuid::from_u128(99),
can_write: true,
});
view_state.mouse.tab_drag_enabled = true;
view_state.cached_status_line = Some(tab_status_line(vec![
AttachStatusTabHitbox {
start_col: 2,
end_col: 5,
context_id: first,
},
AttachStatusTabHitbox {
start_col: 8,
end_col: 11,
context_id: second,
},
AttachStatusTabHitbox {
start_col: 14,
end_col: 17,
context_id: third,
},
]));
view_state
}
const fn tab_reducer_geometry() -> TerminalGeometry {
TerminalGeometry { cols: 80, rows: 24 }
}
const fn tab_mouse_event(phase: TerminalMousePhase, col: u16, row: u16) -> TerminalMouseEvent {
TerminalMouseEvent {
phase,
button: Some(TerminalMouseButton::Left),
col,
row,
modifiers: TerminalModifiers {
shift: false,
control: false,
alt: false,
super_key: false,
hyper: false,
meta: false,
},
}
}
#[test]
fn status_tab_reducer_emits_move_after_motion() {
let mut view_state = tab_reducer_view_state();
let first = Uuid::from_u128(1);
let third = Uuid::from_u128(3);
let down = reduce_attach_status_tab_mouse_event(
&mut view_state,
tab_mouse_event(TerminalMousePhase::Down, 3, 23),
tab_reducer_geometry(),
);
assert!(down.consumed);
assert!(down.effects.is_empty());
let motion = reduce_attach_status_tab_mouse_event(
&mut view_state,
tab_mouse_event(TerminalMousePhase::Move, 17, 23),
tab_reducer_geometry(),
);
assert!(motion.consumed);
assert!(motion.effects.is_empty());
assert!(view_state.mouse.tab_drag.is_some_and(|drag| drag.active));
let up = reduce_attach_status_tab_mouse_event(
&mut view_state,
tab_mouse_event(TerminalMousePhase::Up, 17, 23),
tab_reducer_geometry(),
);
assert_eq!(
up.effects,
vec![AttachUiEffect::MoveWindow {
source_context_id: first,
target_context_id: third,
placement: AttachTabDropPlacement::After,
}]
);
assert!(view_state.mouse.tab_drag.is_none());
}
#[test]
fn status_tab_reducer_infers_drag_on_cross_tab_mouse_up() {
let mut view_state = tab_reducer_view_state();
let first = Uuid::from_u128(1);
let third = Uuid::from_u128(3);
let down = reduce_attach_status_tab_mouse_event(
&mut view_state,
tab_mouse_event(TerminalMousePhase::Down, 3, 23),
tab_reducer_geometry(),
);
assert!(down.consumed);
let up = reduce_attach_status_tab_mouse_event(
&mut view_state,
tab_mouse_event(TerminalMousePhase::Up, 17, 23),
tab_reducer_geometry(),
);
assert_eq!(
up.effects,
vec![AttachUiEffect::MoveWindow {
source_context_id: first,
target_context_id: third,
placement: AttachTabDropPlacement::After,
}]
);
}
#[test]
fn status_tab_reducer_click_without_drag_switches() {
let mut view_state = tab_reducer_view_state();
let first = Uuid::from_u128(1);
let down = reduce_attach_status_tab_mouse_event(
&mut view_state,
tab_mouse_event(TerminalMousePhase::Down, 3, 23),
tab_reducer_geometry(),
);
assert!(down.consumed);
let up = reduce_attach_status_tab_mouse_event(
&mut view_state,
tab_mouse_event(TerminalMousePhase::Up, 3, 23),
tab_reducer_geometry(),
);
assert_eq!(
up.effects,
vec![AttachUiEffect::SwitchWindow {
target_context_id: first,
}]
);
}
#[test]
fn status_tab_reducer_left_half_moves_before_target() {
let mut view_state = tab_reducer_view_state();
let first = Uuid::from_u128(1);
let third = Uuid::from_u128(3);
let down = reduce_attach_status_tab_mouse_event(
&mut view_state,
tab_mouse_event(TerminalMousePhase::Down, 15, 23),
tab_reducer_geometry(),
);
assert!(down.consumed);
let up = reduce_attach_status_tab_mouse_event(
&mut view_state,
tab_mouse_event(TerminalMousePhase::Up, 2, 23),
tab_reducer_geometry(),
);
assert_eq!(
up.effects,
vec![AttachUiEffect::MoveWindow {
source_context_id: third,
target_context_id: first,
placement: AttachTabDropPlacement::Before,
}]
);
}
#[test]
fn status_tab_reducer_right_half_moves_after_target() {
let mut view_state = tab_reducer_view_state();
let first = Uuid::from_u128(1);
let second = Uuid::from_u128(2);
let down = reduce_attach_status_tab_mouse_event(
&mut view_state,
tab_mouse_event(TerminalMousePhase::Down, 3, 23),
tab_reducer_geometry(),
);
assert!(down.consumed);
let up = reduce_attach_status_tab_mouse_event(
&mut view_state,
tab_mouse_event(TerminalMousePhase::Up, 11, 23),
tab_reducer_geometry(),
);
assert_eq!(
up.effects,
vec![AttachUiEffect::MoveWindow {
source_context_id: first,
target_context_id: second,
placement: AttachTabDropPlacement::After,
}]
);
}
#[test]
fn status_tab_reducer_gap_drop_resolves_to_nearest_insertion_point() {
let mut view_state = tab_reducer_view_state();
let first = Uuid::from_u128(1);
let third = Uuid::from_u128(3);
let down = reduce_attach_status_tab_mouse_event(
&mut view_state,
tab_mouse_event(TerminalMousePhase::Down, 3, 23),
tab_reducer_geometry(),
);
assert!(down.consumed);
let up = reduce_attach_status_tab_mouse_event(
&mut view_state,
tab_mouse_event(TerminalMousePhase::Up, 79, 23),
tab_reducer_geometry(),
);
assert_eq!(
up.effects,
vec![AttachUiEffect::MoveWindow {
source_context_id: first,
target_context_id: third,
placement: AttachTabDropPlacement::After,
}]
);
}
#[test]
fn status_tab_reducer_tracks_marker_target_and_clears_drag_after_release() {
let mut view_state = tab_reducer_view_state();
let first = Uuid::from_u128(1);
let second = Uuid::from_u128(2);
let down = reduce_attach_status_tab_mouse_event(
&mut view_state,
tab_mouse_event(TerminalMousePhase::Down, 3, 23),
tab_reducer_geometry(),
);
assert!(down.consumed);
let motion = reduce_attach_status_tab_mouse_event(
&mut view_state,
tab_mouse_event(TerminalMousePhase::Move, 8, 23),
tab_reducer_geometry(),
);
assert!(motion.consumed);
let drag = view_state.mouse.tab_drag.expect("active tab drag");
let drop_target = drag.drop_target.expect("drop target");
assert!(drag.active);
assert_eq!(drop_target.context_id, second);
assert_eq!(drop_target.placement, AttachTabDropPlacement::Before);
let status_line = view_state
.cached_status_line
.as_ref()
.expect("cached status line");
assert_eq!(
attach_tab_drop_marker_col(status_line, drop_target, tab_reducer_geometry().cols),
Some(8)
);
let up = reduce_attach_status_tab_mouse_event(
&mut view_state,
tab_mouse_event(TerminalMousePhase::Up, 8, 23),
tab_reducer_geometry(),
);
assert_eq!(
up.effects,
vec![AttachUiEffect::MoveWindow {
source_context_id: first,
target_context_id: second,
placement: AttachTabDropPlacement::Before,
}]
);
assert!(view_state.mouse.tab_drag.is_none());
}
#[test]
fn status_tab_reducer_mru_disables_move() {
let mut view_state = tab_reducer_view_state();
let first = Uuid::from_u128(1);
view_state.mouse.tab_drag_enabled = false;
let down = reduce_attach_status_tab_mouse_event(
&mut view_state,
tab_mouse_event(TerminalMousePhase::Down, 3, 23),
tab_reducer_geometry(),
);
assert_eq!(
down.effects,
vec![AttachUiEffect::SwitchWindow {
target_context_id: first,
}]
);
assert!(view_state.mouse.tab_drag.is_none());
let up = reduce_attach_status_tab_mouse_event(
&mut view_state,
tab_mouse_event(TerminalMousePhase::Up, 17, 23),
tab_reducer_geometry(),
);
assert!(!up.consumed);
assert!(up.effects.is_empty());
}
#[test]
fn optimistic_window_list_reorder_moves_entry_and_bumps_revision() {
let first = Uuid::from_u128(1);
let second = Uuid::from_u128(2);
let third = Uuid::from_u128(3);
let session_id = Uuid::from_u128(10);
let mut view_state = AttachViewState::new(AttachOpenInfo {
context_id: Some(first),
session_id,
can_write: true,
});
view_state.cached_window_list = Some(std::sync::Arc::new(
bmux_windows_plugin_api::windows_list::WindowListSnapshot {
windows: vec![
bmux_windows_plugin_api::windows_list::WindowListEntry {
id: first,
name: "one".to_string(),
active: true,
},
bmux_windows_plugin_api::windows_list::WindowListEntry {
id: second,
name: "two".to_string(),
active: false,
},
bmux_windows_plugin_api::windows_list::WindowListEntry {
id: third,
name: "three".to_string(),
active: false,
},
],
revision: 7,
},
));
optimistically_reorder_attach_window_list(
&mut view_state,
first,
third,
AttachTabDropPlacement::After,
);
let snapshot = view_state.cached_window_list.as_ref().expect("window list");
let order = snapshot
.windows
.iter()
.map(|entry| entry.id)
.collect::<Vec<_>>();
assert_eq!(order, vec![second, third, first]);
assert_eq!(snapshot.revision, 8);
assert!(snapshot.windows[2].active);
}
#[test]
fn tab_drag_is_disabled_for_mru_status_config() {
let mut config = BmuxConfig::default().status_bar;
assert!(status_tab_drag_enabled(&config));
config.tab_order = bmux_config::StatusTabOrder::Mru;
assert!(!status_tab_drag_enabled(&config));
config.tab_order = bmux_config::StatusTabOrder::Stable;
config.tab_scope = bmux_config::StatusTabScope::Mru;
assert!(!status_tab_drag_enabled(&config));
}
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),
}
}
#[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]
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 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
}
#[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 plugin_window_snapshot_uses_attached_context_as_active() {
let session_id = Uuid::new_v4();
let stale_active = Uuid::new_v4();
let attached_context = Uuid::new_v4();
let mut view_state = AttachViewState::new(AttachOpenInfo {
context_id: Some(attached_context),
session_id,
can_write: true,
});
view_state.cached_window_list = Some(std::sync::Arc::new(
bmux_windows_plugin_api::windows_list::WindowListSnapshot {
windows: vec![
bmux_windows_plugin_api::windows_list::WindowListEntry {
id: stale_active,
name: "old".to_string(),
active: true,
},
bmux_windows_plugin_api::windows_list::WindowListEntry {
id: attached_context,
name: "new".to_string(),
active: false,
},
],
revision: 1,
},
));
let contexts = vec![
ContextRow {
id: stale_active,
name: Some("old".to_string()),
},
ContextRow {
id: attached_context,
name: Some("new".to_string()),
},
];
let bindings = vec![
ContextSessionBinding {
context_id: stale_active,
session_id,
},
ContextSessionBinding {
context_id: attached_context,
session_id,
},
];
let tabs = build_attach_tabs_from_catalog(
&contexts,
&bindings,
&view_state,
&BmuxConfig::default().status_bar,
Some(attached_context),
session_id,
);
assert_eq!(tabs.iter().filter(|tab| tab.active).count(), 1);
assert!(
tabs.iter()
.any(|tab| tab.context_id == Some(attached_context) && tab.active)
);
assert!(
tabs.iter()
.any(|tab| tab.context_id == Some(stale_active) && !tab.active)
);
}
#[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.status_needs_redraw = false;
view_state.dirty.layout_needs_refresh = false;
view_state.dirty.full_pane_redraw = false;
apply_attach_view_change_components(&[AttachViewComponent::Status], &mut view_state);
assert!(view_state.dirty.status_needs_redraw);
assert!(!view_state.dirty.layout_needs_refresh);
assert!(!view_state.dirty.full_pane_redraw);
view_state.dirty.status_needs_redraw = false;
apply_attach_view_change_components(&[AttachViewComponent::Layout], &mut view_state);
assert!(view_state.dirty.status_needs_redraw);
assert!(view_state.dirty.layout_needs_refresh);
assert!(!view_state.dirty.full_pane_redraw);
view_state.dirty.status_needs_redraw = false;
view_state.dirty.layout_needs_refresh = false;
apply_attach_view_change_components(
&[AttachViewComponent::Scene, AttachViewComponent::Layout],
&mut view_state,
);
assert!(view_state.dirty.status_needs_redraw);
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 =
InputProcessor::new(attach_keymap_from_config(&BmuxConfig::default()), false);
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 =
InputProcessor::new(attach_keymap_from_config(&BmuxConfig::default()), false);
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.windows".to_string(),
command_name: "new-window".to_string(),
args: vec![],
};
assert!(!super::action_supports_repeat(&action));
}
#[test]
fn repeat_event_drops_plugin_command_action() {
let mut processor =
InputProcessor::new(attach_keymap_from_config(&BmuxConfig::default()), false);
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(_) => 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_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 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!(
view_state.selection_anchor,
Some(AttachScrollbackPosition { row: 1, col: 1 })
);
assert_eq!(
view_state.scrollback_cursor,
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!(view_state.selection_anchor, 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 { row: 1, col: 1 },
active: true,
});
assert!(enter_attach_scrollback(&mut view_state));
view_state.selection_anchor = Some(AttachScrollbackPosition { row: 1, col: 1 });
view_state.scrollback_cursor = 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.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_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=");
let mode = attach_pane_input_mode(&view_state, pane_id).expect("pane mode");
assert!(mode.application_cursor);
assert!(mode.application_keypad);
}
#[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,
},
);
let mode = attach_pane_input_mode(&view_state, pane_id).expect("pane mode");
assert!(mode.application_cursor);
assert!(mode.application_keypad);
}
#[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 target_pane = attach_scene_pane_at(&view_state, mouse_event.column, mouse_event.row);
let focused_pane = view_state
.cached_layout_state
.as_ref()
.map(|layout| layout.focused_pane_id);
let in_focused_pane = target_pane.is_some() && target_pane == focused_pane;
let forwarded = attach_mouse_forward_bytes_for_target(
&view_state,
mouse_event,
target_pane,
in_focused_pane,
);
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,
target_pane,
in_focused_pane,
)
.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.windows:new-window".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.windows" && command_name == "new-window" && 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 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_windows_plugin_api::windows_commands::PaneResizeDirection::Right
);
assert_eq!(horizontal.negative_target_pane_id, right_pane);
assert_eq!(
horizontal.negative_direction,
bmux_windows_plugin_api::windows_commands::PaneResizeDirection::Left
);
assert!(attach_scene_resize_separator_at(&view_state, 4, 3, now).is_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_windows_plugin_api::windows_commands::PaneResizeDirection::Down
);
assert_eq!(vertical.negative_target_pane_id, bottom_pane);
assert_eq!(
vertical.negative_direction,
bmux_windows_plugin_api::windows_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_windows_plugin_api::windows_commands::PaneResizeDirection::Right,
negative_target_pane_id: right_pane,
negative_direction:
bmux_windows_plugin_api::windows_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_windows_plugin_api::windows_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_windows_plugin_api::windows_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_windows_plugin_api::windows_commands::PaneResizeDirection::Right,
negative_target_pane_id: right_pane,
negative_direction:
bmux_windows_plugin_api::windows_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 =
InputProcessor::new(attach_keymap_from_config(&BmuxConfig::default()), false);
let new_window = 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_window.first(),
Some(AttachEventAction::PluginCommand { plugin_id, command_name, args })
if plugin_id == "bmux.windows" && command_name == "new-window" && 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.windows" && command_name == "next-window" && 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.windows" && command_name == "prev-window" && 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.windows" && command_name == "last-window" && 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.windows"
&& 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.windows"
&& 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 =
InputProcessor::new(attach_keymap_from_config(&BmuxConfig::default()), false);
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.windows"
&& 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.windows:goto-window 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.windows"
&& command_name == "goto-window"
&& 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 =
InputProcessor::new(attach_keymap_from_config(&BmuxConfig::default()), false);
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.windows"
&& 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 =
InputProcessor::new(attach_keymap_from_config(&BmuxConfig::default()), false);
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 attach_mode_hint_reflects_remapped_normal_mode_keys() {
let mut config = BmuxConfig::default();
config
.keybindings
.modes
.get_mut("normal")
.expect("normal mode")
.bindings
.insert("z".to_string(), "detach".to_string());
config
.keybindings
.modes
.get_mut("normal")
.expect("normal mode")
.bindings
.insert("d".to_string(), "quit".to_string());
let keymap = attach_keymap_from_config(&config);
let hint = attach_mode_hint("normal", AttachUiMode::Normal, &keymap);
assert!(hint.contains("z detach"));
assert!(hint.contains("d quit"));
}
#[test]
fn attach_mode_hint_includes_session_navigation_overrides() {
let mut config = BmuxConfig::default();
config
.keybindings
.global
.insert("alt+h".to_string(), "new_session".to_string());
config
.keybindings
.global
.insert("alt+l".to_string(), "detach".to_string());
config
.keybindings
.global
.insert("q".to_string(), "quit".to_string());
let keymap = attach_keymap_from_config(&config);
let hint = attach_mode_hint("normal", AttachUiMode::Normal, &keymap);
assert!(hint.contains("Ctrl-A d quit") || hint.contains("q quit"));
assert!(hint.contains("detach"));
}
#[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!(view_state.scrollback_offset, 0);
assert_eq!(
view_state.scrollback_cursor,
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!(
view_state.scrollback_cursor,
Some(AttachScrollbackCursor { row: 2, col: 2 })
);
assert_eq!(view_state.scrollback_offset, 0);
move_attach_scrollback_cursor_vertical(&mut view_state, -3);
assert_eq!(
view_state.scrollback_cursor,
Some(AttachScrollbackCursor { row: 0, col: 2 })
);
assert_eq!(view_state.scrollback_offset, 1);
move_attach_scrollback_cursor_vertical(&mut view_state, 1);
assert_eq!(
view_state.scrollback_cursor,
Some(AttachScrollbackCursor { row: 1, col: 2 })
);
assert_eq!(view_state.scrollback_offset, 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!(
view_state.scrollback_cursor,
Some(AttachScrollbackCursor { row: 3, col: 5 })
);
move_attach_scrollback_cursor_horizontal(&mut view_state, -10);
assert_eq!(
view_state.scrollback_cursor,
Some(AttachScrollbackCursor { row: 3, col: 0 })
);
}
#[test]
fn begin_attach_selection_uses_absolute_cursor_position() {
let mut view_state = attach_view_state_with_scrollback_fixture();
assert!(enter_attach_scrollback(&mut view_state));
view_state.scrollback_offset = 2;
assert!(begin_attach_selection(&mut view_state));
assert_eq!(
view_state.selection_anchor,
Some(AttachScrollbackPosition { row: 5, 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!(view_state.selection_anchor, 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));
view_state.selection_anchor = Some(AttachScrollbackPosition { row: 2, col: 2 });
view_state.scrollback_cursor = Some(AttachScrollbackCursor { row: 3, col: 8 });
view_state.scrollback_offset = 0;
assert_eq!(
selected_attach_text(&mut view_state),
Some("e\n four".to_string())
);
}
#[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 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!(view_state.scrollback_offset, 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));
view_state.scrollback_offset = 1;
assert!(handle_attach_mouse_scrollback(
&mut view_state,
MouseEventKind::ScrollDown,
));
assert!(!view_state.scrollback_active);
assert_eq!(view_state.scrollback_offset, 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_scrollback_hint_uses_default_bindings() {
let keymap = attach_keymap_from_config(&BmuxConfig::default());
let hint = attach_scrollback_hint(&keymap);
assert!(hint.contains("select"));
assert!(hint.contains("copy"));
assert!(hint.contains("page"));
assert!(hint.contains("top/bottom"));
assert!(hint.contains("exit scroll"));
}
#[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.windows:focus-pane-in-direction --direction next".to_string())
);
}
#[test]
fn attach_key_event_action_maps_show_help_to_ui() {
let config = BmuxConfig::default();
let keymap = attach_keymap_from_config(&config);
let mut processor = InputProcessor::new(keymap, false);
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.windows: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.windows:focus-pane-in-direction --direction left"
&& entry.action == focus_action("left")
}));
}
#[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.status_needs_redraw = false;
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.status_needs_redraw);
assert!(view_state.dirty.overlay_needs_redraw);
assert!(!view_state.dirty.full_pane_redraw);
}
#[test]
fn retained_status_surface_uses_render_ops_payload() {
let status_line = AttachStatusLine {
rendered: "NORMAL".to_owned(),
spans: vec![bmux_plugin::RenderTextSpan::new(
"NORMAL",
RenderStyle::new().bold(),
)],
tab_hitboxes: Vec::new(),
drag_marker_col: Some(3),
};
let surface = retained_status_surface(&status_line, StatusPosition::Bottom, (20, 5))
.expect("status surface");
assert_eq!(surface.id, STATUS_SURFACE_ID);
assert_eq!(surface.rect, DamageRect::new(0, 4, 20, 1));
assert_eq!(surface.opacity, RetainedOpacity::Opaque);
let RetainedSurfacePayload::RenderOps(ops) = surface.payload else {
panic!("status should lower to render ops");
};
assert_eq!(ops.len(), 2);
}
#[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);
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::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 status_only = bmux_attach_pipeline::FrameDamage::default();
status_only.mark_status();
assert!(!frame_uses_synchronized_update(&status_only));
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 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();
view_state.dirty.status_needs_redraw = false;
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);
assert!(view_state.dirty.status_needs_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);
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());
}
}