use super::cli_parse::{
RECORDING_AUTO_EXPORT_DIR_OVERRIDE_ENV, RECORDING_AUTO_EXPORT_OVERRIDE_ENV,
};
use super::{
BmuxConfig, BufWriter, ConfigPaths, ConnectionContext, ConnectionPolicyScope, Context,
GifEncoder, GifFrame, Instant, IsTerminal, Path, PathBuf, RecordingCursorBlinkMode,
RecordingCursorMode, RecordingCursorPaintMode, RecordingCursorProfile, RecordingCursorShape,
RecordingCursorTextMode, RecordingEventEnvelope, RecordingEventKind, RecordingEventKindArg,
RecordingExportFormat, RecordingListOrderArg, RecordingListSortArg, RecordingListStatusArg,
RecordingPaletteSource, RecordingProfileArg, RecordingRenderMode, RecordingReplayMode,
RecordingStatus, RecordingSummary, Repeat, Result, Uuid, Write, active_runtime_name,
cleanup_stale_pid_file, connect_if_running_with_context, current_cli_build_id, io,
parse_uuid_value, read_server_runtime_metadata, terminal,
};
use ab_glyph::{Font, FontArc, FontVec, PxScale, ScaleFont, point};
use bmux_cli_output::{Table, TableAlign, TableColumn, write_table};
use bmux_fonts::FontPreset;
use bmux_performance_state::{
PERF_RECORDING_SCHEMA_VERSION, PERF_RECORDING_SOURCE,
PerformanceRecordingLevel as RuntimePerformanceRecordingLevel,
PerformanceRuntimeSettings as RuntimePerformanceRuntimeSettings,
};
use bmux_plugin_sdk::{TypedDispatchClientError, TypedServiceClientError};
use bmux_recording_plugin_api::{
recording_commands, recording_events, recording_state, recording_types,
};
use bmux_recording_protocol::{
DisplayActivityKind, DisplayCursorShape, DisplayTrackEnvelope, DisplayTrackEvent,
RECORDING_FORMAT_VERSION, RecordingPayload as ProtocolRecordingPayload, RecordingProfile,
read_frames, write_frame,
};
use font8x8::UnicodeFonts;
use resvg::{tiny_skia, usvg};
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
use std::fmt::Write as _;
use std::sync::mpsc;
use std::thread;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
mod terminal_profile;
type RecordingPayload = ProtocolRecordingPayload<bmux_ipc::Event, bmux_ipc::ErrorCode>;
pub fn recording_plugin_error(error: recording_types::RecordingError) -> anyhow::Error {
match error {
recording_types::RecordingError::NoActive => anyhow::anyhow!("no active recording"),
recording_types::RecordingError::Unavailable => {
anyhow::anyhow!("recording runtime unavailable")
}
recording_types::RecordingError::Failed { reason } => anyhow::anyhow!(reason),
}
}
fn recording_service_client_error(error: TypedServiceClientError) -> anyhow::Error {
if let Some(details) = missing_recording_provider_details(&error) {
let mut message = format!(
"recording service is unavailable on the running bmux server ({details}). Restart the server with `bmux server stop` and retry; verify `bmux.recording` is enabled with `bmux plugin list`."
);
if let Some(hint) = stale_server_build_hint() {
message.push(' ');
message.push_str(&hint);
}
emit_recording_command_status(&message);
return anyhow::anyhow!(message);
}
anyhow::Error::new(error)
}
fn missing_recording_provider_details(error: &TypedServiceClientError) -> Option<String> {
let TypedServiceClientError::Dispatch(TypedDispatchClientError::Server { details, .. }) = error
else {
return None;
};
(details.contains("no provider for service capability='bmux.recording."))
.then(|| details.clone())
}
fn stale_server_build_hint() -> Option<String> {
let metadata = read_server_runtime_metadata().ok().flatten()?;
let cli_build = current_cli_build_id().ok()?;
(metadata.build_id != cli_build).then(|| {
format!(
"Running server build differs from current CLI build (server: {} at {}; cli: {}).",
metadata.build_id, metadata.executable_path, cli_build
)
})
}
pub(super) async fn run_recording_start(
session_id: Option<&str>,
capture_input: bool,
name: Option<&str>,
profile: Option<RecordingProfileArg>,
event_kinds: &[RecordingEventKindArg],
connection_context: ConnectionContext<'_>,
) -> Result<u8> {
let name = normalize_recording_name(name)?;
let runtime_config = BmuxConfig::load().unwrap_or_default();
cleanup_stale_pid_file().await?;
let mut client = connect_if_running_with_context(
ConnectionPolicyScope::Normal,
"bmux-cli-recording-start",
connection_context,
)
.await?
.ok_or_else(|| {
anyhow::anyhow!(
"recording start requires a running bmux server.\nRun `bmux server start --daemon` and retry."
)
})?;
let session_id = match session_id {
Some(raw) => Some(Uuid::parse_str(raw).context("invalid --session-id UUID")?),
None => None,
};
let profile_overridden = profile.is_some();
let effective_profile = profile.unwrap_or(RecordingProfileArg::Functional);
let profile = recording_profile_arg_to_ipc(Some(effective_profile));
let event_kinds = if profile_overridden || !event_kinds.is_empty() {
resolve_event_kind_override(Some(effective_profile), event_kinds, capture_input)
} else {
Some(default_event_kinds_from_config(capture_input))
};
let summary: RecordingSummary = recording_commands::client::start(
&mut client,
session_id,
capture_input,
name,
profile.map(Into::into),
event_kinds.map(|kinds| kinds.into_iter().map(Into::into).collect()),
)
.await?
.map(Into::into)
.map_err(recording_plugin_error)?;
let name_display = summary.name.as_deref().unwrap_or("-");
println!(
"recording started: {} name={} (capture_input={} profile={:?} kinds={})",
summary.id,
name_display,
summary.capture_input,
summary.profile,
summary
.event_kinds
.iter()
.map(|kind| recording_event_kind_name(*kind))
.collect::<Vec<_>>()
.join(",")
);
if performance_capture_enabled(runtime_config.performance.recording_level)
&& !event_kinds_include_custom(&summary.event_kinds)
{
eprintln!(
"bmux warning: performance recording level '{}' is enabled, but this recording does not include `custom` events; perf telemetry will be missing",
performance_recording_level_label(runtime_config.performance.recording_level)
);
}
Ok(0)
}
pub(super) const fn recording_profile_arg_to_ipc(
profile: Option<RecordingProfileArg>,
) -> Option<RecordingProfile> {
match profile {
Some(RecordingProfileArg::Full) => Some(RecordingProfile::Full),
Some(RecordingProfileArg::Functional) => Some(RecordingProfile::Functional),
Some(RecordingProfileArg::Visual) => Some(RecordingProfile::Visual),
None => None,
}
}
pub(super) fn resolve_event_kind_override(
profile: Option<RecordingProfileArg>,
event_kinds: &[RecordingEventKindArg],
capture_input: bool,
) -> Option<Vec<RecordingEventKind>> {
if !event_kinds.is_empty() {
return Some(
event_kinds
.iter()
.copied()
.map(recording_event_kind_arg_to_ipc)
.collect(),
);
}
let profile = profile?;
let mut kinds = match profile {
RecordingProfileArg::Full => vec![
RecordingEventKind::PaneOutputRaw,
RecordingEventKind::ProtocolReplyRaw,
RecordingEventKind::PaneImage,
RecordingEventKind::ServerEvent,
RecordingEventKind::RequestStart,
RecordingEventKind::RequestDone,
RecordingEventKind::RequestError,
RecordingEventKind::Custom,
],
RecordingProfileArg::Functional => vec![
RecordingEventKind::PaneOutputRaw,
RecordingEventKind::PaneImage,
RecordingEventKind::ServerEvent,
RecordingEventKind::RequestStart,
RecordingEventKind::RequestDone,
RecordingEventKind::RequestError,
RecordingEventKind::Custom,
],
RecordingProfileArg::Visual => vec![RecordingEventKind::PaneOutputRaw],
};
if capture_input && profile != RecordingProfileArg::Visual {
kinds.push(RecordingEventKind::PaneInputRaw);
}
Some(kinds)
}
const fn recording_event_kind_arg_to_ipc(kind: RecordingEventKindArg) -> RecordingEventKind {
match kind {
RecordingEventKindArg::PaneInputRaw => RecordingEventKind::PaneInputRaw,
RecordingEventKindArg::PaneOutputRaw => RecordingEventKind::PaneOutputRaw,
RecordingEventKindArg::ProtocolReplyRaw => RecordingEventKind::ProtocolReplyRaw,
RecordingEventKindArg::PaneImage => RecordingEventKind::PaneImage,
RecordingEventKindArg::ServerEvent => RecordingEventKind::ServerEvent,
RecordingEventKindArg::RequestStart => RecordingEventKind::RequestStart,
RecordingEventKindArg::RequestDone => RecordingEventKind::RequestDone,
RecordingEventKindArg::RequestError => RecordingEventKind::RequestError,
RecordingEventKindArg::Custom => RecordingEventKind::Custom,
}
}
fn default_event_kinds_from_config(capture_input: bool) -> Vec<RecordingEventKind> {
let config = BmuxConfig::load().unwrap_or_default();
default_event_kinds_for_flags(
capture_input && config.recording.capture_input,
config.recording.capture_output,
config.recording.capture_events,
)
}
fn default_event_kinds_for_flags(
capture_input: bool,
capture_output: bool,
capture_events: bool,
) -> Vec<RecordingEventKind> {
let mut kinds = Vec::new();
if capture_input {
kinds.push(RecordingEventKind::PaneInputRaw);
}
if capture_output {
kinds.push(RecordingEventKind::PaneOutputRaw);
}
if capture_events {
kinds.extend([
RecordingEventKind::ServerEvent,
RecordingEventKind::RequestStart,
RecordingEventKind::RequestDone,
RecordingEventKind::RequestError,
RecordingEventKind::Custom,
]);
}
if kinds.is_empty() {
kinds.push(RecordingEventKind::PaneOutputRaw);
}
kinds
}
const fn performance_recording_level_label(
level: bmux_config::PerformanceRecordingLevel,
) -> &'static str {
match level {
bmux_config::PerformanceRecordingLevel::Off => "off",
bmux_config::PerformanceRecordingLevel::Basic => "basic",
bmux_config::PerformanceRecordingLevel::Detailed => "detailed",
bmux_config::PerformanceRecordingLevel::Trace => "trace",
}
}
const fn performance_capture_enabled(level: bmux_config::PerformanceRecordingLevel) -> bool {
!matches!(level, bmux_config::PerformanceRecordingLevel::Off)
}
fn event_kinds_include_custom(kinds: &[RecordingEventKind]) -> bool {
kinds.contains(&RecordingEventKind::Custom)
}
fn normalize_recording_name(name: Option<&str>) -> Result<Option<String>> {
let Some(name) = name else {
return Ok(None);
};
let trimmed = name.trim();
if trimmed.is_empty() {
anyhow::bail!("recording name cannot be empty")
}
Ok(Some(trimmed.to_string()))
}
#[allow(clippy::struct_excessive_bools)] #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
struct RecordingConfigStatus {
capture_input: bool,
capture_output: bool,
capture_events: bool,
default_event_kinds: Vec<RecordingEventKind>,
performance_recording_level: bmux_config::PerformanceRecordingLevel,
perf_custom_events_enabled_by_default: bool,
segment_mb: usize,
retention_days: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, Default)]
struct RecordingStorageUsage {
#[serde(default)]
bytes: u64,
#[serde(default)]
files: u64,
#[serde(default)]
directories: u64,
#[serde(default)]
recording_dirs: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
struct RecordingStatusView {
active: Option<RecordingSummary>,
queue_len: usize,
root_path: String,
config: RecordingConfigStatus,
usage: RecordingStorageUsage,
}
#[derive(Debug, Clone)]
struct RecordingAutoExportSettings {
enabled: bool,
output_dir: Option<PathBuf>,
fps: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum RecordingAutoExportOutcome {
Disabled,
Exported { output_path: PathBuf },
Failed { output_path: PathBuf, error: String },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(super) enum PerfCaptureLevel {
Off,
Basic,
Detailed,
Trace,
}
impl PerfCaptureLevel {
#[must_use]
pub(super) const fn from_config(level: bmux_config::PerformanceRecordingLevel) -> Self {
match level {
bmux_config::PerformanceRecordingLevel::Off => Self::Off,
bmux_config::PerformanceRecordingLevel::Basic => Self::Basic,
bmux_config::PerformanceRecordingLevel::Detailed => Self::Detailed,
bmux_config::PerformanceRecordingLevel::Trace => Self::Trace,
}
}
#[must_use]
pub(super) const fn from_runtime(level: RuntimePerformanceRecordingLevel) -> Self {
match level {
RuntimePerformanceRecordingLevel::Off => Self::Off,
RuntimePerformanceRecordingLevel::Basic => Self::Basic,
RuntimePerformanceRecordingLevel::Detailed => Self::Detailed,
RuntimePerformanceRecordingLevel::Trace => Self::Trace,
}
}
#[must_use]
pub(super) const fn from_plugin(
level: bmux_performance_plugin_api::performance_types::PerformanceRecordingLevel,
) -> Self {
match level {
bmux_performance_plugin_api::performance_types::PerformanceRecordingLevel::Off => {
Self::Off
}
bmux_performance_plugin_api::performance_types::PerformanceRecordingLevel::Basic => {
Self::Basic
}
bmux_performance_plugin_api::performance_types::PerformanceRecordingLevel::Detailed => {
Self::Detailed
}
bmux_performance_plugin_api::performance_types::PerformanceRecordingLevel::Trace => {
Self::Trace
}
}
}
#[must_use]
pub(super) const fn as_str(self) -> &'static str {
match self {
Self::Off => "off",
Self::Basic => "basic",
Self::Detailed => "detailed",
Self::Trace => "trace",
}
}
}
#[derive(Debug, Clone, Copy)]
pub(super) struct PerfCaptureSettings {
level: PerfCaptureLevel,
window_ms: u64,
max_events_per_sec: u32,
max_payload_bytes_per_sec: usize,
}
impl PerfCaptureSettings {
#[must_use]
pub(super) fn from_config(config: &BmuxConfig) -> Self {
let perf = &config.performance;
Self {
level: PerfCaptureLevel::from_config(perf.recording_level),
window_ms: perf.window_ms.max(1),
max_events_per_sec: perf.max_events_per_sec.max(1),
max_payload_bytes_per_sec: perf.max_payload_bytes_per_sec.max(1),
}
}
#[must_use]
pub(super) fn from_runtime_settings(settings: &RuntimePerformanceRuntimeSettings) -> Self {
Self {
level: PerfCaptureLevel::from_runtime(settings.recording_level),
window_ms: settings.window_ms.max(1),
max_events_per_sec: settings.max_events_per_sec.max(1),
max_payload_bytes_per_sec: settings.max_payload_bytes_per_sec.max(1),
}
}
#[must_use]
pub(super) fn from_plugin_settings(
settings: &bmux_performance_plugin_api::performance_types::PerformanceRuntimeSettings,
) -> Self {
Self {
level: PerfCaptureLevel::from_plugin(settings.recording_level),
window_ms: settings.window_ms.max(1),
max_events_per_sec: settings.max_events_per_sec.max(1),
max_payload_bytes_per_sec: usize::try_from(settings.max_payload_bytes_per_sec)
.unwrap_or(usize::MAX)
.max(1),
}
}
}
#[derive(Debug)]
pub(super) struct PerfEventEmitter {
settings: PerfCaptureSettings,
rate_window_started_at: Instant,
emitted_events_in_window: u32,
emitted_payload_bytes_in_window: usize,
dropped_events_since_emit: u64,
dropped_payload_bytes_since_emit: u64,
}
impl PerfEventEmitter {
#[must_use]
pub(super) fn new(settings: PerfCaptureSettings) -> Self {
Self {
settings,
rate_window_started_at: Instant::now(),
emitted_events_in_window: 0,
emitted_payload_bytes_in_window: 0,
dropped_events_since_emit: 0,
dropped_payload_bytes_since_emit: 0,
}
}
pub(super) fn update_settings(&mut self, settings: PerfCaptureSettings) {
self.settings = settings;
self.rate_window_started_at = Instant::now();
self.emitted_events_in_window = 0;
self.emitted_payload_bytes_in_window = 0;
self.dropped_events_since_emit = 0;
self.dropped_payload_bytes_since_emit = 0;
}
#[must_use]
pub(super) const fn window_ms(&self) -> u64 {
self.settings.window_ms
}
#[must_use]
pub(super) fn level_at_least(&self, level: PerfCaptureLevel) -> bool {
self.settings.level >= level
}
#[must_use]
pub(super) fn enabled(&self) -> bool {
self.settings.level != PerfCaptureLevel::Off
}
fn reset_rate_window_if_needed(&mut self) {
if self.rate_window_started_at.elapsed() >= std::time::Duration::from_secs(1) {
self.rate_window_started_at = Instant::now();
self.emitted_events_in_window = 0;
self.emitted_payload_bytes_in_window = 0;
}
}
fn can_emit_payload(&mut self, payload_len: usize) -> bool {
if !self.enabled() {
return false;
}
self.reset_rate_window_if_needed();
let event_limit_hit = self.emitted_events_in_window >= self.settings.max_events_per_sec;
let payload_limit_hit = self
.emitted_payload_bytes_in_window
.saturating_add(payload_len)
> self.settings.max_payload_bytes_per_sec;
if event_limit_hit || payload_limit_hit {
self.dropped_events_since_emit = self.dropped_events_since_emit.saturating_add(1);
self.dropped_payload_bytes_since_emit = self
.dropped_payload_bytes_since_emit
.saturating_add(u64::try_from(payload_len).unwrap_or(u64::MAX));
return false;
}
self.emitted_events_in_window = self.emitted_events_in_window.saturating_add(1);
self.emitted_payload_bytes_in_window = self
.emitted_payload_bytes_in_window
.saturating_add(payload_len);
true
}
fn normalized_payload(&mut self, payload: serde_json::Value) -> serde_json::Value {
let mut object = match payload {
serde_json::Value::Object(map) => map,
other => {
let mut map = serde_json::Map::new();
map.insert("value".to_string(), other);
map
}
};
object.insert(
"schema_version".to_string(),
serde_json::Value::from(PERF_RECORDING_SCHEMA_VERSION),
);
object.insert(
"level".to_string(),
serde_json::Value::String(self.settings.level.as_str().to_string()),
);
object.insert(
"runtime".to_string(),
serde_json::Value::String(active_runtime_name()),
);
object.insert(
"ts_epoch_ms".to_string(),
serde_json::Value::from(epoch_millis_now()),
);
if self.dropped_events_since_emit > 0 || self.dropped_payload_bytes_since_emit > 0 {
object.insert(
"dropped_events_since_emit".to_string(),
serde_json::Value::from(self.dropped_events_since_emit),
);
object.insert(
"dropped_payload_bytes_since_emit".to_string(),
serde_json::Value::from(self.dropped_payload_bytes_since_emit),
);
self.dropped_events_since_emit = 0;
self.dropped_payload_bytes_since_emit = 0;
}
serde_json::Value::Object(object)
}
pub(super) async fn emit_with_client(
&mut self,
client: &mut bmux_client::BmuxClient,
session_id: Option<Uuid>,
pane_id: Option<Uuid>,
event_name: &str,
payload: serde_json::Value,
) -> Result<()> {
if !self.enabled() {
return Ok(());
}
let payload = self.normalized_payload(payload);
let encoded = serde_json::to_vec(&payload).context("failed encoding perf payload")?;
if !self.can_emit_payload(encoded.len()) {
return Ok(());
}
recording_commands::client::write_custom_event(
client,
session_id,
pane_id,
PERF_RECORDING_SOURCE.to_string(),
event_name.to_string(),
encoded,
)
.await?
.map_err(recording_plugin_error)
}
pub(super) async fn emit_with_streaming_client(
&mut self,
client: &mut bmux_client::StreamingBmuxClient,
session_id: Option<Uuid>,
pane_id: Option<Uuid>,
event_name: &str,
payload: serde_json::Value,
) -> Result<()> {
if !self.enabled() {
return Ok(());
}
let payload = self.normalized_payload(payload);
let encoded = serde_json::to_vec(&payload).context("failed encoding perf payload")?;
if !self.can_emit_payload(encoded.len()) {
return Ok(());
}
recording_commands::client::write_custom_event(
client,
session_id,
pane_id,
PERF_RECORDING_SOURCE.to_string(),
event_name.to_string(),
encoded,
)
.await?
.map_err(recording_plugin_error)
}
}
#[allow(clippy::cast_possible_truncation)]
fn epoch_millis_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| duration.as_millis() as u64)
}
fn parse_bool_env_flag(raw: &str) -> Option<bool> {
match raw.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Some(true),
"0" | "false" | "no" | "off" => Some(false),
_ => None,
}
}
fn env_path_override(name: &str) -> Option<PathBuf> {
let raw = std::env::var_os(name)?;
if raw.is_empty() {
return None;
}
let path = PathBuf::from(raw);
if path.is_absolute() {
return Some(path);
}
match std::env::current_dir() {
Ok(cwd) => Some(cwd.join(path)),
Err(_) => Some(path),
}
}
fn recording_auto_export_settings() -> RecordingAutoExportSettings {
let paths = ConfigPaths::default();
let config = BmuxConfig::load_from_path(&paths.config_file()).unwrap_or_default();
let enabled = std::env::var(RECORDING_AUTO_EXPORT_OVERRIDE_ENV)
.ok()
.map_or(config.recording.auto_export, |raw| {
parse_bool_env_flag(&raw).unwrap_or_else(|| {
tracing::warn!(
"ignoring invalid {} value {:?}",
RECORDING_AUTO_EXPORT_OVERRIDE_ENV,
raw
);
config.recording.auto_export
})
});
let output_dir = env_path_override(RECORDING_AUTO_EXPORT_DIR_OVERRIDE_ENV)
.or_else(|| config.recording_auto_export_dir(&paths));
RecordingAutoExportSettings {
enabled,
output_dir,
fps: config.recording.export.fps.max(1),
}
}
fn auto_export_default_dir(recording_dir: &Path) -> PathBuf {
recording_dir
.parent()
.map_or_else(|| recording_dir.to_path_buf(), std::path::Path::to_path_buf)
}
fn auto_export_filename_stem(timestamp: time::OffsetDateTime) -> String {
let hour = timestamp.hour();
let (hour12, meridiem) = match hour {
0 => (12_u8, "AM"),
1..=11 => (hour, "AM"),
12 => (12_u8, "PM"),
_ => (hour - 12, "PM"),
};
format!(
"Recording {:04}-{:02}-{:02} at {}.{:02}.{:02} {meridiem}",
timestamp.year(),
u8::from(timestamp.month()),
timestamp.day(),
hour12,
timestamp.minute(),
timestamp.second(),
)
}
fn unique_auto_export_path(output_dir: &Path, stem: &str) -> PathBuf {
let mut candidate = output_dir.join(format!("{stem}.gif"));
if !candidate.exists() {
return candidate;
}
let mut suffix = 2_u32;
loop {
candidate = output_dir.join(format!("{stem} {suffix}.gif"));
if !candidate.exists() {
return candidate;
}
suffix = suffix.saturating_add(1);
}
}
fn auto_export_output_path(recording_dir: &Path, explicit_output_dir: Option<&Path>) -> PathBuf {
let output_dir = explicit_output_dir.map_or_else(
|| auto_export_default_dir(recording_dir),
std::path::Path::to_path_buf,
);
unique_auto_export_path(
&output_dir,
&auto_export_filename_stem(time::OffsetDateTime::now_utc()),
)
}
pub(super) async fn maybe_auto_export_recording(
recording_id: Uuid,
recording_path: Option<&Path>,
fps_override: Option<u32>,
) -> RecordingAutoExportOutcome {
let settings = recording_auto_export_settings();
if !settings.enabled {
return RecordingAutoExportOutcome::Disabled;
}
let recording_dir = recording_path.map_or_else(
|| recordings_root_dir().join(recording_id.to_string()),
std::path::Path::to_path_buf,
);
let output_path = auto_export_output_path(&recording_dir, settings.output_dir.as_deref());
let output = output_path.to_string_lossy().into_owned();
publish_recording_export_started(recording_id, output.clone());
let recording_id_string = recording_id.to_string();
let fps = fps_override.unwrap_or(settings.fps).max(1);
match super::recording_cli::run_recording_auto_export_gif(
&recording_id_string,
&output,
Some(fps),
)
.await
{
Ok(_) => {
tracing::info!(
%recording_id,
output_path = %output_path.display(),
fps,
"recording auto-export completed"
);
publish_recording_export_completed(recording_id, output);
RecordingAutoExportOutcome::Exported { output_path }
}
Err(error) => {
let error = error.to_string();
tracing::warn!(
%recording_id,
output_path = %output_path.display(),
fps,
error = %error,
"recording auto-export failed"
);
publish_recording_export_failed(recording_id, output, error.clone());
RecordingAutoExportOutcome::Failed { output_path, error }
}
}
}
fn publish_recording_export_started(recording_id: Uuid, output_path: String) {
let _ = bmux_plugin::global_event_bus().emit(
&recording_events::EVENT_KIND,
recording_events::RecordingEvent::ExportStarted {
recording_id,
output_path,
},
);
}
fn publish_recording_export_completed(recording_id: Uuid, output_path: String) {
let _ = bmux_plugin::global_event_bus().emit(
&recording_events::EVENT_KIND,
recording_events::RecordingEvent::ExportCompleted {
recording_id,
output_path,
},
);
}
fn publish_recording_export_failed(recording_id: Uuid, output_path: String, reason: String) {
let _ = bmux_plugin::global_event_bus().emit(
&recording_events::EVENT_KIND,
recording_events::RecordingEvent::ExportFailed {
recording_id,
output_path,
reason,
},
);
}
fn emit_recording_command_status(message: impl Into<String>) {
bmux_plugin_sdk::record_command_outcome_metadata(
bmux_plugin_sdk::COMMAND_OUTCOME_STATUS_MESSAGE_KEY,
serde_json::json!(message.into()),
);
}
fn print_auto_export_outcome(recording_id: Uuid, outcome: &RecordingAutoExportOutcome) {
match outcome {
RecordingAutoExportOutcome::Disabled => {}
RecordingAutoExportOutcome::Exported { output_path } => {
println!("recording auto-exported: {}", output_path.display());
}
RecordingAutoExportOutcome::Failed { output_path, error } => {
eprintln!(
"bmux warning: recording auto-export failed for {} (output={}): {}",
recording_id,
output_path.display(),
error
);
}
}
}
fn auto_export_status_suffix(outcome: &RecordingAutoExportOutcome) -> Option<String> {
match outcome {
RecordingAutoExportOutcome::Disabled => None,
RecordingAutoExportOutcome::Exported { output_path } => {
Some(format!("; GIF exported to {}", output_path.display()))
}
RecordingAutoExportOutcome::Failed { output_path, error } => Some(format!(
"; GIF export failed for {}: {error}",
output_path.display()
)),
}
}
fn recording_config_and_root() -> (RecordingConfigStatus, PathBuf) {
let paths = ConfigPaths::default();
let (config, root) = BmuxConfig::load_from_path(&paths.config_file()).map_or_else(
|_| (BmuxConfig::default(), paths.recordings_dir()),
|config| {
let root = config.recordings_dir(&paths);
(config, root)
},
);
let capture_input = config.recording.capture_input;
let capture_output = config.recording.capture_output;
let capture_events = config.recording.capture_events;
let default_event_kinds =
default_event_kinds_for_flags(capture_input, capture_output, capture_events);
(
RecordingConfigStatus {
capture_input,
capture_output,
capture_events,
performance_recording_level: config.performance.recording_level,
perf_custom_events_enabled_by_default: event_kinds_include_custom(&default_event_kinds),
default_event_kinds,
segment_mb: config.recording.segment_mb,
retention_days: config.recording.retention_days,
},
root,
)
}
fn collect_recording_storage_usage(root: &Path) -> Result<RecordingStorageUsage> {
if !root.exists() {
return Ok(RecordingStorageUsage::default());
}
let mut usage = RecordingStorageUsage::default();
collect_recording_storage_usage_recursive(root, &mut usage, true)?;
Ok(usage)
}
fn collect_recording_storage_usage_recursive(
dir: &Path,
usage: &mut RecordingStorageUsage,
is_root: bool,
) -> Result<()> {
for entry in std::fs::read_dir(dir)
.with_context(|| format!("failed reading recordings dir {}", dir.display()))?
{
let entry = entry?;
let file_type = entry.file_type()?;
let path = entry.path();
if file_type.is_dir() {
if is_root && entry.file_name() == ".rolling" {
continue;
}
usage.directories = usage.directories.saturating_add(1);
if path.join("manifest.json").exists() {
usage.recording_dirs = usage.recording_dirs.saturating_add(1);
}
collect_recording_storage_usage_recursive(&path, usage, false)?;
continue;
}
if file_type.is_file() {
usage.files = usage.files.saturating_add(1);
usage.bytes = usage.bytes.saturating_add(entry.metadata()?.len());
}
}
Ok(())
}
#[allow(clippy::cast_precision_loss)] fn format_byte_size(bytes: u64) -> String {
const KIB: u64 = 1024;
const MIB: u64 = KIB * 1024;
const GIB: u64 = MIB * 1024;
if bytes >= GIB {
format!("{:.2} GiB", bytes as f64 / GIB as f64)
} else if bytes >= MIB {
format!("{:.2} MiB", bytes as f64 / MIB as f64)
} else if bytes >= KIB {
format!("{:.2} KiB", bytes as f64 / KIB as f64)
} else {
format!("{bytes} B")
}
}
const fn recording_status_label(ended_epoch_ms: Option<u64>) -> &'static str {
if ended_epoch_ms.is_some() {
"done"
} else {
"active"
}
}
fn format_recording_age(started_epoch_ms: u64, now_epoch_ms: u64) -> String {
const MINUTE: u64 = 60;
const HOUR: u64 = 60 * MINUTE;
const DAY: u64 = 24 * HOUR;
const WEEK: u64 = 7 * DAY;
const YEAR: u64 = 365 * DAY;
let elapsed_secs = now_epoch_ms.saturating_sub(started_epoch_ms) / 1_000;
if elapsed_secs == 0 {
return "now".to_string();
}
let (value, unit) = if elapsed_secs < MINUTE {
(elapsed_secs, "s")
} else if elapsed_secs < HOUR {
(elapsed_secs / MINUTE, "m")
} else if elapsed_secs < DAY {
(elapsed_secs / HOUR, "h")
} else if elapsed_secs < WEEK {
(elapsed_secs / DAY, "d")
} else if elapsed_secs < YEAR {
(elapsed_secs / WEEK, "w")
} else {
(elapsed_secs / YEAR, "y")
};
format!("{value}{unit} ago")
}
fn write_stdout_table(table: &Table) -> Result<()> {
let mut stdout = io::stdout().lock();
write_table(&mut stdout, table).context("failed rendering recording list table")
}
const RECORDING_LIST_DEFAULT_LIMIT: usize = 10;
#[derive(Debug, Clone, Copy)]
pub(super) struct RecordingListOptions<'a> {
pub limit: Option<usize>,
pub all: bool,
pub sort: Option<RecordingListSortArg>,
pub order: Option<RecordingListOrderArg>,
pub status: Option<RecordingListStatusArg>,
pub query: Option<&'a str>,
}
const fn resolve_recording_list_limit(
as_json: bool,
explicit_limit: Option<usize>,
all: bool,
) -> Option<usize> {
if all {
None
} else if let Some(limit) = explicit_limit {
Some(limit)
} else if as_json {
None
} else {
Some(RECORDING_LIST_DEFAULT_LIMIT)
}
}
const fn default_recording_list_order(sort: RecordingListSortArg) -> RecordingListOrderArg {
match sort {
RecordingListSortArg::Started
| RecordingListSortArg::Events
| RecordingListSortArg::Size => RecordingListOrderArg::Desc,
RecordingListSortArg::Name => RecordingListOrderArg::Asc,
}
}
const fn recording_matches_status(
recording: &RecordingSummary,
status: RecordingListStatusArg,
) -> bool {
match status {
RecordingListStatusArg::All => true,
RecordingListStatusArg::Active => recording.ended_epoch_ms.is_none(),
RecordingListStatusArg::Done => recording.ended_epoch_ms.is_some(),
}
}
fn recording_matches_query(recording: &RecordingSummary, query: &str) -> bool {
let id = recording.id.to_string();
if id.starts_with(query) {
return true;
}
recording
.name
.as_deref()
.is_some_and(|name| name.to_ascii_lowercase().contains(query))
}
fn filter_recordings(
recordings: Vec<RecordingSummary>,
status: RecordingListStatusArg,
query: Option<&str>,
) -> Vec<RecordingSummary> {
let normalized_query = query
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_ascii_lowercase);
recordings
.into_iter()
.filter(|recording| {
recording_matches_status(recording, status)
&& normalized_query
.as_deref()
.is_none_or(|value| recording_matches_query(recording, value))
})
.collect()
}
fn compare_recordings(
left: &RecordingSummary,
right: &RecordingSummary,
sort: RecordingListSortArg,
) -> std::cmp::Ordering {
let primary = match sort {
RecordingListSortArg::Started => left.started_epoch_ms.cmp(&right.started_epoch_ms),
RecordingListSortArg::Events => left.event_count.cmp(&right.event_count),
RecordingListSortArg::Size => left.payload_bytes.cmp(&right.payload_bytes),
RecordingListSortArg::Name => {
let left_name = left.name.as_deref().unwrap_or("");
let right_name = right.name.as_deref().unwrap_or("");
let presence = left_name.is_empty().cmp(&right_name.is_empty());
if presence != std::cmp::Ordering::Equal {
return presence;
}
let by_name = left_name
.to_ascii_lowercase()
.cmp(&right_name.to_ascii_lowercase());
if by_name != std::cmp::Ordering::Equal {
return by_name;
}
left.started_epoch_ms.cmp(&right.started_epoch_ms)
}
};
if primary != std::cmp::Ordering::Equal {
return primary;
}
left.id.cmp(&right.id)
}
fn sort_recordings(
recordings: &mut [RecordingSummary],
sort: RecordingListSortArg,
order: RecordingListOrderArg,
) {
recordings.sort_by(|left, right| {
let ordering = compare_recordings(left, right, sort);
if order == RecordingListOrderArg::Asc {
ordering
} else {
ordering.reverse()
}
});
}
pub(super) async fn run_recording_stop(
recording_id: Option<&str>,
connection_context: ConnectionContext<'_>,
) -> Result<u8> {
cleanup_stale_pid_file().await?;
let mut client = connect_if_running_with_context(
ConnectionPolicyScope::Normal,
"bmux-cli-recording-stop",
connection_context,
)
.await?
.ok_or_else(|| {
anyhow::anyhow!(
"recording stop requires a running bmux server.\nRun `bmux server start --daemon` and retry."
)
})?;
let recording_id = match recording_id {
Some(raw) => Some(Uuid::parse_str(raw).context("invalid recording id")?),
None => None,
};
let stopped_id = recording_commands::client::stop(&mut client, recording_id)
.await?
.map_err(recording_plugin_error)?;
println!("recording stopped: {stopped_id}");
let auto_export = maybe_auto_export_recording(stopped_id, None, None).await;
print_auto_export_outcome(stopped_id, &auto_export);
let mut status = format!("recording stopped: {stopped_id}");
if let Some(suffix) = auto_export_status_suffix(&auto_export) {
status.push_str(&suffix);
}
emit_recording_command_status(status);
Ok(0)
}
#[allow(clippy::too_many_lines)]
pub(super) async fn run_recording_status(
as_json: bool,
connection_context: ConnectionContext<'_>,
) -> Result<u8> {
cleanup_stale_pid_file().await?;
let runtime_status = match connect_if_running_with_context(
ConnectionPolicyScope::Normal,
"bmux-cli-recording-status",
connection_context,
)
.await?
{
Some(mut client) => recording_state::client::status(&mut client).await?.into(),
None => offline_recording_status(),
};
let (config, root_path) = recording_config_and_root();
let usage = collect_recording_storage_usage(&root_path)?;
let status = RecordingStatusView {
active: runtime_status.active,
queue_len: runtime_status.queue_len,
root_path: root_path.display().to_string(),
config,
usage,
};
if as_json {
println!(
"{}",
serde_json::to_string_pretty(&status)
.context("failed encoding recording status json")?
);
return Ok(0);
}
println!("recordings root: {}", status.root_path);
println!(
"default capture input: {}",
if status.config.capture_input {
"enabled"
} else {
"disabled"
}
);
println!(
"default capture output: {}",
if status.config.capture_output {
"enabled"
} else {
"disabled"
}
);
println!(
"default capture events: {}",
if status.config.capture_events {
"enabled"
} else {
"disabled"
}
);
println!(
"performance recording level: {}",
performance_recording_level_label(status.config.performance_recording_level)
);
println!(
"default perf custom-event capture: {}",
if status.config.perf_custom_events_enabled_by_default {
"enabled"
} else {
"disabled"
}
);
println!(
"default event kinds: {}",
status
.config
.default_event_kinds
.iter()
.map(|kind| recording_event_kind_name(*kind))
.collect::<Vec<_>>()
.join(", ")
);
println!(
"segment size: {} MiB retention days: {}",
status.config.segment_mb, status.config.retention_days
);
if performance_capture_enabled(status.config.performance_recording_level)
&& !status.config.perf_custom_events_enabled_by_default
{
eprintln!(
"bmux warning: perf recording is enabled but default recording event kinds exclude `custom`; enable `recording.capture_events` or add `--kind custom` when starting recordings"
);
}
if let Some(active) = status.active.as_ref() {
println!(
"active recording: {} name={} events={} bytes={} capture_input={} profile={:?} kinds={} path={}",
active.id,
active.name.as_deref().unwrap_or("-"),
active.event_count,
active.payload_bytes,
active.capture_input,
active.profile,
active
.event_kinds
.iter()
.map(|kind| recording_event_kind_name(*kind))
.collect::<Vec<_>>()
.join(","),
active.path
);
} else {
println!("active recording: none");
}
println!("queue length: {}", status.queue_len);
println!(
"usage: bytes={} ({}) files={} dirs={} recordings={}",
status.usage.bytes,
format_byte_size(status.usage.bytes),
status.usage.files,
status.usage.directories,
status.usage.recording_dirs
);
Ok(0)
}
pub(super) fn run_recording_path(as_json: bool) -> Result<u8> {
let (_config, root_path) = recording_config_and_root();
let path = root_path.display().to_string();
if as_json {
let payload = serde_json::json!({ "path": path });
println!(
"{}",
serde_json::to_string_pretty(&payload)
.context("failed encoding recording path json")?
);
} else {
println!("{path}");
}
Ok(0)
}
pub(super) async fn run_recording_list(
as_json: bool,
options: RecordingListOptions<'_>,
connection_context: ConnectionContext<'_>,
) -> Result<u8> {
cleanup_stale_pid_file().await?;
let recordings = match connect_if_running_with_context(
ConnectionPolicyScope::Normal,
"bmux-cli-recording-list",
connection_context,
)
.await?
{
Some(mut client) => recording_state::client::list_recordings(&mut client)
.await?
.into_iter()
.map(Into::into)
.collect(),
None => list_recordings_from_disk()?,
};
let sort = options.sort.unwrap_or(RecordingListSortArg::Started);
let order = options
.order
.unwrap_or_else(|| default_recording_list_order(sort));
let status = options.status.unwrap_or(RecordingListStatusArg::All);
let mut recordings = filter_recordings(recordings, status, options.query);
sort_recordings(&mut recordings, sort, order);
let total_count = recordings.len();
if let Some(limit) = resolve_recording_list_limit(as_json, options.limit, options.all) {
recordings.truncate(limit);
}
if as_json {
println!(
"{}",
serde_json::to_string_pretty(&recordings)
.context("failed encoding recording list json")?
);
return Ok(0);
}
if recordings.is_empty() {
println!("no recordings");
return Ok(0);
}
let now_epoch_ms = epoch_millis_now();
let mut table = Table::new(vec![
TableColumn::new("ID").min_width(36),
TableColumn::new("NAME").min_width(8),
TableColumn::new("STATUS").min_width(6),
TableColumn::new("STARTED").min_width(8),
TableColumn::new("EVENTS")
.align(TableAlign::Right)
.min_width(6),
TableColumn::new("SIZE").min_width(8),
]);
for recording in recordings {
table.push_row(vec![
recording.id.to_string(),
recording.name.unwrap_or_else(|| "-".to_string()),
recording_status_label(recording.ended_epoch_ms).to_string(),
format_recording_age(recording.started_epoch_ms, now_epoch_ms),
recording.event_count.to_string(),
format_byte_size(recording.payload_bytes),
]);
}
write_stdout_table(&table)?;
if total_count > table.rows().len() {
println!(
"showing {} of {} recordings (use --limit N or --all)",
table.rows().len(),
total_count
);
}
Ok(0)
}
pub(super) async fn run_recording_delete(
recording_id_or_prefix: &str,
connection_context: ConnectionContext<'_>,
) -> Result<u8> {
cleanup_stale_pid_file().await?;
if let Some(mut client) = connect_if_running_with_context(
ConnectionPolicyScope::Normal,
"bmux-cli-recording-delete",
connection_context,
)
.await?
{
let status: RecordingStatus = recording_state::client::status(&mut client).await?.into();
let recordings: Vec<RecordingSummary> =
recording_state::client::list_recordings(&mut client)
.await?
.into_iter()
.map(Into::into)
.collect();
let resolved = resolve_recording_id_prefix(recording_id_or_prefix, &recordings)?;
if status
.active
.as_ref()
.is_some_and(|active| active.id == resolved)
{
let stopped_id = recording_commands::client::stop(&mut client, Some(resolved))
.await?
.map_err(recording_plugin_error)?;
println!("stopped active recording {stopped_id} before delete");
}
let deleted_id = recording_commands::client::delete(&mut client, resolved)
.await?
.map_err(recording_plugin_error)?;
println!("deleted recording {deleted_id}");
} else {
let recordings = list_recordings_from_disk()?;
let resolved = resolve_recording_id_prefix(recording_id_or_prefix, &recordings)?;
delete_recording_dir(resolved)?;
println!("deleted recording {resolved}");
}
Ok(0)
}
pub(super) async fn run_recording_delete_all(
yes: bool,
connection_context: ConnectionContext<'_>,
) -> Result<u8> {
if !confirm_delete_all_recordings(yes)? {
println!("skipped recording delete-all");
return Ok(0);
}
cleanup_stale_pid_file().await?;
if let Some(mut client) = connect_if_running_with_context(
ConnectionPolicyScope::Normal,
"bmux-cli-recording-delete-all",
connection_context,
)
.await?
{
let status: RecordingStatus = recording_state::client::status(&mut client).await?.into();
if let Some(active) = status.active {
let stopped_id = recording_commands::client::stop(&mut client, Some(active.id))
.await?
.map_err(recording_plugin_error)?;
println!("stopped active recording {stopped_id} before delete");
}
let deleted_count =
usize::try_from(recording_commands::client::delete_all(&mut client).await?)
.unwrap_or(usize::MAX);
println!("deleted {deleted_count} recordings");
} else {
let deleted_count = delete_all_recordings_from_disk()?;
println!("deleted {deleted_count} recordings");
}
Ok(0)
}
pub(super) async fn run_recording_cut(
last_seconds: Option<u64>,
export_fps: Option<u32>,
name: Option<&str>,
connection_context: ConnectionContext<'_>,
) -> Result<u8> {
let name = normalize_recording_name(name)?;
cleanup_stale_pid_file().await?;
let mut client = connect_if_running_with_context(
ConnectionPolicyScope::Normal,
"bmux-cli-recording-cut",
connection_context,
)
.await?
.ok_or_else(|| {
anyhow::anyhow!(
"recording cut requires a running bmux server.\nRun `bmux server start --daemon` and retry."
)
})?;
let recording: RecordingSummary =
recording_commands::client::cut(&mut client, last_seconds, name)
.await
.map_err(recording_service_client_error)?
.map(Into::into)
.map_err(recording_plugin_error)?;
let name_display = recording.name.as_deref().unwrap_or("-");
tracing::info!(
id = %recording.id,
name = %name_display,
events = recording.event_count,
bytes = recording.payload_bytes,
path = %recording.path,
"recording cut created",
);
println!(
"recording cut created: {} name={} events={} bytes={} path={}",
recording.id, name_display, recording.event_count, recording.payload_bytes, recording.path
);
let recording_path = PathBuf::from(&recording.path);
let auto_export =
maybe_auto_export_recording(recording.id, Some(&recording_path), export_fps).await;
print_auto_export_outcome(recording.id, &auto_export);
let mut status = format!("recording cut created: {}", recording.path);
if let Some(suffix) = auto_export_status_suffix(&auto_export) {
status.push_str(&suffix);
}
emit_recording_command_status(status);
Ok(0)
}
pub(super) async fn run_recording_prune(
older_than: Option<u64>,
json: bool,
connection_context: ConnectionContext<'_>,
) -> Result<u8> {
cleanup_stale_pid_file().await?;
let deleted_count = if let Some(mut client) = connect_if_running_with_context(
ConnectionPolicyScope::Normal,
"bmux-cli-recording-prune",
connection_context,
)
.await?
{
usize::try_from(recording_commands::client::prune(&mut client, older_than).await?)
.unwrap_or(usize::MAX)
} else {
let root = recordings_root_dir();
let config = bmux_config::BmuxConfig::load().unwrap_or_default();
let retention = older_than.unwrap_or(config.recording.retention_days);
bmux_recording_plugin_api::prune_old_recordings(&root, retention)?
};
if json {
let report = serde_json::json!({
"deleted_count": deleted_count,
"older_than_days": older_than,
});
println!("{}", serde_json::to_string_pretty(&report)?);
} else if deleted_count > 0 {
println!("pruned {deleted_count} recording(s)");
} else {
println!("no recordings to prune");
}
Ok(0)
}
#[allow(clippy::too_many_lines)]
pub(super) fn run_recording_inspect(
recording_id: &str,
limit: usize,
kind: Option<&str>,
as_json: bool,
) -> Result<u8> {
let events = load_recording_events(recording_id)?;
let filtered = events
.into_iter()
.filter(|event| {
kind.is_none_or(|kind| {
recording_event_kind_name(event.kind) == kind.to_ascii_lowercase()
})
})
.take(limit.max(1))
.collect::<Vec<_>>();
if as_json {
println!(
"{}",
serde_json::to_string_pretty(&filtered)
.context("failed encoding recording inspect json")?
);
return Ok(0);
}
for event in filtered {
println!(
"seq={} t={} kind={:?} session={:?} pane={:?} client={:?}",
event.seq, event.mono_ns, event.kind, event.session_id, event.pane_id, event.client_id
);
}
Ok(0)
}
#[derive(Debug, Clone, serde::Serialize, Default)]
struct PerfTimingSummary {
count: u64,
min_ms: u64,
p50_ms: u64,
p95_ms: u64,
p99_ms: u64,
avg_ms: u64,
max_ms: u64,
}
#[derive(Debug, Clone, serde::Serialize)]
struct PerfOutlierSample {
event_name: String,
metric: String,
value_ms: u64,
p95_ms: u64,
ts_epoch_ms: Option<u64>,
}
#[derive(Debug, Clone, serde::Serialize, Default)]
struct PerfRenderOutlierSample {
reason: String,
frame_index: Option<u64>,
since_attach_start_ms: Option<u64>,
frame_render_ms: Option<u64>,
frame_bytes: Option<u64>,
full_frame_fallback: bool,
full_surface_fallbacks: u64,
dirty_reasons: Vec<String>,
dirty_events: Vec<serde_json::Value>,
extension_stats: BTreeMap<String, BTreeMap<String, u64>>,
}
#[derive(Debug, Clone)]
struct PerfTimingSample {
event_name: String,
metric: String,
value_ms: u64,
ts_epoch_ms: Option<u64>,
}
#[derive(Debug, Clone, serde::Serialize, Default)]
struct PerfAnalysisReport {
recording_events: usize,
perf_events: usize,
malformed_payloads: usize,
dropped_events_reported: u64,
dropped_payload_bytes_reported: u64,
first_ts_epoch_ms: Option<u64>,
last_ts_epoch_ms: Option<u64>,
span_ms: Option<u64>,
by_event_name: BTreeMap<String, u64>,
by_level: BTreeMap<String, u64>,
attach_window_counters: BTreeMap<String, u64>,
overrender_counters: BTreeMap<String, u64>,
extension_counters: BTreeMap<String, BTreeMap<String, u64>>,
timings_ms: BTreeMap<String, PerfTimingSummary>,
outlier_samples: Vec<PerfOutlierSample>,
render_outliers: Vec<PerfRenderOutlierSample>,
connect_to_first_frame_ms: Option<u64>,
connect_to_interactive_ms: Option<u64>,
reconnect_outage_max_ms: Option<u64>,
hints: Vec<String>,
}
fn percentile_nearest_rank(sorted_values: &[u64], percentile: u8) -> u64 {
if sorted_values.is_empty() {
return 0;
}
let clamped = usize::from(percentile.min(100));
let len = sorted_values.len();
let rank = (clamped.saturating_mul(len).saturating_add(99)) / 100;
let index = rank.saturating_sub(1).min(len.saturating_sub(1));
sorted_values[index]
}
fn timing_summary_from_values(values: &[u64]) -> PerfTimingSummary {
if values.is_empty() {
return PerfTimingSummary::default();
}
let mut sorted = values.to_vec();
sorted.sort_unstable();
let count = u64::try_from(sorted.len()).unwrap_or(u64::MAX);
let sum = sorted
.iter()
.fold(0_u128, |acc, value| acc.saturating_add(u128::from(*value)));
let avg_ms = u64::try_from(sum / u128::from(count.max(1))).unwrap_or(u64::MAX);
PerfTimingSummary {
count,
min_ms: sorted[0],
p50_ms: percentile_nearest_rank(&sorted, 50),
p95_ms: percentile_nearest_rank(&sorted, 95),
p99_ms: percentile_nearest_rank(&sorted, 99),
avg_ms,
max_ms: *sorted.last().unwrap_or(&0),
}
}
fn perf_counter(counters: &BTreeMap<String, u64>, name: &str) -> u64 {
counters.get(name).copied().unwrap_or(0)
}
fn parse_extension_stats(
value: Option<&serde_json::Value>,
) -> BTreeMap<String, BTreeMap<String, u64>> {
let Some(object) = value.and_then(serde_json::Value::as_object) else {
return BTreeMap::new();
};
object
.iter()
.filter_map(|(extension_name, counters)| {
let counters = counters.as_object()?;
let parsed = counters
.iter()
.filter_map(|(counter_name, value)| {
value
.as_u64()
.map(|counter| (counter_name.clone(), counter))
})
.collect::<BTreeMap<_, _>>();
(!parsed.is_empty()).then(|| (extension_name.clone(), parsed))
})
.collect()
}
fn aggregate_extension_counters(
target: &mut BTreeMap<String, BTreeMap<String, u64>>,
source: &BTreeMap<String, BTreeMap<String, u64>>,
) {
for (extension_name, counters) in source {
let target_counters = target.entry(extension_name.clone()).or_default();
for (counter_name, value) in counters {
let target_value = target_counters.entry(counter_name.clone()).or_default();
*target_value = target_value.saturating_add(*value);
}
}
}
fn string_array_field(
object: &serde_json::Map<String, serde_json::Value>,
field: &str,
) -> Vec<String> {
object
.get(field)
.and_then(serde_json::Value::as_array)
.map(|values| {
values
.iter()
.filter_map(serde_json::Value::as_str)
.map(str::to_string)
.collect()
})
.unwrap_or_default()
}
fn render_outlier_from_frame_trace(
object: &serde_json::Map<String, serde_json::Value>,
) -> Option<PerfRenderOutlierSample> {
let full_frame_fallback = object
.get("full_frame_fallback")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
let extension_render_calls = object
.get("extension_render_calls")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
let extension_cache_hits = object
.get("extension_cache_hits")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
let extension_imperative_calls = object
.get("extension_imperative_calls")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
let extension_cache_misses = extension_render_calls.saturating_sub(extension_cache_hits);
let extension_pressure = extension_render_calls > 0
&& extension_cache_misses.saturating_add(extension_imperative_calls) > 0;
if !full_frame_fallback && !extension_pressure {
return None;
}
let reason = match (full_frame_fallback, extension_pressure) {
(true, true) => "full_frame_and_extension_pressure",
(true, false) => "full_frame_fallback",
(false, true) => "extension_cache_miss_or_imperative",
(false, false) => return None,
}
.to_string();
Some(PerfRenderOutlierSample {
reason,
frame_index: object
.get("frame_index")
.and_then(serde_json::Value::as_u64),
since_attach_start_ms: object
.get("since_attach_start_ms")
.and_then(serde_json::Value::as_u64),
frame_render_ms: object
.get("frame_render_ms")
.and_then(serde_json::Value::as_u64),
frame_bytes: object
.get("frame_bytes")
.and_then(serde_json::Value::as_u64),
full_frame_fallback,
full_surface_fallbacks: object
.get("full_surface_fallbacks")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0),
dirty_reasons: string_array_field(object, "dirty_reasons"),
dirty_events: object
.get("dirty_events")
.and_then(serde_json::Value::as_array)
.cloned()
.unwrap_or_default(),
extension_stats: parse_extension_stats(object.get("extension_stats")),
})
}
fn append_attach_window_counter_hints(hints: &mut Vec<String>, counters: &BTreeMap<String, u64>) {
let render_frames = perf_counter(counters, "render_frames");
if perf_counter(counters, "terminal_graphic_deletes") > 0 {
hints.push("retained terminal graphics were deleted during captured attach windows; if this occurs outside tab/layout teardown, inspect graphic identity and lifecycle reconciliation".to_string());
}
if perf_counter(counters, "terminal_graphic_transmits") > 0
&& perf_counter(counters, "terminal_graphic_places")
> render_frames.max(1).saturating_mul(2)
{
hints.push("terminal graphics placements greatly exceed rendered frames; inspect retained graphics placement stability and damage intersection checks".to_string());
}
if perf_counter(counters, "terminal_graphic_bytes") > 512 * 1024 {
hints.push("terminal graphics uploaded more than 512KiB of raw pixels; prefer small retained sources and placement scaling for stable decorations".to_string());
}
if perf_counter(counters, "full_frame_fallbacks") > 0
&& perf_counter(counters, "damage_area_cells_max")
< perf_counter(counters, "viewport_cells")
{
hints.push("full-frame fallback was observed without a matching full viewport damage area; inspect damage coalescing and dirty reason promotion".to_string());
}
}
fn append_extension_counter_hints(
hints: &mut Vec<String>,
extension_counters: &BTreeMap<String, BTreeMap<String, u64>>,
) {
let Some((extension_name, counters)) = extension_counters
.iter()
.max_by_key(|(_, counters)| perf_counter(counters, "render_calls"))
else {
return;
};
let render_calls = perf_counter(counters, "render_calls");
if render_calls > 0 {
let cache_hits = perf_counter(counters, "cache_hits");
let imperative_calls = perf_counter(counters, "imperative_calls");
let full_surface_calls = perf_counter(counters, "full_surface_calls");
hints.push(format!(
"top render extension by call count was {extension_name} (calls={render_calls}, cache_hits={cache_hits}, imperative={imperative_calls}, full_surface={full_surface_calls})"
));
}
}
fn append_overrender_perf_hints(hints: &mut Vec<String>, counters: &BTreeMap<String, u64>) {
if perf_counter(counters, "dirty_no_visible_row_change_frames") > 0 {
hints.push("attach rendered dirty frames with no visible pane row changes; inspect dirty-source tracking for no-op invalidations".to_string());
}
if perf_counter(counters, "full_frame_fallback_flagged_frames") > 0 {
hints.push("attach used full-frame damage fallbacks; inspect layout/resize/overlay churn before optimizing terminal throughput".to_string());
}
if perf_counter(counters, "extension_full_surface_excessive_frames") > 0 {
hints.push("extension rendering frequently fell back to full-surface damage; prefer precise extension regions when possible".to_string());
}
if perf_counter(counters, "extension_imperative_or_cache_miss_frames") > 0 {
hints.push("extension rendering had imperative fallback or cache-miss pressure; check render-op revisions and cacheability".to_string());
}
if perf_counter(counters, "status_overlay_only_emits_pane_work_frames") > 0 {
hints.push("status/overlay-only frames emitted pane content; inspect damage classification for unnecessary scene redraws".to_string());
}
if perf_counter(counters, "slow_terminal_write_per_kib_frames") > 0 {
hints.push("terminal writes were slow per KiB; host terminal throughput may be the bottleneck after render work is bounded".to_string());
}
}
fn derive_perf_hints(
report: &PerfAnalysisReport,
recording_captures_custom: Option<bool>,
) -> Vec<String> {
let mut hints = Vec::new();
if report.perf_events == 0 {
if matches!(recording_captures_custom, Some(false)) {
hints.push(
"recording did not capture `custom` events; perf telemetry requires `custom` event kind"
.to_string(),
);
} else {
hints.push(
"no bmux.perf events found; drive a telemetry-emitting path (for example real attach/runtime activity) and ensure `performance.recording_level` is enabled"
.to_string(),
);
}
return hints;
}
if report.malformed_payloads > 0 {
hints.push(format!(
"{} perf payloads could not be parsed; check plugin/runtime payload compatibility",
report.malformed_payloads
));
}
if report.dropped_events_reported > 0 || report.dropped_payload_bytes_reported > 0 {
hints.push(format!(
"perf telemetry was rate-limited (dropped events={}, dropped payload bytes={}); consider raising `performance.max_events_per_sec` or `performance.max_payload_bytes_per_sec`",
report.dropped_events_reported, report.dropped_payload_bytes_reported
));
}
if let Some(connect_to_interactive_ms) = report.connect_to_interactive_ms
&& connect_to_interactive_ms > 1500
{
hints.push(format!(
"connect-to-interactive took {connect_to_interactive_ms}ms; inspect iroh connect stages and attach hydration timing"
));
}
if let Some(max_outage_ms) = report.reconnect_outage_max_ms
&& max_outage_ms > 1000
{
hints.push(format!(
"max reconnect outage was {max_outage_ms}ms; investigate network stability and relay path quality"
));
}
if let Some(render_max) = report.timings_ms.get("render_ms_max")
&& render_max.p95_ms > 16
{
hints.push(format!(
"render p95 is {}ms (>16ms frame budget); local rendering may be a bottleneck",
render_max.p95_ms
));
}
append_overrender_perf_hints(&mut hints, &report.overrender_counters);
append_attach_window_counter_hints(&mut hints, &report.attach_window_counters);
append_extension_counter_hints(&mut hints, &report.extension_counters);
if let Some(drain_ipc_max) = report.timings_ms.get("drain_ipc_ms_max")
&& drain_ipc_max.p95_ms > 20
{
hints.push(format!(
"drain IPC p95 is {}ms; server/client round-trip latency is likely impacting smoothness",
drain_ipc_max.p95_ms
));
}
if hints.is_empty() {
hints.push("no obvious bottleneck stood out from captured perf telemetry".to_string());
}
hints
}
#[allow(clippy::too_many_lines)] fn analyze_perf_events(
events: &[RecordingEventEnvelope],
recording_captures_custom: Option<bool>,
) -> PerfAnalysisReport {
let mut report = PerfAnalysisReport {
recording_events: events.len(),
..PerfAnalysisReport::default()
};
let mut timing_values: HashMap<String, Vec<u64>> = HashMap::new();
let mut timing_samples = Vec::new();
let mut first_connect_ts_epoch_ms = None;
let mut first_attach_first_frame_ts_epoch_ms = None;
let mut first_attach_interactive_ts_epoch_ms = None;
let mut reconnect_outage_max_ms = None;
for event in events {
let RecordingPayload::Custom {
source,
name,
payload,
} = &event.payload
else {
continue;
};
if source != PERF_RECORDING_SOURCE {
continue;
}
report.perf_events = report.perf_events.saturating_add(1);
*report.by_event_name.entry(name.clone()).or_default() += 1;
let decoded: serde_json::Value = if let Ok(value) = serde_json::from_slice(payload) {
value
} else {
report.malformed_payloads = report.malformed_payloads.saturating_add(1);
continue;
};
let Some(object) = decoded.as_object() else {
report.malformed_payloads = report.malformed_payloads.saturating_add(1);
continue;
};
if let Some(level) = object.get("level").and_then(serde_json::Value::as_str) {
*report.by_level.entry(level.to_string()).or_default() += 1;
}
if let Some(ts_epoch_ms) = object
.get("ts_epoch_ms")
.and_then(serde_json::Value::as_u64)
{
report.first_ts_epoch_ms = Some(
report
.first_ts_epoch_ms
.map_or(ts_epoch_ms, |first| first.min(ts_epoch_ms)),
);
report.last_ts_epoch_ms = Some(
report
.last_ts_epoch_ms
.map_or(ts_epoch_ms, |last| last.max(ts_epoch_ms)),
);
}
let ts_epoch_ms = object
.get("ts_epoch_ms")
.and_then(serde_json::Value::as_u64);
if name == "attach.window" {
let extension_stats = parse_extension_stats(object.get("extension_stats"));
aggregate_extension_counters(&mut report.extension_counters, &extension_stats);
for (field, value) in object {
if matches!(field.as_str(), "schema_version" | "ts_epoch_ms")
|| field.ends_with("_ms")
{
continue;
}
if let Some(counter) = value.as_u64() {
*report
.attach_window_counters
.entry(field.clone())
.or_default() += counter;
}
}
for field in [
"overrender_flagged_frames",
"dirty_no_visible_row_change_frames",
"high_cached_skip_ratio_frames",
"large_partial_frame_frames",
"extension_full_surface_excessive_frames",
"full_frame_fallback_flagged_frames",
"extension_imperative_or_cache_miss_frames",
"status_overlay_only_emits_pane_work_frames",
"slow_terminal_write_per_kib_frames",
] {
let value = object
.get(field)
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
*report
.overrender_counters
.entry(field.to_string())
.or_default() += value;
}
}
if name == "attach.frame.trace"
&& let Some(outlier) = render_outlier_from_frame_trace(object)
{
report.render_outliers.push(outlier);
}
if name == "iroh.connect.summary" && first_connect_ts_epoch_ms.is_none() {
first_connect_ts_epoch_ms = ts_epoch_ms;
}
if name == "attach.first_frame" && first_attach_first_frame_ts_epoch_ms.is_none() {
first_attach_first_frame_ts_epoch_ms = ts_epoch_ms;
}
if name == "attach.interactive.ready" && first_attach_interactive_ts_epoch_ms.is_none() {
first_attach_interactive_ts_epoch_ms = ts_epoch_ms;
}
if name == "iroh.reconnect.outage"
&& let Some(outage_ms) = object.get("outage_ms").and_then(serde_json::Value::as_u64)
{
reconnect_outage_max_ms = Some(
reconnect_outage_max_ms.map_or(outage_ms, |current: u64| current.max(outage_ms)),
);
}
report.dropped_events_reported = report.dropped_events_reported.saturating_add(
object
.get("dropped_events_since_emit")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0),
);
report.dropped_payload_bytes_reported =
report.dropped_payload_bytes_reported.saturating_add(
object
.get("dropped_payload_bytes_since_emit")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0),
);
for (field, value) in object {
if !field.ends_with("_ms") && !field.contains("_ms_") {
continue;
}
let Some(ms) = value.as_u64() else {
continue;
};
timing_values.entry(field.clone()).or_default().push(ms);
timing_samples.push(PerfTimingSample {
event_name: name.clone(),
metric: field.clone(),
value_ms: ms,
ts_epoch_ms,
});
}
}
if let (Some(first), Some(last)) = (report.first_ts_epoch_ms, report.last_ts_epoch_ms) {
report.span_ms = Some(last.saturating_sub(first));
}
report.timings_ms = timing_values
.into_iter()
.map(|(field, values)| (field, timing_summary_from_values(&values)))
.collect();
let p95_by_metric = report
.timings_ms
.iter()
.map(|(metric, summary)| (metric.clone(), summary.p95_ms))
.collect::<HashMap<_, _>>();
report.outlier_samples = timing_samples
.into_iter()
.filter_map(|sample| {
let p95_ms = p95_by_metric.get(&sample.metric).copied()?;
if p95_ms == 0 || sample.value_ms < p95_ms {
return None;
}
Some(PerfOutlierSample {
event_name: sample.event_name,
metric: sample.metric,
value_ms: sample.value_ms,
p95_ms,
ts_epoch_ms: sample.ts_epoch_ms,
})
})
.collect();
report
.outlier_samples
.sort_by_key(|sample| std::cmp::Reverse(sample.value_ms));
report.outlier_samples.truncate(20);
report.render_outliers.sort_by(|left, right| {
right
.frame_bytes
.unwrap_or(0)
.cmp(&left.frame_bytes.unwrap_or(0))
.then_with(|| left.frame_index.cmp(&right.frame_index))
});
report.render_outliers.truncate(20);
if let (Some(connect_ts_epoch_ms), Some(first_frame_ts_epoch_ms)) = (
first_connect_ts_epoch_ms,
first_attach_first_frame_ts_epoch_ms,
) && first_frame_ts_epoch_ms >= connect_ts_epoch_ms
{
report.connect_to_first_frame_ms = Some(first_frame_ts_epoch_ms - connect_ts_epoch_ms);
}
if let (Some(connect_ts_epoch_ms), Some(interactive_ts_epoch_ms)) = (
first_connect_ts_epoch_ms,
first_attach_interactive_ts_epoch_ms,
) && interactive_ts_epoch_ms >= connect_ts_epoch_ms
{
report.connect_to_interactive_ms = Some(interactive_ts_epoch_ms - connect_ts_epoch_ms);
}
report.reconnect_outage_max_ms = reconnect_outage_max_ms.or_else(|| {
report
.timings_ms
.get("outage_ms")
.map(|timing| timing.max_ms)
});
report.hints = derive_perf_hints(&report, recording_captures_custom);
report
}
fn print_nonzero_perf_counters(label: &str, counters: &BTreeMap<String, u64>) {
if counters.is_empty() || !counters.values().any(|value| *value > 0) {
return;
}
println!("{label}:");
for (name, value) in counters {
if *value > 0 {
println!(" {name}: {value}");
}
}
}
fn print_extension_counters(counters: &BTreeMap<String, BTreeMap<String, u64>>) {
if counters.is_empty() {
return;
}
println!("extension counters:");
for (extension_name, extension_counters) in counters {
let summary = extension_counters
.iter()
.filter(|(_, value)| **value > 0)
.map(|(name, value)| format!("{name}={value}"))
.collect::<Vec<_>>()
.join(", ");
if !summary.is_empty() {
println!(" {extension_name}: {summary}");
}
}
}
fn print_render_outliers(outliers: &[PerfRenderOutlierSample]) {
if outliers.is_empty() {
return;
}
println!("render outliers:");
for outlier in outliers.iter().take(10) {
println!(
" frame={} reason={} bytes={} render_ms={} full_frame={} dirty={}",
outlier
.frame_index
.map_or_else(|| "?".to_string(), |value| value.to_string()),
outlier.reason,
outlier
.frame_bytes
.map_or_else(|| "?".to_string(), |value| value.to_string()),
outlier
.frame_render_ms
.map_or_else(|| "?".to_string(), |value| value.to_string()),
outlier.full_frame_fallback,
outlier.dirty_reasons.join("+")
);
}
}
fn print_perf_analysis_text(report: &PerfAnalysisReport) {
if report.perf_events == 0 {
println!("no bmux.perf custom events found in recording");
return;
}
println!(
"perf events: {} / {} (malformed payloads: {})",
report.perf_events, report.recording_events, report.malformed_payloads
);
if let Some(span_ms) = report.span_ms {
println!("time span: {span_ms}ms");
}
if report.dropped_events_reported > 0 || report.dropped_payload_bytes_reported > 0 {
println!(
"reported drops: events={} payload_bytes={}",
report.dropped_events_reported, report.dropped_payload_bytes_reported
);
}
if !report.by_level.is_empty() {
let levels = report
.by_level
.iter()
.map(|(level, count)| format!("{level}={count}"))
.collect::<Vec<_>>()
.join(", ");
println!("levels: {levels}");
}
if !report.by_event_name.is_empty() {
println!("events:");
let mut entries = report.by_event_name.iter().collect::<Vec<_>>();
entries.sort_by(|(left_name, left_count), (right_name, right_count)| {
right_count
.cmp(left_count)
.then_with(|| left_name.cmp(right_name))
});
for (name, count) in entries.into_iter().take(12) {
println!(" {name}: {count}");
}
}
print_nonzero_perf_counters("attach window counters", &report.attach_window_counters);
print_nonzero_perf_counters("render inefficiency counters", &report.overrender_counters);
print_extension_counters(&report.extension_counters);
if !report.timings_ms.is_empty() {
println!("timings (ms):");
let mut timings = report.timings_ms.iter().collect::<Vec<_>>();
timings.sort_by(|(left_name, left), (right_name, right)| {
right
.p95_ms
.cmp(&left.p95_ms)
.then_with(|| left_name.cmp(right_name))
});
for (name, timing) in timings.into_iter().take(16) {
println!(
" {name}: count={} min={} p50={} p95={} p99={} avg={} max={}",
timing.count,
timing.min_ms,
timing.p50_ms,
timing.p95_ms,
timing.p99_ms,
timing.avg_ms,
timing.max_ms
);
}
}
if let Some(connect_to_first_frame_ms) = report.connect_to_first_frame_ms {
println!("connect to first frame: {connect_to_first_frame_ms}ms");
}
if let Some(connect_to_interactive_ms) = report.connect_to_interactive_ms {
println!("connect to interactive: {connect_to_interactive_ms}ms");
}
if let Some(reconnect_outage_max_ms) = report.reconnect_outage_max_ms {
println!("max reconnect outage: {reconnect_outage_max_ms}ms");
}
if !report.outlier_samples.is_empty() {
println!("timing outliers:");
for outlier in report.outlier_samples.iter().take(10) {
if let Some(ts_epoch_ms) = outlier.ts_epoch_ms {
println!(
" {}: value={}ms p95={}ms ts={} event={}",
outlier.metric,
outlier.value_ms,
outlier.p95_ms,
ts_epoch_ms,
outlier.event_name
);
} else {
println!(
" {}: value={}ms p95={}ms event={}",
outlier.metric, outlier.value_ms, outlier.p95_ms, outlier.event_name
);
}
}
}
print_render_outliers(&report.render_outliers);
if !report.hints.is_empty() {
println!("hints:");
for hint in &report.hints {
println!(" - {hint}");
}
}
}
fn resolve_recording_summary(recording_id: &str) -> Result<RecordingSummary> {
let recordings = list_recordings_from_disk()?;
let id = resolve_recording_id_prefix(recording_id, &recordings)?;
recordings
.into_iter()
.find(|recording| recording.id == id)
.ok_or_else(|| anyhow::anyhow!("recording '{recording_id}' not found after resolving id"))
}
pub(super) fn run_recording_analyze(recording_id: &str, perf: bool, as_json: bool) -> Result<u8> {
if !perf {
anyhow::bail!("recording analyze currently supports only --perf")
}
let recording_summary = resolve_recording_summary(recording_id)?;
let events = load_recording_events(recording_id)?;
let report = analyze_perf_events(
&events,
Some(event_kinds_include_custom(&recording_summary.event_kinds)),
);
if as_json {
println!(
"{}",
serde_json::to_string_pretty(&report)
.context("failed encoding recording analyze json")?
);
return Ok(0);
}
print_perf_analysis_text(&report);
Ok(0)
}
#[allow(clippy::too_many_arguments)]
pub(super) async fn run_recording_replay(
recording_id: &str,
mode: RecordingReplayMode,
speed: f64,
target_bmux: Option<&str>,
compare_recording: Option<&str>,
ignore: Option<&str>,
strict_timing: bool,
max_verify_duration_secs: Option<u64>,
verify_start_timeout_secs: Option<u64>,
) -> Result<u8> {
let events = load_recording_events(recording_id)?;
match mode {
RecordingReplayMode::Watch => super::replay_watch(&events, speed),
RecordingReplayMode::Interactive => super::replay_interactive(&events, speed),
RecordingReplayMode::Verify => {
super::replay_verify(
&events,
target_bmux,
compare_recording,
ignore,
strict_timing,
max_verify_duration_secs,
verify_start_timeout_secs,
)
.await
}
}
}
pub(super) async fn run_recording_verify_smoke(
recording_id: &str,
target_bmux: Option<&str>,
compare_recording: Option<&str>,
ignore: Option<&str>,
strict_timing: bool,
max_verify_duration_secs: Option<u64>,
verify_start_timeout_secs: Option<u64>,
) -> Result<u8> {
let events = load_recording_events(recording_id)?;
let report = super::verify_recording_report(
&events,
target_bmux,
compare_recording,
ignore,
strict_timing,
max_verify_duration_secs,
verify_start_timeout_secs,
)
.await?;
println!(
"{}",
serde_json::to_string_pretty(&report)
.context("failed encoding verify smoke report json")?
);
Ok(u8::from(!report.pass))
}
#[allow(clippy::unused_async, clippy::too_many_arguments)] pub(super) async fn run_recording_export(
recording_id: &str,
format: RecordingExportFormat,
output: &str,
view_client: Option<&str>,
speed: f64,
fps: u32,
max_duration: Option<u64>,
max_frames: Option<u32>,
renderer: RecordingRenderMode,
cell_size: Option<(u16, u16)>,
cell_width: Option<u16>,
cell_height: Option<u16>,
font_family: Option<&str>,
font_size: Option<f32>,
line_height: Option<f32>,
font_path: &[String],
palette_source: RecordingPaletteSource,
palette_foreground: Option<&str>,
palette_background: Option<&str>,
palette_color: &[String],
cursor: RecordingCursorMode,
cursor_shape: RecordingCursorShape,
cursor_blink: RecordingCursorBlinkMode,
cursor_blink_period_ms: u32,
cursor_color: &str,
cursor_profile: RecordingCursorProfile,
cursor_solid_after_activity_ms: Option<u32>,
cursor_solid_after_input_ms: Option<u32>,
cursor_solid_after_output_ms: Option<u32>,
cursor_solid_after_cursor_ms: Option<u32>,
cursor_paint_mode: RecordingCursorPaintMode,
cursor_text_mode: RecordingCursorTextMode,
cursor_bar_width_pct: u8,
cursor_underline_height_pct: u8,
export_metadata: Option<&str>,
show_progress: bool,
) -> Result<u8> {
let recordings = list_recordings_from_disk()?;
let recording_id = resolve_recording_id_prefix(recording_id, &recordings)?;
let recording_dir = recordings_root_dir().join(recording_id.to_string());
if !recording_dir.exists() {
anyhow::bail!("recording not found: {recording_id}")
}
let manifest_summary = read_recording_manifest(&recording_dir.join("manifest.json"))?;
if manifest_summary.format_version != RECORDING_FORMAT_VERSION {
anyhow::bail!(
"recording format version {} is unsupported; expected {}. re-record with current bmux",
manifest_summary.format_version,
RECORDING_FORMAT_VERSION
)
}
let selected_client = if let Some(raw) = view_client {
parse_uuid_value(raw, "view client id")?
} else if let Some(owner) = read_recording_owner_client(&recording_dir)? {
owner
} else {
match infer_display_track_client(&recording_dir) {
InferredClient::One(id) => id,
InferredClient::Multiple(ids) => {
anyhow::bail!(
"multiple display tracks found; pass --view-client with one of: {}",
ids.iter()
.map(std::string::ToString::to_string)
.collect::<Vec<_>>()
.join(", ")
);
}
InferredClient::None => {
anyhow::bail!("no display tracks found in recording; cannot export");
}
}
};
let events = load_display_track_events(&recording_dir, selected_client)?;
if events.is_empty() {
anyhow::bail!(
"display track is empty for client {selected_client}; cannot export exact-view media"
)
}
let recording_profile = recording_terminal_profile(&events);
let host_profile = terminal_profile::detect_render_profile();
let terminal_profile = recording_profile.as_ref().or(host_profile.as_ref());
match format {
RecordingExportFormat::Gif => export_recording_gif(
&events,
output,
speed,
fps,
max_duration,
max_frames,
terminal_profile,
recording_profile.as_ref(),
host_profile.as_ref(),
renderer,
cell_size,
cell_width,
cell_height,
font_family,
font_size,
line_height,
font_path,
palette_source,
palette_foreground,
palette_background,
palette_color,
cursor,
cursor_shape,
cursor_blink,
cursor_blink_period_ms,
cursor_color,
cursor_profile,
cursor_solid_after_activity_ms,
cursor_solid_after_input_ms,
cursor_solid_after_output_ms,
cursor_solid_after_cursor_ms,
cursor_paint_mode,
cursor_text_mode,
cursor_bar_width_pct,
cursor_underline_height_pct,
export_metadata,
show_progress,
)?,
}
println!("export complete: format={format:?} view_client={selected_client} output={output}");
Ok(0)
}
fn read_recording_owner_client(recording_dir: &Path) -> Result<Option<Uuid>> {
let owner_path = recording_dir.join("owner-client-id.txt");
let content = match std::fs::read_to_string(&owner_path) {
Ok(content) => content,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => {
return Err(error).with_context(|| format!("failed reading {}", owner_path.display()));
}
};
let trimmed = content.trim();
if trimmed.is_empty() {
return Ok(None);
}
Ok(Some(parse_uuid_value(trimmed, "owner client id")?))
}
enum InferredClient {
One(Uuid),
Multiple(Vec<Uuid>),
None,
}
fn infer_display_track_client(recording_dir: &Path) -> InferredClient {
let Ok(entries) = std::fs::read_dir(recording_dir) else {
return InferredClient::None;
};
let mut found: Vec<Uuid> = Vec::new();
for entry in entries.flatten() {
let name = entry.file_name();
let name = name.to_string_lossy();
if let Some(rest) = name.strip_prefix("display-")
&& let Some(uuid_str) = rest.strip_suffix(".bin")
&& let Ok(id) = uuid_str.parse::<Uuid>()
{
found.push(id);
}
}
match found.len() {
1 => InferredClient::One(found[0]),
n if n > 1 => InferredClient::Multiple(found),
_ => InferredClient::None,
}
}
fn load_display_track_events(
recording_dir: &Path,
client_id: Uuid,
) -> Result<Vec<DisplayTrackEnvelope>> {
let path = display_track_path(recording_dir, client_id);
let bytes = std::fs::read(&path)
.with_context(|| format!("failed reading display track {}", path.display()))?;
let result = read_frames(&bytes)
.map_err(|e| anyhow::anyhow!("failed parsing display track {}: {e}", path.display()))?;
if result.bytes_remaining > 0 {
tracing::warn!(
"display track {}: {} trailing bytes could not be parsed (truncated?)",
path.display(),
result.bytes_remaining
);
}
Ok(result.frames)
}
fn recording_terminal_profile(
events: &[DisplayTrackEnvelope],
) -> Option<terminal_profile::DetectedTerminalProfile> {
for envelope in events {
if let DisplayTrackEvent::StreamOpened {
terminal_profile: Some(profile_bytes),
..
} = &envelope.event
&& let Ok(profile) =
bmux_ipc::decode::<terminal_profile::DetectedTerminalProfile>(profile_bytes)
{
return Some(profile);
}
}
None
}
#[derive(Clone, Copy, Debug)]
struct CellMetrics {
width: u16,
height: u16,
}
fn resolve_export_cell_metrics(
events: &[DisplayTrackEnvelope],
cell_size: Option<(u16, u16)>,
cell_width: Option<u16>,
cell_height: Option<u16>,
) -> Result<CellMetrics> {
if cell_size.is_some_and(|(w, h)| w == 0 || h == 0) {
anyhow::bail!("--cell-size values must be greater than zero")
}
if cell_width.is_some_and(|value| value == 0) {
anyhow::bail!("--cell-width must be greater than zero")
}
if cell_height.is_some_and(|value| value == 0) {
anyhow::bail!("--cell-height must be greater than zero")
}
let (size_width, size_height) = cell_size.unwrap_or((0, 0));
let cli_width = cell_width.or_else(|| (size_width > 0).then_some(size_width));
let cli_height = cell_height.or_else(|| (size_height > 0).then_some(size_height));
let recorded = recording_cell_metrics(events);
let current = current_terminal_cell_metrics();
let width = cli_width
.or_else(|| recorded.map(|value| value.width))
.or_else(|| current.map(|value| value.width))
.unwrap_or(8);
let height = cli_height
.or_else(|| recorded.map(|value| value.height))
.or_else(|| current.map(|value| value.height))
.unwrap_or(16);
Ok(CellMetrics { width, height })
}
fn recording_cell_metrics(events: &[DisplayTrackEnvelope]) -> Option<CellMetrics> {
let mut stream_opened = None::<(Option<u16>, Option<u16>, Option<u16>, Option<u16>)>;
let mut fallback_cols_rows = None::<(u16, u16)>;
for envelope in events {
match envelope.event {
DisplayTrackEvent::StreamOpened {
cell_width_px,
cell_height_px,
window_width_px,
window_height_px,
..
} => {
stream_opened = Some((
cell_width_px,
cell_height_px,
window_width_px,
window_height_px,
));
if let (Some(width), Some(height)) = (cell_width_px, cell_height_px)
&& width > 0
&& height > 0
{
return Some(CellMetrics { width, height });
}
}
DisplayTrackEvent::Resize { cols, rows } => {
if fallback_cols_rows.is_none() && cols > 0 && rows > 0 {
fallback_cols_rows = Some((cols, rows));
}
}
DisplayTrackEvent::FrameBytes { .. }
| DisplayTrackEvent::CursorSnapshot { .. }
| DisplayTrackEvent::Activity { .. }
| DisplayTrackEvent::ImageUpdate { .. }
| DisplayTrackEvent::StreamClosed => {}
}
}
let (cell_width_px, cell_height_px, window_width_px, window_height_px) = stream_opened?;
if let (Some(width), Some(height)) = (cell_width_px, cell_height_px)
&& width > 0
&& height > 0
{
return Some(CellMetrics { width, height });
}
let (window_width, window_height) = (window_width_px?, window_height_px?);
let (cols, rows) = fallback_cols_rows?;
infer_cell_metrics(window_width, window_height, cols, rows)
}
fn current_terminal_cell_metrics() -> Option<CellMetrics> {
let (cols, rows) = terminal::size().ok()?;
if cols == 0 || rows == 0 {
return None;
}
let size = terminal::window_size().ok()?;
infer_cell_metrics(size.width, size.height, cols, rows)
}
fn infer_cell_metrics(
window_width: u16,
window_height: u16,
cols: u16,
rows: u16,
) -> Option<CellMetrics> {
if window_width == 0 || window_height == 0 || cols == 0 || rows == 0 {
return None;
}
let width = (window_width / cols).max(1);
let height = (window_height / rows).max(1);
Some(CellMetrics { width, height })
}
fn capture_stream_open_metrics() -> (Option<u16>, Option<u16>, Option<u16>, Option<u16>) {
let (window_width_px, window_height_px) =
terminal::window_size().ok().map_or((None, None), |value| {
(
(value.width > 0).then_some(value.width),
(value.height > 0).then_some(value.height),
)
});
let (cell_width_px, cell_height_px) = terminal::size()
.ok()
.and_then(|(cols, rows)| {
let window_width = window_width_px?;
let window_height = window_height_px?;
infer_cell_metrics(window_width, window_height, cols, rows)
})
.map_or((None, None), |value| {
(Some(value.width), Some(value.height))
});
(
cell_width_px,
cell_height_px,
window_width_px,
window_height_px,
)
}
fn infer_export_terminal_bounds(events: &[DisplayTrackEnvelope]) -> Result<(u16, u16)> {
let mut resize_bounds = None::<(u16, u16)>;
let mut stream_bounds = None::<(u16, u16)>;
let mut cursor_cols = 0_u16;
let mut cursor_rows = 0_u16;
for envelope in events {
match envelope.event {
DisplayTrackEvent::Resize { cols, rows } => {
let cols = cols.max(1);
let rows = rows.max(1);
resize_bounds = Some(match resize_bounds {
Some((current_cols, current_rows)) => {
(current_cols.max(cols), current_rows.max(rows))
}
None => (cols, rows),
});
}
DisplayTrackEvent::StreamOpened {
cell_width_px,
cell_height_px,
window_width_px,
window_height_px,
..
} => {
if let (
Some(cell_width),
Some(cell_height),
Some(window_width),
Some(window_height),
) = (
cell_width_px,
cell_height_px,
window_width_px,
window_height_px,
) && cell_width > 0
&& cell_height > 0
{
let cols = (window_width / cell_width).max(1);
let rows = (window_height / cell_height).max(1);
stream_bounds = Some(match stream_bounds {
Some((current_cols, current_rows)) => {
(current_cols.max(cols), current_rows.max(rows))
}
None => (cols, rows),
});
}
}
DisplayTrackEvent::CursorSnapshot { x, y, .. } => {
cursor_cols = cursor_cols.max(x.saturating_add(1));
cursor_rows = cursor_rows.max(y.saturating_add(1));
}
DisplayTrackEvent::FrameBytes { .. }
| DisplayTrackEvent::Activity { .. }
| DisplayTrackEvent::ImageUpdate { .. }
| DisplayTrackEvent::StreamClosed => {}
}
}
if let Some((cols, rows)) = resize_bounds {
return Ok((cols, rows));
}
if let Some((stream_cols, stream_rows)) = stream_bounds {
return Ok((stream_cols.max(cursor_cols), stream_rows.max(cursor_rows)));
}
anyhow::bail!(
"recording export cannot infer terminal bounds: display track is missing resize events and stream-opened grid metrics"
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CursorVisualShape {
Block,
Bar,
Underline,
}
#[derive(Debug, Clone, Copy)]
struct CursorReplayState {
shape: CursorVisualShape,
blink_enabled: bool,
}
impl Default for CursorReplayState {
fn default() -> Self {
Self {
shape: CursorVisualShape::Block,
blink_enabled: true,
}
}
}
#[derive(Debug, Clone)]
struct CursorExportOptions {
mode: RecordingCursorMode,
shape: RecordingCursorShape,
blink: RecordingCursorBlinkMode,
profile: RecordingCursorProfile,
blink_period_ns: u64,
solid_after_input_ns: u64,
solid_after_output_ns: u64,
solid_after_cursor_ns: u64,
paint_mode: RecordingCursorPaintMode,
text_mode: RecordingCursorTextMode,
bar_width_pct: u8,
underline_height_pct: u8,
color_label: String,
color_override: Option<(u8, u8, u8)>,
}
#[derive(Debug, Clone, Copy)]
struct RecordedCursorSnapshot {
x: u16,
y: u16,
visible: bool,
shape: DisplayCursorShape,
blink_enabled: bool,
}
#[derive(Debug, Clone, Copy, serde::Serialize)]
#[serde(rename_all = "snake_case")]
enum CursorVisibilityReason {
Hidden,
ForcedOn,
HoldInput,
HoldOutput,
HoldCursor,
BlinkOn,
BlinkOff,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BlockPaintMode {
Invert,
Fill,
Outline,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BlockTextMode {
SwapFgBg,
ForceContrast,
}
#[derive(Debug, Clone, serde::Serialize)]
struct ExportCursorFrame {
mono_ns: u64,
row: u16,
col: u16,
visible: bool,
shape: &'static str,
blink_on: bool,
cursor_source: &'static str,
visible_reason: CursorVisibilityReason,
paint_mode_used: &'static str,
text_mode_used: &'static str,
paint_fallback_reason: Option<&'static str>,
last_input_activity_ns: Option<u64>,
last_output_activity_ns: Option<u64>,
last_cursor_activity_ns: Option<u64>,
}
#[derive(Debug, serde::Serialize)]
struct ExportMetadata<'a> {
format: &'a str,
output: &'a str,
fps: u32,
speed: f64,
emitted_frames: u32,
cursor: CursorMetadata<'a>,
frames: Vec<ExportCursorFrame>,
}
#[derive(Debug, serde::Serialize)]
struct CursorMetadata<'a> {
mode: &'a str,
shape: &'a str,
blink: &'a str,
profile: &'a str,
blink_period_ms: u32,
solid_after_input_ms: u32,
solid_after_output_ms: u32,
solid_after_cursor_ms: u32,
paint_mode: &'a str,
text_mode: &'a str,
bar_width_pct: u8,
underline_height_pct: u8,
color: &'a str,
}
fn parse_cursor_color(value: &str) -> Result<Option<(u8, u8, u8)>> {
let trimmed = value.trim();
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("auto") {
return Ok(None);
}
let Some(rgb) = parse_rgb_color(trimmed) else {
anyhow::bail!("invalid cursor color '{value}'; expected auto or a color value")
};
Ok(Some(rgb))
}
fn update_cursor_replay_state(state: &mut CursorReplayState, data: &[u8]) {
let mut index = 0usize;
while index + 4 < data.len() {
if data[index] != 0x1b || data[index + 1] != b'[' {
index += 1;
continue;
}
let mut cursor = index + 2;
let mut value: u16 = 0;
let mut saw_digit = false;
while cursor < data.len() && data[cursor].is_ascii_digit() {
saw_digit = true;
value = value
.saturating_mul(10)
.saturating_add(u16::from(data[cursor].saturating_sub(b'0')));
cursor += 1;
}
if cursor + 1 >= data.len() || data[cursor] != b' ' || data[cursor + 1] != b'q' {
index += 1;
continue;
}
let ps = if saw_digit { value } else { 0 };
match ps {
0 | 1 => {
state.shape = CursorVisualShape::Block;
state.blink_enabled = true;
}
2 => {
state.shape = CursorVisualShape::Block;
state.blink_enabled = false;
}
3 => {
state.shape = CursorVisualShape::Underline;
state.blink_enabled = true;
}
4 => {
state.shape = CursorVisualShape::Underline;
state.blink_enabled = false;
}
5 => {
state.shape = CursorVisualShape::Bar;
state.blink_enabled = true;
}
6 => {
state.shape = CursorVisualShape::Bar;
state.blink_enabled = false;
}
_ => {}
}
index = cursor + 2;
}
}
const fn display_cursor_shape_from_visual(shape: CursorVisualShape) -> DisplayCursorShape {
match shape {
CursorVisualShape::Block => DisplayCursorShape::Block,
CursorVisualShape::Bar => DisplayCursorShape::Bar,
CursorVisualShape::Underline => DisplayCursorShape::Underline,
}
}
fn cursor_snapshot_from_grid_fallback(
grid: &bmux_terminal_grid::TerminalGrid,
replay_state: CursorReplayState,
) -> RecordedCursorSnapshot {
let cursor = grid.cursor();
RecordedCursorSnapshot {
x: u16::try_from(cursor.col).unwrap_or(u16::MAX),
y: u16::try_from(cursor.row).unwrap_or(u16::MAX),
visible: cursor.visible,
shape: display_cursor_shape_from_visual(replay_state.shape),
blink_enabled: replay_state.blink_enabled,
}
}
const fn effective_cursor_shape(
options: &CursorExportOptions,
replay_state: CursorReplayState,
snapshot_shape: DisplayCursorShape,
) -> CursorVisualShape {
match options.shape {
RecordingCursorShape::Auto => match snapshot_shape {
DisplayCursorShape::Block => replay_state.shape,
DisplayCursorShape::Bar => CursorVisualShape::Bar,
DisplayCursorShape::Underline => CursorVisualShape::Underline,
},
RecordingCursorShape::Block => CursorVisualShape::Block,
RecordingCursorShape::Bar => CursorVisualShape::Bar,
RecordingCursorShape::Underline => CursorVisualShape::Underline,
}
}
#[allow(
clippy::too_many_arguments,
clippy::cast_possible_truncation,
clippy::cast_sign_loss
)]
fn compute_cursor_visibility(
options: &CursorExportOptions,
replay_state: CursorReplayState,
snapshot_blink_enabled: bool,
parser_visible: bool,
mono_ns: u64,
last_input_activity_ns: Option<u64>,
last_output_activity_ns: Option<u64>,
last_cursor_activity_ns: Option<u64>,
blink_anchor_ns: &mut Option<u64>,
) -> (bool, bool, CursorVisibilityReason) {
let base_visible = match options.mode {
RecordingCursorMode::Auto => parser_visible,
RecordingCursorMode::On => true,
RecordingCursorMode::Off => false,
};
if !base_visible {
return (false, true, CursorVisibilityReason::Hidden);
}
if matches!(options.mode, RecordingCursorMode::On) {
return (true, true, CursorVisibilityReason::ForcedOn);
}
let blink_enabled = match options.blink {
RecordingCursorBlinkMode::Auto => replay_state.blink_enabled && snapshot_blink_enabled,
RecordingCursorBlinkMode::On => true,
RecordingCursorBlinkMode::Off => false,
};
if !blink_enabled {
return (true, true, CursorVisibilityReason::ForcedOn);
}
if last_input_activity_ns
.is_some_and(|last| mono_ns.saturating_sub(last) < options.solid_after_input_ns)
{
return (true, true, CursorVisibilityReason::HoldInput);
}
if last_output_activity_ns
.is_some_and(|last| mono_ns.saturating_sub(last) < options.solid_after_output_ns)
{
return (true, true, CursorVisibilityReason::HoldOutput);
}
if last_cursor_activity_ns
.is_some_and(|last| mono_ns.saturating_sub(last) < options.solid_after_cursor_ns)
{
return (true, true, CursorVisibilityReason::HoldCursor);
}
let latest_activity = [
last_input_activity_ns,
last_output_activity_ns,
last_cursor_activity_ns,
]
.into_iter()
.flatten()
.max();
if let Some(last_activity) = latest_activity
&& matches!(options.profile, RecordingCursorProfile::Ghostty)
&& last_activity <= mono_ns
&& blink_anchor_ns.is_none_or(|anchor| last_activity > anchor)
{
*blink_anchor_ns = Some(last_activity);
}
let period = options.blink_period_ns.max(1);
let anchor = *blink_anchor_ns.get_or_insert(mono_ns);
let phase_ns = mono_ns.saturating_sub(anchor);
let blink_on = (phase_ns / period).is_multiple_of(2);
(
blink_on,
blink_on,
if blink_on {
CursorVisibilityReason::BlinkOn
} else {
CursorVisibilityReason::BlinkOff
},
)
}
const fn cursor_shape_name(shape: CursorVisualShape) -> &'static str {
match shape {
CursorVisualShape::Block => "block",
CursorVisualShape::Bar => "bar",
CursorVisualShape::Underline => "underline",
}
}
const fn paint_mode_name(mode: BlockPaintMode) -> &'static str {
match mode {
BlockPaintMode::Invert => "invert",
BlockPaintMode::Fill => "fill",
BlockPaintMode::Outline => "outline",
}
}
const fn text_mode_name(mode: BlockTextMode) -> &'static str {
match mode {
BlockTextMode::SwapFgBg => "swap_fg_bg",
BlockTextMode::ForceContrast => "force_contrast",
}
}
fn relative_luminance(rgb: (u8, u8, u8)) -> f32 {
let channel = |value: u8| {
let v = f32::from(value) / 255.0;
if v <= 0.04045 {
v / 12.92
} else {
((v + 0.055) / 1.055).powf(2.4)
}
};
0.0722f32.mul_add(
channel(rgb.2),
0.2126f32.mul_add(channel(rgb.0), 0.7152 * channel(rgb.1)),
)
}
fn contrast_ratio(a: (u8, u8, u8), b: (u8, u8, u8)) -> f32 {
let l1 = relative_luminance(a);
let l2 = relative_luminance(b);
let (high, low) = if l1 >= l2 { (l1, l2) } else { (l2, l1) };
(high + 0.05) / (low + 0.05)
}
fn pick_contrast_text_color(fill: (u8, u8, u8)) -> (u8, u8, u8) {
if contrast_ratio((0, 0, 0), fill) >= contrast_ratio((255, 255, 255), fill) {
(0, 0, 0)
} else {
(255, 255, 255)
}
}
#[allow(
clippy::too_many_arguments,
clippy::too_many_lines,
clippy::cast_possible_truncation,
clippy::cast_sign_loss
)]
fn overlay_cursor_rgba(
pixels: &mut [u8],
frame_width: usize,
frame_height: usize,
cell_w: usize,
cell_h: usize,
row: u16,
col: u16,
shape: CursorVisualShape,
paint_mode: BlockPaintMode,
text_mode: BlockTextMode,
bar_width_pct: u8,
underline_height_pct: u8,
cell_foreground: (u8, u8, u8),
cell_background: (u8, u8, u8),
color: (u8, u8, u8),
) -> (BlockPaintMode, BlockTextMode, Option<&'static str>) {
if frame_width == 0 || frame_height == 0 || cell_w == 0 || cell_h == 0 {
return (paint_mode, text_mode, None);
}
let x0 = usize::from(col).saturating_mul(cell_w);
let y0 = usize::from(row).saturating_mul(cell_h);
if x0 >= frame_width || y0 >= frame_height {
return (paint_mode, text_mode, None);
}
let resolved_paint_mode = paint_mode;
let mut resolved_text_mode = text_mode;
let mut fallback_reason = None;
match shape {
CursorVisualShape::Block => match resolved_paint_mode {
BlockPaintMode::Invert => {
for py in 0..cell_h {
let y = y0 + py;
if y >= frame_height {
continue;
}
for px in 0..cell_w {
let x = x0 + px;
if x >= frame_width {
continue;
}
let idx = (y * frame_width + x) * 4;
pixels[idx] = 255_u8.saturating_sub(pixels[idx]);
pixels[idx + 1] = 255_u8.saturating_sub(pixels[idx + 1]);
pixels[idx + 2] = 255_u8.saturating_sub(pixels[idx + 2]);
pixels[idx + 3] = 255;
}
}
}
BlockPaintMode::Fill => {
let mut effective_text_mode = text_mode;
if matches!(text_mode, BlockTextMode::SwapFgBg)
&& contrast_ratio(cell_background, color) < 2.0
{
effective_text_mode = BlockTextMode::ForceContrast;
fallback_reason = Some("swap_fg_bg_low_contrast");
}
resolved_text_mode = effective_text_mode;
let fill_text = match effective_text_mode {
BlockTextMode::SwapFgBg => cell_background,
BlockTextMode::ForceContrast => pick_contrast_text_color(color),
};
for py in 0..cell_h {
let y = y0 + py;
if y >= frame_height {
continue;
}
for px in 0..cell_w {
let x = x0 + px;
if x >= frame_width {
continue;
}
let idx = (y * frame_width + x) * 4;
pixels[idx] = color.0;
pixels[idx + 1] = color.1;
pixels[idx + 2] = color.2;
pixels[idx + 3] = 255;
}
}
let inset_x = (cell_w / 8).max(1);
let inset_y = (cell_h / 8).max(1);
if cell_w > inset_x.saturating_mul(2) && cell_h > inset_y.saturating_mul(2) {
for py in inset_y..(cell_h - inset_y) {
let y = y0 + py;
if y >= frame_height {
continue;
}
for px in inset_x..(cell_w - inset_x) {
let x = x0 + px;
if x >= frame_width {
continue;
}
let idx = (y * frame_width + x) * 4;
pixels[idx] = fill_text.0;
pixels[idx + 1] = fill_text.1;
pixels[idx + 2] = fill_text.2;
pixels[idx + 3] = 255;
}
}
}
}
BlockPaintMode::Outline => {
for py in 0..cell_h {
let y = y0 + py;
if y >= frame_height {
continue;
}
for px in 0..cell_w {
let x = x0 + px;
if x >= frame_width {
continue;
}
if px > 0
&& py > 0
&& px < cell_w.saturating_sub(1)
&& py < cell_h.saturating_sub(1)
{
continue;
}
let idx = (y * frame_width + x) * 4;
pixels[idx] = color.0;
pixels[idx + 1] = color.1;
pixels[idx + 2] = color.2;
pixels[idx + 3] = 255;
}
}
}
},
CursorVisualShape::Bar => {
let bar_width =
((cell_w.saturating_mul(usize::from(bar_width_pct.clamp(1, 100)))) / 100).max(1);
for py in 0..cell_h {
let y = y0 + py;
if y >= frame_height {
continue;
}
for px in 0..bar_width {
let x = x0 + px;
if x >= frame_width {
continue;
}
let idx = (y * frame_width + x) * 4;
pixels[idx] = color.0;
pixels[idx + 1] = color.1;
pixels[idx + 2] = color.2;
pixels[idx + 3] = 255;
}
}
}
CursorVisualShape::Underline => {
let line_height =
((cell_h.saturating_mul(usize::from(underline_height_pct.clamp(1, 100)))) / 100)
.max(1);
let start_y = y0 + cell_h.saturating_sub(line_height);
for py in start_y..(start_y + line_height) {
if py >= frame_height {
continue;
}
for px in 0..cell_w {
let x = x0 + px;
if x >= frame_width {
continue;
}
let idx = (py * frame_width + x) * 4;
pixels[idx] = color.0;
pixels[idx + 1] = color.1;
pixels[idx + 2] = color.2;
pixels[idx + 3] = 255;
}
}
}
}
let _ = cell_foreground;
(resolved_paint_mode, resolved_text_mode, fallback_reason)
}
#[allow(
clippy::too_many_arguments,
clippy::too_many_lines,
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss
)]
fn export_recording_gif(
events: &[DisplayTrackEnvelope],
output: &str,
speed: f64,
fps: u32,
max_duration: Option<u64>,
max_frames: Option<u32>,
terminal_profile: Option<&terminal_profile::DetectedTerminalProfile>,
recording_profile: Option<&terminal_profile::DetectedTerminalProfile>,
host_profile: Option<&terminal_profile::DetectedTerminalProfile>,
renderer: RecordingRenderMode,
cell_size: Option<(u16, u16)>,
cell_width: Option<u16>,
cell_height: Option<u16>,
font_family: Option<&str>,
font_size: Option<f32>,
line_height: Option<f32>,
font_path: &[String],
palette_source: RecordingPaletteSource,
palette_foreground: Option<&str>,
palette_background: Option<&str>,
palette_color: &[String],
cursor_mode: RecordingCursorMode,
cursor_shape: RecordingCursorShape,
cursor_blink: RecordingCursorBlinkMode,
cursor_blink_period_ms: u32,
cursor_color: &str,
cursor_profile: RecordingCursorProfile,
cursor_solid_after_activity_ms: Option<u32>,
cursor_solid_after_input_ms: Option<u32>,
cursor_solid_after_output_ms: Option<u32>,
cursor_solid_after_cursor_ms: Option<u32>,
cursor_paint_mode: RecordingCursorPaintMode,
cursor_text_mode: RecordingCursorTextMode,
cursor_bar_width_pct: u8,
cursor_underline_height_pct: u8,
export_metadata: Option<&str>,
show_progress: bool,
) -> Result<()> {
let mut profiler = ExportProfiler::new();
let speed = if speed <= 0.0 { 1.0 } else { speed };
let fps = fps.max(1);
let frame_interval_ns = (1_000_000_000_f64 / f64::from(fps)) as u64;
let estimate = estimate_export_progress(events, speed, fps, max_duration, max_frames);
let mut progress = ExportProgress::new(show_progress, estimate);
let profile_defaults = terminal_profile.map(|profile| &profile.cursor_defaults);
let resolved_shape = if matches!(cursor_shape, RecordingCursorShape::Auto) {
profile_defaults
.and_then(|defaults| defaults.shape)
.map_or(cursor_shape, |shape| match shape {
terminal_profile::CursorDefaultShape::Block => RecordingCursorShape::Block,
terminal_profile::CursorDefaultShape::Bar => RecordingCursorShape::Bar,
terminal_profile::CursorDefaultShape::Underline => RecordingCursorShape::Underline,
})
} else {
cursor_shape
};
let resolved_blink = if matches!(cursor_blink, RecordingCursorBlinkMode::Auto) {
profile_defaults
.and_then(|defaults| defaults.blink)
.map_or(cursor_blink, |blink| match blink {
terminal_profile::CursorDefaultBlink::On => RecordingCursorBlinkMode::On,
terminal_profile::CursorDefaultBlink::Off => RecordingCursorBlinkMode::Off,
})
} else {
cursor_blink
};
let resolved_profile = if matches!(cursor_profile, RecordingCursorProfile::Auto) {
profile_defaults
.and_then(|defaults| defaults.profile)
.map_or(RecordingCursorProfile::Generic, |profile| match profile {
terminal_profile::CursorDefaultProfile::Ghostty => RecordingCursorProfile::Ghostty,
terminal_profile::CursorDefaultProfile::Generic => RecordingCursorProfile::Generic,
})
} else {
cursor_profile
};
let resolved_paint_mode = if matches!(cursor_paint_mode, RecordingCursorPaintMode::Auto) {
profile_defaults
.and_then(|defaults| defaults.paint_mode)
.map_or(
match resolved_profile {
RecordingCursorProfile::Ghostty => RecordingCursorPaintMode::Fill,
_ => RecordingCursorPaintMode::Invert,
},
|mode| match mode {
terminal_profile::CursorDefaultPaintMode::Invert => {
RecordingCursorPaintMode::Invert
}
terminal_profile::CursorDefaultPaintMode::Fill => {
RecordingCursorPaintMode::Fill
}
terminal_profile::CursorDefaultPaintMode::Outline => {
RecordingCursorPaintMode::Outline
}
},
)
} else {
cursor_paint_mode
};
let resolved_text_mode = if matches!(cursor_text_mode, RecordingCursorTextMode::Auto) {
profile_defaults
.and_then(|defaults| defaults.text_mode)
.map_or(
match resolved_profile {
RecordingCursorProfile::Ghostty => RecordingCursorTextMode::SwapFgBg,
_ => RecordingCursorTextMode::ForceContrast,
},
|mode| match mode {
terminal_profile::CursorDefaultTextMode::SwapFgBg => {
RecordingCursorTextMode::SwapFgBg
}
terminal_profile::CursorDefaultTextMode::ForceContrast => {
RecordingCursorTextMode::ForceContrast
}
},
)
} else {
cursor_text_mode
};
let resolved_bar_width_pct = profile_defaults
.and_then(|defaults| defaults.bar_width_pct)
.unwrap_or(cursor_bar_width_pct)
.clamp(1, 100);
let resolved_underline_height_pct = profile_defaults
.and_then(|defaults| defaults.underline_height_pct)
.unwrap_or(cursor_underline_height_pct)
.clamp(1, 100);
let resolved_solid_after_input_ms = cursor_solid_after_input_ms
.or(cursor_solid_after_activity_ms)
.or_else(|| profile_defaults.and_then(|defaults| defaults.solid_after_input_ms))
.unwrap_or(500);
let resolved_solid_after_output_ms = cursor_solid_after_output_ms
.or(cursor_solid_after_activity_ms)
.or_else(|| profile_defaults.and_then(|defaults| defaults.solid_after_output_ms))
.unwrap_or(500);
let resolved_solid_after_cursor_ms = cursor_solid_after_cursor_ms
.or(cursor_solid_after_activity_ms)
.or_else(|| profile_defaults.and_then(|defaults| defaults.solid_after_cursor_ms))
.unwrap_or(500);
let color_input = cursor_color.trim();
let (resolved_color_label, resolved_color_override) =
if color_input.is_empty() || color_input.eq_ignore_ascii_case("auto") {
profile_defaults
.and_then(|defaults| defaults.color.as_deref())
.map_or_else(
|| ("auto".to_string(), None),
|profile_color| {
let parsed = parse_cursor_color(profile_color).ok().flatten();
if parsed.is_some() {
(profile_color.to_string(), parsed)
} else {
("auto".to_string(), None)
}
},
)
} else {
(color_input.to_string(), parse_cursor_color(color_input)?)
};
let cursor_options = CursorExportOptions {
mode: cursor_mode,
shape: resolved_shape,
blink: resolved_blink,
profile: resolved_profile,
blink_period_ns: u64::from(cursor_blink_period_ms.max(1)).saturating_mul(1_000_000),
solid_after_input_ns: u64::from(resolved_solid_after_input_ms).saturating_mul(1_000_000),
solid_after_output_ns: u64::from(resolved_solid_after_output_ms).saturating_mul(1_000_000),
solid_after_cursor_ns: u64::from(resolved_solid_after_cursor_ms).saturating_mul(1_000_000),
paint_mode: resolved_paint_mode,
text_mode: resolved_text_mode,
bar_width_pct: resolved_bar_width_pct,
underline_height_pct: resolved_underline_height_pct,
color_label: resolved_color_label,
color_override: resolved_color_override,
};
let (max_cols, max_rows) = infer_export_terminal_bounds(events)?;
let cell_metrics = resolve_export_cell_metrics(events, cell_size, cell_width, cell_height)?;
let cell_w = cell_metrics.width;
let cell_h = cell_metrics.height;
let width = max_cols.saturating_mul(cell_w).max(8);
let height = max_rows.saturating_mul(cell_h).max(8);
let render_options = build_render_options(
terminal_profile,
renderer,
font_family,
font_size,
line_height,
font_path,
)?;
let renderer_init_started_at = profiler.stage_started();
let palette = resolve_export_palette(
palette_source,
recording_profile,
host_profile,
palette_foreground,
palette_background,
palette_color,
)?;
let mut glyph_renderer = match render_options.mode {
RecordingRenderMode::Font => GlyphRenderer::new(cell_w, cell_h, &render_options),
RecordingRenderMode::Bitmap => None,
};
let mut resvg_renderer = match render_options.mode {
RecordingRenderMode::Font => Some(
ResvgFrameRenderer::new(max_rows, max_cols, cell_w, cell_h, &render_options)
.map_err(|error| {
profiler.note_resvg_fallback();
tracing::warn!(
"recording export: resvg renderer init failed, falling back to bitmap: {error:#}"
);
error
})
.ok(),
),
RecordingRenderMode::Bitmap => None,
}
.flatten();
let mut bitmap_cache = BitmapGlyphCache::new(usize::from(cell_w), usize::from(cell_h));
profiler.record_renderer_init(renderer_init_started_at);
let output_path = PathBuf::from(output);
if let Some(parent) = output_path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent).with_context(|| {
format!(
"failed creating export parent directory {}",
parent.display()
)
})?;
}
let file = std::fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&output_path)
.with_context(|| format!("failed opening export output {}", output_path.display()))?;
let mut encoder =
GifEncoder::new(file, width, height, &[]).context("failed creating gif encoder")?;
encoder
.set_repeat(Repeat::Infinite)
.context("failed setting gif repeat")?;
let mut terminal_grid = bmux_terminal_grid::TerminalGridStream::new(
max_cols.max(1),
max_rows.max(1),
bmux_terminal_grid::GridLimits::default(),
)
.expect("recording export grid dimensions are valid");
let mut current_cols = max_cols;
let mut current_rows = max_rows;
let mut emitted_frames = 0_u32;
let mut processed_frame_events = 0_u32;
let mut previous_emit_frame_idx = None::<u32>;
let mut gif_delay_clock = GifDelayClock::new(fps);
let mut cursor_state = CursorReplayState::default();
let mut snapshot_cursor_state = None::<RecordedCursorSnapshot>;
let mut cursor_frames = export_metadata.map(|_| Vec::<ExportCursorFrame>::new());
let mut blink_anchor_ns = None::<u64>;
let mut last_input_activity_ns = None::<u64>;
let mut last_output_activity_ns = None::<u64>;
let mut last_cursor_activity_ns = None::<u64>;
let mut warned_cursor_snapshot_fallback = false;
let mut previous_visual_state = None::<FrameVisualState>;
let start_mono_ns = events.iter().map(|event| event.mono_ns).min().unwrap_or(0);
let frame_cutoff_ns = max_frames.map(|limit| {
if limit == 0 {
0_u64
} else {
u64::from(limit.saturating_sub(1)).saturating_mul(frame_interval_ns)
}
});
let mut considered_event_count = 0_usize;
let mut end_scaled_ns = 0_u64;
for event in events {
let rel_mono_ns = event.mono_ns.saturating_sub(start_mono_ns);
if let Some(limit_secs) = max_duration
&& rel_mono_ns / 1_000_000_000 > limit_secs
{
break;
}
let scaled_ns = ((rel_mono_ns as f64) / speed) as u64;
if let Some(cutoff) = frame_cutoff_ns
&& scaled_ns > cutoff
{
break;
}
considered_event_count = considered_event_count.saturating_add(1);
end_scaled_ns = scaled_ns;
}
let max_timeline_frames = if considered_event_count == 0 {
0_u32
} else {
let base = end_scaled_ns
.saturating_div(frame_interval_ns.max(1))
.saturating_add(1);
base.min(u64::from(u32::MAX)) as u32
};
let target_frames =
max_frames.map_or(max_timeline_frames, |limit| limit.min(max_timeline_frames));
let mut event_index = 0_usize;
#[cfg(any(
feature = "image-sixel",
feature = "image-kitty",
feature = "image-iterm2"
))]
let mut active_images: Vec<bmux_attach_image_protocol::AttachPaneImage> = Vec::new();
for frame_idx in 0..target_frames {
profiler.record_frame_considered();
let frame_time_ns = u64::from(frame_idx).saturating_mul(frame_interval_ns);
let apply_started_at = profiler.stage_started();
let mut frame_had_display_change = false;
while event_index < considered_event_count {
let event = &events[event_index];
let rel_mono_ns = event.mono_ns.saturating_sub(start_mono_ns);
let scaled_ns = ((rel_mono_ns as f64) / speed) as u64;
if scaled_ns > frame_time_ns {
break;
}
match &event.event {
DisplayTrackEvent::Resize { cols, rows } => {
current_cols = (*cols).max(1);
current_rows = (*rows).max(1);
let _ = terminal_grid.resize(current_cols, current_rows);
frame_had_display_change = true;
}
DisplayTrackEvent::FrameBytes { data } => {
update_cursor_replay_state(&mut cursor_state, data);
terminal_grid.process(data);
processed_frame_events = processed_frame_events.saturating_add(1);
frame_had_display_change = true;
}
DisplayTrackEvent::CursorSnapshot {
x,
y,
visible,
shape,
blink_enabled,
} => {
snapshot_cursor_state = Some(RecordedCursorSnapshot {
x: *x,
y: *y,
visible: *visible,
shape: *shape,
blink_enabled: *blink_enabled,
});
frame_had_display_change = true;
}
DisplayTrackEvent::Activity { kind } => match kind {
DisplayActivityKind::Input => {
last_input_activity_ns = Some(scaled_ns);
}
DisplayActivityKind::Output => {
last_output_activity_ns = Some(scaled_ns);
}
DisplayActivityKind::Cursor => {
last_cursor_activity_ns = Some(scaled_ns);
}
},
DisplayTrackEvent::StreamOpened { .. } | DisplayTrackEvent::StreamClosed => {}
DisplayTrackEvent::ImageUpdate { images } => {
#[cfg(any(
feature = "image-sixel",
feature = "image-kitty",
feature = "image-iterm2"
))]
{
active_images.clone_from(images);
}
let _ = images; frame_had_display_change = true;
}
}
event_index = event_index.saturating_add(1);
}
profiler.record_apply_events(apply_started_at);
if processed_frame_events == 0 {
progress.update(processed_frame_events, emitted_frames, false);
continue;
}
let (snapshot, cursor_source) = snapshot_cursor_state.map_or_else(
|| {
if !warned_cursor_snapshot_fallback {
tracing::warn!(
"recording export: display track missing initial cursor snapshot; using structured grid cursor fallback until snapshots appear"
);
warned_cursor_snapshot_fallback = true;
}
(
cursor_snapshot_from_grid_fallback(terminal_grid.grid(), cursor_state),
"grid_fallback",
)
},
|snapshot| (snapshot, "snapshot"),
);
let cursor_row = snapshot.y;
let cursor_col = snapshot.x;
let grid_cursor_visible = snapshot.visible;
let shape = effective_cursor_shape(&cursor_options, cursor_state, snapshot.shape);
let (cursor_visible, blink_on, visible_reason) = compute_cursor_visibility(
&cursor_options,
cursor_state,
snapshot.blink_enabled,
grid_cursor_visible,
frame_time_ns,
last_input_activity_ns,
last_output_activity_ns,
last_cursor_activity_ns,
&mut blink_anchor_ns,
);
let visual_state = FrameVisualState {
rows: current_rows,
cols: current_cols,
cursor_row,
cursor_col,
cursor_visible,
shape,
blink_on,
};
if !frame_had_display_change && previous_visual_state == Some(visual_state) {
profiler.record_frame_skipped();
progress.update(processed_frame_events, emitted_frames, false);
continue;
}
let frame_span =
previous_emit_frame_idx.map_or(1, |previous| frame_idx.saturating_sub(previous).max(1));
let delay_cs = gif_delay_clock.delay_for_frame_span(frame_span);
let render_started_at = profiler.stage_started();
let mut pixels = if render_options.mode == RecordingRenderMode::Font {
if let Some(renderer) = resvg_renderer.as_mut() {
match renderer.render(terminal_grid.grid(), current_rows, current_cols, &palette) {
Ok(pixels) => pixels,
Err(error) => {
profiler.note_resvg_fallback();
tracing::warn!(
"recording export: resvg frame render failed, falling back to bitmap: {error:#}"
);
resvg_renderer = None;
render_screen_rgba(
terminal_grid.grid(),
current_rows,
current_cols,
max_rows,
max_cols,
cell_w,
cell_h,
&palette,
glyph_renderer.as_mut(),
&mut bitmap_cache,
)
}
}
} else {
render_screen_rgba(
terminal_grid.grid(),
current_rows,
current_cols,
max_rows,
max_cols,
cell_w,
cell_h,
&palette,
glyph_renderer.as_mut(),
&mut bitmap_cache,
)
}
} else {
render_screen_rgba(
terminal_grid.grid(),
current_rows,
current_cols,
max_rows,
max_cols,
cell_w,
cell_h,
&palette,
glyph_renderer.as_mut(),
&mut bitmap_cache,
)
};
#[cfg(any(
feature = "image-sixel",
feature = "image-kitty",
feature = "image-iterm2"
))]
if !active_images.is_empty() {
overlay_display_track_images(
&mut pixels,
u32::from(width),
u32::from(height),
u32::from(cell_w),
u32::from(cell_h),
&active_images,
);
}
if cursor_visible && cursor_row < current_rows && cursor_col < current_cols {
let (cell_foreground, cell_background) = grid_cell_at(
terminal_grid.grid(),
usize::from(cursor_row),
usize::from(cursor_col),
)
.map_or(((255, 255, 255), (0, 0, 0)), |cell| {
resolved_grid_cell_colors(terminal_grid.grid(), &cell, &palette)
});
let cursor_color_rgb = cursor_options.color_override.unwrap_or(cell_foreground);
let (paint_mode_used, text_mode_used, paint_fallback_reason) = overlay_cursor_rgba(
&mut pixels,
usize::from(width),
usize::from(height),
usize::from(cell_w),
usize::from(cell_h),
cursor_row,
cursor_col,
shape,
match cursor_options.paint_mode {
RecordingCursorPaintMode::Auto | RecordingCursorPaintMode::Invert => {
BlockPaintMode::Invert
}
RecordingCursorPaintMode::Fill => BlockPaintMode::Fill,
RecordingCursorPaintMode::Outline => BlockPaintMode::Outline,
},
match cursor_options.text_mode {
RecordingCursorTextMode::Auto | RecordingCursorTextMode::SwapFgBg => {
BlockTextMode::SwapFgBg
}
RecordingCursorTextMode::ForceContrast => BlockTextMode::ForceContrast,
},
cursor_options.bar_width_pct,
cursor_options.underline_height_pct,
cell_foreground,
cell_background,
cursor_color_rgb,
);
if let Some(frames) = cursor_frames.as_mut() {
frames.push(ExportCursorFrame {
mono_ns: frame_time_ns,
row: cursor_row,
col: cursor_col,
visible: cursor_visible,
shape: cursor_shape_name(shape),
blink_on,
cursor_source,
visible_reason,
paint_mode_used: paint_mode_name(paint_mode_used),
text_mode_used: text_mode_name(text_mode_used),
paint_fallback_reason,
last_input_activity_ns,
last_output_activity_ns,
last_cursor_activity_ns,
});
}
} else if let Some(frames) = cursor_frames.as_mut() {
frames.push(ExportCursorFrame {
mono_ns: frame_time_ns,
row: cursor_row,
col: cursor_col,
visible: cursor_visible,
shape: cursor_shape_name(shape),
blink_on,
cursor_source,
visible_reason,
paint_mode_used: paint_mode_name(match cursor_options.paint_mode {
RecordingCursorPaintMode::Auto | RecordingCursorPaintMode::Invert => {
BlockPaintMode::Invert
}
RecordingCursorPaintMode::Fill => BlockPaintMode::Fill,
RecordingCursorPaintMode::Outline => BlockPaintMode::Outline,
}),
text_mode_used: text_mode_name(match cursor_options.text_mode {
RecordingCursorTextMode::Auto | RecordingCursorTextMode::SwapFgBg => {
BlockTextMode::SwapFgBg
}
RecordingCursorTextMode::ForceContrast => BlockTextMode::ForceContrast,
}),
paint_fallback_reason: None,
last_input_activity_ns,
last_output_activity_ns,
last_cursor_activity_ns,
});
}
profiler.record_render(render_started_at);
let encode_started_at = profiler.stage_started();
let mut frame =
GifFrame::from_rgba_speed(width, height, &mut pixels, GIF_QUANTIZATION_SAMPLE_FACTOR);
frame.delay = delay_cs;
encoder
.write_frame(&frame)
.context("failed writing gif frame")?;
profiler.record_encode(encode_started_at);
previous_visual_state = Some(visual_state);
previous_emit_frame_idx = Some(frame_idx);
emitted_frames = emitted_frames.saturating_add(1);
profiler.record_frame_emitted();
progress.update(processed_frame_events, emitted_frames, false);
}
progress.finish(processed_frame_events, emitted_frames);
profiler.finish(processed_frame_events, emitted_frames);
if emitted_frames == 0 {
anyhow::bail!("no drawable frame events found in display track")
}
if let Some(path) = export_metadata {
let metadata_path = PathBuf::from(path);
if let Some(parent) = metadata_path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent).with_context(|| {
format!(
"failed creating export metadata parent directory {}",
parent.display()
)
})?;
}
let metadata = ExportMetadata {
format: "gif",
output,
fps,
speed,
emitted_frames,
cursor: CursorMetadata {
mode: match cursor_options.mode {
RecordingCursorMode::Auto => "auto",
RecordingCursorMode::On => "on",
RecordingCursorMode::Off => "off",
},
shape: match cursor_options.shape {
RecordingCursorShape::Auto => "auto",
RecordingCursorShape::Block => "block",
RecordingCursorShape::Bar => "bar",
RecordingCursorShape::Underline => "underline",
},
blink: match cursor_options.blink {
RecordingCursorBlinkMode::Auto => "auto",
RecordingCursorBlinkMode::On => "on",
RecordingCursorBlinkMode::Off => "off",
},
profile: match cursor_options.profile {
RecordingCursorProfile::Auto => "auto",
RecordingCursorProfile::Ghostty => "ghostty",
RecordingCursorProfile::Generic => "generic",
},
blink_period_ms: cursor_blink_period_ms.max(1),
solid_after_input_ms: resolved_solid_after_input_ms,
solid_after_output_ms: resolved_solid_after_output_ms,
solid_after_cursor_ms: resolved_solid_after_cursor_ms,
paint_mode: match cursor_options.paint_mode {
RecordingCursorPaintMode::Auto => "auto",
RecordingCursorPaintMode::Invert => "invert",
RecordingCursorPaintMode::Fill => "fill",
RecordingCursorPaintMode::Outline => "outline",
},
text_mode: match cursor_options.text_mode {
RecordingCursorTextMode::Auto => "auto",
RecordingCursorTextMode::SwapFgBg => "swap_fg_bg",
RecordingCursorTextMode::ForceContrast => "force_contrast",
},
bar_width_pct: cursor_options.bar_width_pct,
underline_height_pct: cursor_options.underline_height_pct,
color: &cursor_options.color_label,
},
frames: cursor_frames.unwrap_or_default(),
};
let json = serde_json::to_vec_pretty(&metadata)
.context("failed serializing export cursor metadata")?;
std::fs::write(&metadata_path, json).with_context(|| {
format!("failed writing export metadata {}", metadata_path.display())
})?;
}
Ok(())
}
#[derive(Debug, Clone, Copy)]
struct ExportProgressEstimate {
total_frame_events: u32,
estimated_emitted_frames: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct FrameVisualState {
rows: u16,
cols: u16,
cursor_row: u16,
cursor_col: u16,
cursor_visible: bool,
shape: CursorVisualShape,
blink_on: bool,
}
#[derive(Debug)]
struct GifDelayClock {
fps: u32,
remainder: u64,
}
impl GifDelayClock {
fn new(fps: u32) -> Self {
Self {
fps: fps.max(1),
remainder: 0,
}
}
fn delay_for_frame_span(&mut self, frame_span: u32) -> u16 {
let total = u64::from(frame_span.max(1))
.saturating_mul(100)
.saturating_add(self.remainder);
let fps = u64::from(self.fps.max(1));
let mut delay = total / fps;
self.remainder = total % fps;
if delay == 0 {
delay = 1;
self.remainder = 0;
}
u16::try_from(delay.min(u64::from(u16::MAX))).unwrap_or(u16::MAX)
}
}
#[derive(Debug)]
struct ExportProfiler {
enabled: bool,
started_at: Instant,
renderer_init: std::time::Duration,
apply_events: std::time::Duration,
render: std::time::Duration,
encode: std::time::Duration,
frames_considered: u32,
frames_emitted: u32,
frames_skipped: u32,
resvg_fallbacks: u32,
}
impl ExportProfiler {
fn new() -> Self {
let enabled = std::env::var("BMUX_RECORDING_EXPORT_PROFILE")
.ok()
.is_some_and(|value| {
let normalized = value.trim().to_ascii_lowercase();
matches!(normalized.as_str(), "1" | "true" | "yes" | "on")
});
Self {
enabled,
started_at: Instant::now(),
renderer_init: std::time::Duration::ZERO,
apply_events: std::time::Duration::ZERO,
render: std::time::Duration::ZERO,
encode: std::time::Duration::ZERO,
frames_considered: 0,
frames_emitted: 0,
frames_skipped: 0,
resvg_fallbacks: 0,
}
}
fn stage_started(&self) -> Option<Instant> {
self.enabled.then(Instant::now)
}
fn record_renderer_init(&mut self, started_at: Option<Instant>) {
if let Some(started_at) = started_at {
self.renderer_init += started_at.elapsed();
}
}
fn record_apply_events(&mut self, started_at: Option<Instant>) {
if let Some(started_at) = started_at {
self.apply_events += started_at.elapsed();
}
}
fn record_render(&mut self, started_at: Option<Instant>) {
if let Some(started_at) = started_at {
self.render += started_at.elapsed();
}
}
fn record_encode(&mut self, started_at: Option<Instant>) {
if let Some(started_at) = started_at {
self.encode += started_at.elapsed();
}
}
const fn record_frame_considered(&mut self) {
self.frames_considered = self.frames_considered.saturating_add(1);
}
const fn record_frame_emitted(&mut self) {
self.frames_emitted = self.frames_emitted.saturating_add(1);
}
const fn record_frame_skipped(&mut self) {
self.frames_skipped = self.frames_skipped.saturating_add(1);
}
const fn note_resvg_fallback(&mut self) {
self.resvg_fallbacks = self.resvg_fallbacks.saturating_add(1);
}
fn finish(&self, processed_frame_events: u32, emitted_frames: u32) {
if !self.enabled {
return;
}
let elapsed = self.started_at.elapsed();
let considered = self.frames_considered.max(1);
let avg_render_ms = self.render.as_secs_f64() * 1000.0 / f64::from(considered);
let avg_encode_ms = self.encode.as_secs_f64() * 1000.0 / f64::from(considered);
tracing::info!(
"recording export profile: elapsed={} init={} apply={} render={} encode={} frames_considered={} frames_emitted={} frames_skipped={} processed_frame_events={} emitted_frames={} resvg_fallbacks={} avg_render_ms={avg_render_ms:.3} avg_encode_ms={avg_encode_ms:.3}",
format_duration_compact(elapsed),
format_duration_compact(self.renderer_init),
format_duration_compact(self.apply_events),
format_duration_compact(self.render),
format_duration_compact(self.encode),
self.frames_considered,
self.frames_emitted,
self.frames_skipped,
processed_frame_events,
emitted_frames,
self.resvg_fallbacks,
);
}
}
#[allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss
)]
fn estimate_export_progress(
events: &[DisplayTrackEnvelope],
speed: f64,
fps: u32,
max_duration: Option<u64>,
max_frames: Option<u32>,
) -> ExportProgressEstimate {
let speed = if speed <= 0.0 { 1.0 } else { speed };
let frame_interval_ns = (1_000_000_000_f64 / f64::from(fps.max(1))) as u64;
let mut total_frame_events = 0_u32;
let mut considered_event_count = 0_u32;
let start_mono_ns = events.iter().map(|event| event.mono_ns).min().unwrap_or(0);
let frame_cutoff_ns = max_frames.map(|limit| {
if limit == 0 {
0_u64
} else {
u64::from(limit.saturating_sub(1)).saturating_mul(frame_interval_ns)
}
});
let mut end_scaled_ns = 0_u64;
for event in events {
let rel_mono_ns = event.mono_ns.saturating_sub(start_mono_ns);
if let Some(limit_secs) = max_duration
&& rel_mono_ns / 1_000_000_000 > limit_secs
{
break;
}
let scaled_ns = ((rel_mono_ns as f64) / speed) as u64;
if let Some(cutoff) = frame_cutoff_ns
&& scaled_ns > cutoff
{
break;
}
considered_event_count = considered_event_count.saturating_add(1);
end_scaled_ns = scaled_ns;
if let DisplayTrackEvent::FrameBytes { .. } = event.event {
total_frame_events = total_frame_events.saturating_add(1);
}
}
let base_emitted_frames = if considered_event_count == 0 || total_frame_events == 0 {
0_u32
} else {
end_scaled_ns
.saturating_div(frame_interval_ns.max(1))
.saturating_add(1)
.min(u64::from(u32::MAX)) as u32
};
let estimated_emitted_frames =
max_frames.map_or(base_emitted_frames, |limit| limit.min(base_emitted_frames));
ExportProgressEstimate {
total_frame_events,
estimated_emitted_frames,
}
}
struct ExportProgress {
enabled: bool,
tty: bool,
started_at: Instant,
last_update_at: Instant,
last_line_len: usize,
last_non_tty_bucket: Option<u32>,
estimate: ExportProgressEstimate,
}
impl ExportProgress {
#[allow(clippy::cast_possible_truncation)]
fn new(show_progress: bool, estimate: ExportProgressEstimate) -> Self {
Self {
enabled: show_progress,
tty: show_progress && io::stderr().is_terminal(),
started_at: Instant::now(),
last_update_at: Instant::now(),
last_line_len: 0,
last_non_tty_bucket: None,
estimate,
}
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
fn update(&mut self, processed_frame_events: u32, emitted_frames: u32, force: bool) {
if !self.enabled || self.estimate.total_frame_events == 0 {
return;
}
let now = Instant::now();
if !force && now.duration_since(self.last_update_at) < std::time::Duration::from_millis(300)
{
return;
}
let percent = (f64::from(processed_frame_events)
/ f64::from(self.estimate.total_frame_events.max(1))
* 100.0)
.clamp(0.0, 100.0);
let elapsed = now.duration_since(self.started_at);
let eta = estimate_eta(
elapsed,
processed_frame_events,
self.estimate.total_frame_events,
);
let estimated_emitted = self.estimate.estimated_emitted_frames.max(emitted_frames);
let line = format!(
"export {percent:5.1}% events {processed_frame_events}/{} frames {emitted_frames}/{} elapsed {} eta {}",
self.estimate.total_frame_events,
estimated_emitted,
format_duration_compact(elapsed),
eta.map_or_else(|| "--:--".to_string(), format_duration_compact),
);
if self.tty {
let mut padded = line;
if self.last_line_len > padded.len() {
padded.push_str(&" ".repeat(self.last_line_len - padded.len()));
}
eprint!("\r{padded}");
let _ = io::stderr().flush();
self.last_line_len = padded.len();
self.last_update_at = now;
return;
}
let bucket = percent.floor() as u32 / 10;
if force
|| self
.last_non_tty_bucket
.is_none_or(|previous| bucket > previous)
{
eprintln!("{line}");
self.last_non_tty_bucket = Some(bucket);
self.last_update_at = now;
}
}
fn finish(&mut self, processed_frame_events: u32, emitted_frames: u32) {
self.update(processed_frame_events, emitted_frames, true);
if self.enabled && self.tty {
eprintln!();
}
}
}
fn estimate_eta(
elapsed: std::time::Duration,
completed: u32,
total: u32,
) -> Option<std::time::Duration> {
if completed == 0 || completed >= total {
return (completed >= total).then_some(std::time::Duration::from_secs(0));
}
let remaining_ratio = f64::from(total.saturating_sub(completed)) / f64::from(completed);
Some(elapsed.mul_f64(remaining_ratio))
}
fn format_duration_compact(duration: std::time::Duration) -> String {
let total_secs = duration.as_secs();
let secs = total_secs % 60;
let mins = (total_secs / 60) % 60;
let hours = total_secs / 3600;
if hours > 0 {
format!("{hours}:{mins:02}:{secs:02}")
} else {
format!("{mins:02}:{secs:02}")
}
}
#[allow(
clippy::too_many_lines,
clippy::too_many_arguments,
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss
)]
fn render_screen_rgba(
grid: &bmux_terminal_grid::TerminalGrid,
rows: u16,
cols: u16,
max_rows: u16,
max_cols: u16,
cell_w: u16,
cell_h: u16,
palette: &ExportPalette,
mut glyph_renderer: Option<&mut GlyphRenderer>,
bitmap_cache: &mut BitmapGlyphCache,
) -> Vec<u8> {
let width = usize::from(max_cols.saturating_mul(cell_w));
let height = usize::from(max_rows.saturating_mul(cell_h));
let mut pixels = vec![0_u8; width.saturating_mul(height).saturating_mul(4)];
let cw = usize::from(cell_w);
let cell_height_px = usize::from(cell_h);
let display_rows = grid.display_rows(0, usize::from(rows));
for row in 0..rows {
for col in 0..cols {
let cell = display_rows
.get(usize::from(row))
.and_then(|grid_row| grid_row.cells().get(usize::from(col)));
let ((fg_r, fg_g, fg_b), (bg_r, bg_g, bg_b)) = cell.map_or_else(
|| {
(
resolve_grid_color(None, true, palette),
resolve_grid_color(None, false, palette),
)
},
|cell| resolved_grid_cell_colors(grid, cell, palette),
);
let x0 = usize::from(col).saturating_mul(cw);
let y0 = usize::from(row).saturating_mul(cell_height_px);
for py in 0..cell_height_px {
let y = y0 + py;
if y >= height {
continue;
}
let row_start = y.saturating_mul(width);
for px in 0..cw {
let x = x0 + px;
if x >= width {
continue;
}
let idx = (row_start + x).saturating_mul(4);
pixels[idx] = bg_r;
pixels[idx + 1] = bg_g;
pixels[idx + 2] = bg_b;
pixels[idx + 3] = 255;
}
}
let glyph_char = cell
.filter(|cell| !cell.is_wide_continuation())
.and_then(|cell| cell.text().chars().next())
.unwrap_or(' ');
if glyph_char == ' ' {
continue;
}
let drawn_with_font = glyph_renderer.as_deref_mut().is_some_and(|renderer| {
renderer.draw_cell(
&mut pixels,
width,
height,
x0,
y0,
glyph_char,
(fg_r, fg_g, fg_b),
(bg_r, bg_g, bg_b),
)
});
if !drawn_with_font {
draw_bitmap_glyph_rgba(
&mut pixels,
width,
height,
x0,
y0,
cw,
cell_height_px,
glyph_char,
(fg_r, fg_g, fg_b),
bitmap_cache,
);
}
}
}
pixels
}
struct ResvgFrameRenderer {
width: usize,
height: usize,
width_u32: u32,
height_u32: u32,
cell_width_px: usize,
cell_height_px: usize,
background_opacity: f32,
backdrop_rgb: (u8, u8, u8),
top_to_baseline: f32,
font_size: f32,
font_family_attr: String,
options_usvg: usvg::Options<'static>,
svg: String,
}
impl ResvgFrameRenderer {
fn new(
max_rows: u16,
max_cols: u16,
cell_w: u16,
cell_h: u16,
options: &RenderOptions,
) -> Result<Self> {
let width = usize::from(max_cols.saturating_mul(cell_w));
let height = usize::from(max_rows.saturating_mul(cell_h));
let width_u32 = u32::try_from(width).context("render width exceeds u32")?;
let height_u32 = u32::try_from(height).context("render height exceeds u32")?;
let cell_width_px = usize::from(cell_w);
let cell_height_px = usize::from(cell_h);
let preset = font_preset_for_options(options);
let mut families = if options.font_families.is_empty() {
bmux_fonts::default_families_for_preset(preset)
} else {
options.font_families.clone()
};
if families.is_empty() {
families.push("monospace".to_string());
}
let metrics = compute_font_grid_metrics(cell_w, cell_h, options);
let font_size = options
.font_size_px
.or_else(|| metrics.as_ref().map(|value| value.font_size_px))
.unwrap_or_else(|| (f32::from(cell_h) * 0.9).max(8.0));
let top_to_baseline = metrics
.as_ref()
.map_or_else(|| f32::from(cell_h) * 0.8, |value| value.top_to_baseline_px);
let font_family_attr = svg_font_family_list(&families);
let font_family = families
.first()
.cloned()
.unwrap_or_else(|| "monospace".to_string());
let mut options_usvg = usvg::Options {
font_family,
font_size,
..usvg::Options::default()
};
let fontdb = options_usvg.fontdb_mut();
let _ = bmux_fonts::register_preset_fonts(fontdb, preset);
fontdb.load_system_fonts();
for path in &options.font_paths {
let Ok(meta) = std::fs::metadata(path) else {
continue;
};
if meta.is_dir() {
fontdb.load_fonts_dir(path);
} else if meta.is_file() {
let _ = fontdb.load_font_file(path);
}
}
Ok(Self {
width,
height,
width_u32,
height_u32,
cell_width_px,
cell_height_px,
background_opacity: options.background_opacity,
backdrop_rgb: options.backdrop_rgb,
top_to_baseline,
font_size,
font_family_attr,
options_usvg,
svg: String::with_capacity(width.saturating_mul(height / 4).max(1024)),
})
}
#[allow(clippy::too_many_lines, clippy::cast_precision_loss)]
fn render(
&mut self,
grid: &bmux_terminal_grid::TerminalGrid,
rows: u16,
cols: u16,
palette: &ExportPalette,
) -> Result<Vec<u8>> {
self.svg.clear();
write!(
&mut self.svg,
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{}\" height=\"{}\" viewBox=\"0 0 {} {}\">",
self.width, self.height, self.width, self.height
)
.expect("svg write cannot fail");
write!(
&mut self.svg,
"<g font-family=\"{}\" font-size=\"{:.3}\" text-rendering=\"optimizeLegibility\" dominant-baseline=\"alphabetic\" font-kerning=\"none\" font-variant-ligatures=\"none\">",
xml_escape_attr(&self.font_family_attr),
self.font_size
)
.expect("svg write cannot fail");
let display_rows = grid.display_rows(0, usize::from(rows));
for row in 0..rows {
let mut row_runs = Vec::<TextRun>::new();
let mut current_run = None::<TextRun>;
for col in 0..cols {
let cell = display_rows
.get(usize::from(row))
.and_then(|grid_row| grid_row.cells().get(usize::from(col)));
let grid_style = cell
.map(|cell| grid.palette().get(cell.style()))
.unwrap_or_default();
let (mut fg_rgb, bg_rgb) = cell.map_or_else(
|| {
(
resolve_grid_color(None, true, palette),
resolve_grid_color(None, false, palette),
)
},
|cell| resolved_grid_cell_colors(grid, cell, palette),
);
if grid_style.dim {
fg_rgb = dim_rgb(fg_rgb);
}
let bg_rgb =
composite_with_backdrop(bg_rgb, self.background_opacity, self.backdrop_rgb);
let x0 = usize::from(col).saturating_mul(self.cell_width_px);
let y0 = usize::from(row).saturating_mul(self.cell_height_px);
write!(
&mut self.svg,
"<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"rgb({},{},{})\"/>",
x0, y0, self.cell_width_px, self.cell_height_px, bg_rgb.0, bg_rgb.1, bg_rgb.2
)
.expect("svg write cannot fail");
let cell_text = cell
.filter(|cell| !cell.is_wide_continuation() && !cell.text().is_empty())
.map_or(" ", bmux_terminal_grid::Cell::text);
let style = TextStyle {
fg_rgb,
bold: grid_style.bold,
italic: grid_style.italic,
underline: grid_style.underline,
};
match current_run.take() {
Some(mut run) if run.style == style => {
run.text.push_str(cell_text);
run.cell_count = run.cell_count.saturating_add(1);
current_run = Some(run);
}
Some(run) => {
row_runs.push(run);
current_run = Some(TextRun {
start_col: col,
text: cell_text.to_string(),
cell_count: 1,
style,
});
}
None => {
current_run = Some(TextRun {
start_col: col,
text: cell_text.to_string(),
cell_count: 1,
style,
});
}
}
}
if let Some(run) = current_run.take() {
row_runs.push(run);
}
for run in row_runs {
let x0 = usize::from(run.start_col).saturating_mul(self.cell_width_px);
let y0 = usize::from(row).saturating_mul(self.cell_height_px);
let text_y = y0 as f32 + self.top_to_baseline;
let style_attrs = svg_style_attrs(run.style);
let text_length = usize::from(run.cell_count).saturating_mul(self.cell_width_px);
write!(
&mut self.svg,
"<text x=\"{}\" y=\"{:.3}\" fill=\"rgb({},{},{})\" xml:space=\"preserve\" textLength=\"{}\" lengthAdjust=\"spacingAndGlyphs\"{}>{}</text>",
x0,
text_y,
run.style.fg_rgb.0,
run.style.fg_rgb.1,
run.style.fg_rgb.2,
text_length,
style_attrs,
xml_escape_text(&run.text)
)
.expect("svg write cannot fail");
}
}
self.svg.push_str("</g></svg>");
let tree = usvg::Tree::from_str(&self.svg, &self.options_usvg)
.context("failed to parse SVG frame")?;
let mut pixmap = tiny_skia::Pixmap::new(self.width_u32, self.height_u32)
.ok_or_else(|| anyhow::anyhow!("failed to allocate pixmap for SVG frame"))?;
resvg::render(&tree, tiny_skia::Transform::default(), &mut pixmap.as_mut());
Ok(pixmap.take())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct TextStyle {
fg_rgb: (u8, u8, u8),
bold: bool,
italic: bool,
underline: bool,
}
#[derive(Debug, Clone)]
struct TextRun {
start_col: u16,
text: String,
cell_count: u16,
style: TextStyle,
}
fn grid_cell_at(
grid: &bmux_terminal_grid::TerminalGrid,
row: usize,
col: usize,
) -> Option<bmux_terminal_grid::Cell> {
grid.display_rows(0, grid.height())
.get(row)
.and_then(|grid_row| grid_row.cells().get(col))
.filter(|cell| !cell.is_wide_continuation())
.cloned()
}
fn resolved_grid_cell_colors(
grid: &bmux_terminal_grid::TerminalGrid,
cell: &bmux_terminal_grid::Cell,
palette: &ExportPalette,
) -> ((u8, u8, u8), (u8, u8, u8)) {
let style = grid.palette().get(cell.style());
let mut fg = resolve_grid_color(style.fg, true, palette);
let mut bg = resolve_grid_color(style.bg, false, palette);
if style.inverse {
std::mem::swap(&mut fg, &mut bg);
}
(fg, bg)
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
fn dim_rgb(rgb: (u8, u8, u8)) -> (u8, u8, u8) {
(
(f32::from(rgb.0) * 0.72).round() as u8,
(f32::from(rgb.1) * 0.72).round() as u8,
(f32::from(rgb.2) * 0.72).round() as u8,
)
}
fn composite_with_backdrop(
rgb: (u8, u8, u8),
opacity: f32,
backdrop_rgb: (u8, u8, u8),
) -> (u8, u8, u8) {
if opacity >= 0.999 {
return rgb;
}
let alpha = opacity.clamp(0.0, 1.0);
(
blend_channel(rgb.0, backdrop_rgb.0, alpha),
blend_channel(rgb.1, backdrop_rgb.1, alpha),
blend_channel(rgb.2, backdrop_rgb.2, alpha),
)
}
fn svg_style_attrs(style: TextStyle) -> String {
let mut attrs = String::new();
if style.bold {
attrs.push_str(" font-weight=\"700\"");
}
if style.italic {
attrs.push_str(" font-style=\"italic\"");
}
if style.underline {
attrs.push_str(" text-decoration=\"underline\"");
}
attrs
}
struct FontGridMetrics {
font_size_px: f32,
top_to_baseline_px: f32,
}
struct PrimaryFontSource {
font: FontArc,
bytes: Vec<u8>,
face_index: u32,
}
fn compute_font_grid_metrics(
cell_w: u16,
cell_h: u16,
options: &RenderOptions,
) -> Option<FontGridMetrics> {
let primary = primary_font_source_for_metrics(options)?;
let unit_scale = PxScale { x: 1.0, y: 1.0 };
let unit_face_width = ascii_cell_width(&primary.font, unit_scale).max(0.0001);
let (unit_ascent, unit_descent, unit_line_gap) =
font_vertical_metrics_px(&primary.bytes, primary.face_index, 1.0).unwrap_or_else(|| {
let scaled = primary.font.as_scaled(unit_scale);
(scaled.ascent(), scaled.descent(), scaled.line_gap())
});
let unit_face_height = (unit_ascent - unit_descent + unit_line_gap).max(0.0001);
let target_w = f32::from(cell_w).max(1.0);
let target_h = f32::from(cell_h).max(1.0);
let font_size =
solve_font_size_for_target_cells(unit_face_width, unit_face_height, target_w, target_h)?;
let (ascent, descent, line_gap) =
font_vertical_metrics_px(&primary.bytes, primary.face_index, font_size).unwrap_or_else(
|| {
let scaled = primary.font.as_scaled(PxScale {
x: font_size,
y: font_size,
});
(scaled.ascent(), scaled.descent(), scaled.line_gap())
},
);
let face_height = (ascent - descent + line_gap).max(0.0001);
let half_line_gap = line_gap / 2.0;
let face_baseline = half_line_gap - descent;
let cell_height = target_h;
let cell_baseline = (face_baseline - (cell_height - face_height) / 2.0).round();
let top_to_baseline = (cell_height - cell_baseline).max(0.0);
Some(FontGridMetrics {
font_size_px: font_size,
top_to_baseline_px: top_to_baseline,
})
}
fn font_vertical_metrics_px(
font_data: &[u8],
face_index: u32,
size_px: f32,
) -> Option<(f32, f32, f32)> {
if !(size_px.is_finite() && size_px > 0.0) {
return None;
}
let face = ttf_parser::Face::parse(font_data, face_index).ok()?;
let units_per_em = f32::from(face.units_per_em()).max(1.0);
let px_per_unit = size_px / units_per_em;
let ascent = f32::from(face.ascender()) * px_per_unit;
let descent = f32::from(face.descender()) * px_per_unit;
let line_gap = f32::from(face.line_gap()) * px_per_unit;
Some((ascent, descent, line_gap))
}
fn ascii_cell_width(font: &FontArc, scale: PxScale) -> f32 {
let scaled = font.as_scaled(scale);
let mut max_advance = 0.0_f32;
for codepoint in 32_u32..127_u32 {
let Some(ch) = char::from_u32(codepoint) else {
continue;
};
let glyph_id = font.glyph_id(ch);
if glyph_id.0 == 0 {
continue;
}
max_advance = max_advance.max(scaled.h_advance(glyph_id));
}
if max_advance <= 0.0 {
scaled.h_advance(font.glyph_id('M')).max(0.0001)
} else {
max_advance
}
}
fn solve_font_size_for_target_cells(
unit_w: f32,
unit_h: f32,
target_w: f32,
target_h: f32,
) -> Option<f32> {
if !(unit_w.is_finite() && unit_h.is_finite() && unit_w > 0.0 && unit_h > 0.0) {
return None;
}
let h_lo = ((target_h - 0.5) / unit_h).max(0.001);
let h_hi = (target_h + 0.5) / unit_h;
if h_lo < h_hi {
let preferred = target_w / unit_w;
let size = preferred.clamp(h_lo, h_hi - f32::EPSILON);
return Some(size.max(0.001));
}
let mut candidates = Vec::new();
candidates.push((target_w / unit_w).max(0.001));
candidates.push((target_h / unit_h).max(0.001));
let w_lo = ((target_w - 0.5) / unit_w).max(0.001);
let w_hi = (target_w + 0.5) / unit_w;
candidates.push(w_lo);
candidates.push(w_hi.max(0.001));
candidates.push(h_lo);
candidates.push(h_hi.max(0.001));
let mut best = None::<(f32, f32)>;
for candidate in candidates {
if !candidate.is_finite() || candidate <= 0.0 {
continue;
}
let width_err = (unit_w * candidate).round() - target_w;
let height_err = (unit_h * candidate).round() - target_h;
let score = height_err.abs().mul_add(2.0, width_err.abs());
if best.is_none_or(|(_, best_score)| score < best_score) {
best = Some((candidate, score));
}
}
best.map(|(value, _)| value)
}
fn primary_font_source_for_metrics(options: &RenderOptions) -> Option<PrimaryFontSource> {
let preset = font_preset_for_options(options);
let mut db = fontdb::Database::new();
let _ = bmux_fonts::register_preset_fonts(&mut db, preset);
db.load_system_fonts();
for path in &options.font_paths {
let Ok(meta) = std::fs::metadata(path) else {
continue;
};
if meta.is_dir() {
db.load_fonts_dir(path);
} else if meta.is_file() {
let _ = db.load_font_file(path);
}
}
let mut families = Vec::<String>::new();
if !options.font_families.is_empty() {
families.extend(options.font_families.iter().cloned());
}
families.extend(bmux_fonts::default_families_for_preset(preset));
let mut seen = HashSet::<String>::new();
for family in families {
let normalized = family.trim().to_ascii_lowercase();
if normalized.is_empty() || !seen.insert(normalized) {
continue;
}
if let Some(source) = load_font_family_source_from_db(&db, &family) {
return Some(source);
}
}
for path in &options.font_paths {
let Ok(meta) = std::fs::metadata(path) else {
continue;
};
if !meta.is_file() {
continue;
}
if let Ok(bytes) = std::fs::read(path)
&& let Some(source) = primary_font_source_from_bytes(bytes, None)
{
return Some(source);
}
}
for embedded in bmux_fonts::bundled_fonts_for_preset(preset) {
if let Some(source) = primary_font_source_from_bytes(embedded.data.to_vec(), None) {
return Some(source);
}
}
None
}
fn primary_font_source_from_bytes(
bytes: Vec<u8>,
preferred_face_index: Option<u32>,
) -> Option<PrimaryFontSource> {
if let Some(face_index) = preferred_face_index
&& let Ok(font) = FontVec::try_from_vec_and_index(bytes.clone(), face_index)
{
return Some(PrimaryFontSource {
font: FontArc::new(font),
bytes,
face_index,
});
}
let face_count = ttf_parser::fonts_in_collection(&bytes).unwrap_or(1);
for face_index in 0..face_count {
let Ok(font) = FontVec::try_from_vec_and_index(bytes.clone(), face_index) else {
continue;
};
return Some(PrimaryFontSource {
font: FontArc::new(font),
bytes,
face_index,
});
}
None
}
fn load_font_family_source_from_db(
db: &fontdb::Database,
family: &str,
) -> Option<PrimaryFontSource> {
let query = fontdb::Query {
families: &[fontdb::Family::Name(family)],
weight: fontdb::Weight::NORMAL,
stretch: fontdb::Stretch::Normal,
style: fontdb::Style::Normal,
};
let face_id = db.query(&query)?;
db.with_face_data(face_id, |font_data, face_index| {
let bytes = font_data.to_vec();
let Ok(font) = FontVec::try_from_vec_and_index(bytes.clone(), face_index) else {
return None;
};
Some(PrimaryFontSource {
font: FontArc::new(font),
bytes,
face_index,
})
})?
}
fn svg_font_family_list(families: &[String]) -> String {
families
.iter()
.map(|value| format!("'{}'", value.replace('\'', "\\'")))
.collect::<Vec<_>>()
.join(", ")
}
fn xml_escape_attr(input: &str) -> String {
input
.chars()
.flat_map(|ch| match ch {
'&' => "&".chars().collect::<Vec<_>>(),
'<' => "<".chars().collect::<Vec<_>>(),
'>' => ">".chars().collect::<Vec<_>>(),
'\"' => """.chars().collect::<Vec<_>>(),
'\'' => "'".chars().collect::<Vec<_>>(),
_ => vec![ch],
})
.collect()
}
fn xml_escape_text(input: &str) -> String {
input
.chars()
.flat_map(|ch| match ch {
'&' => "&".chars().collect::<Vec<_>>(),
'<' => "<".chars().collect::<Vec<_>>(),
'>' => ">".chars().collect::<Vec<_>>(),
'"' => """.chars().collect::<Vec<_>>(),
'\'' => "'".chars().collect::<Vec<_>>(),
_ => vec![ch],
})
.collect()
}
#[allow(
clippy::too_many_lines,
clippy::too_many_arguments,
clippy::cast_possible_truncation,
clippy::cast_precision_loss
)]
fn draw_bitmap_glyph_rgba(
pixels: &mut [u8],
width: usize,
height: usize,
x0: usize,
y0: usize,
cell_w: usize,
cell_h: usize,
glyph_char: char,
fg_rgb: (u8, u8, u8),
bitmap_cache: &mut BitmapGlyphCache,
) {
let Some(mask) = bitmap_cache.mask_for(glyph_char) else {
return;
};
for py in 0..cell_h {
let y = y0 + py;
if y >= height {
continue;
}
let row_start = y.saturating_mul(width);
let mask_row = py.saturating_mul(cell_w);
for px in 0..cell_w {
let x = x0 + px;
if x >= width {
continue;
}
if mask[mask_row + px] == 1 {
let idx = (row_start + x).saturating_mul(4);
pixels[idx] = fg_rgb.0;
pixels[idx + 1] = fg_rgb.1;
pixels[idx + 2] = fg_rgb.2;
pixels[idx + 3] = 255;
}
}
}
}
struct BitmapGlyphCache {
cell_w: usize,
cell_h: usize,
masks: HashMap<char, Option<Vec<u8>>>,
}
impl BitmapGlyphCache {
fn new(cell_w: usize, cell_h: usize) -> Self {
Self {
cell_w,
cell_h,
masks: HashMap::new(),
}
}
fn mask_for(&mut self, glyph_char: char) -> Option<&[u8]> {
let entry = self
.masks
.entry(glyph_char)
.or_insert_with(|| build_bitmap_mask(glyph_char, self.cell_w, self.cell_h));
entry.as_deref()
}
}
fn build_bitmap_mask(glyph_char: char, cell_w: usize, cell_h: usize) -> Option<Vec<u8>> {
if cell_w == 0 || cell_h == 0 {
return None;
}
if let Some(mask) = block_element_mask(glyph_char, cell_w, cell_h) {
return Some(mask);
}
let glyph = resolve_bitmap_glyph(glyph_char)?;
let mut mask = vec![0_u8; cell_w.saturating_mul(cell_h)];
let mut any_set = false;
for py in 0..cell_h {
let glyph_row = ((py.saturating_mul(8)) / cell_h).min(7);
let bits = glyph[glyph_row];
let row_start = py.saturating_mul(cell_w);
for px in 0..cell_w {
let glyph_col = ((px.saturating_mul(8)) / cell_w).min(7);
if ((bits >> glyph_col) & 1) == 1 {
mask[row_start + px] = 1;
any_set = true;
}
}
}
any_set.then_some(mask)
}
fn resolve_bitmap_glyph(glyph_char: char) -> Option<[u8; 8]> {
font8x8::BASIC_FONTS
.get(glyph_char)
.or_else(|| font8x8::LATIN_FONTS.get(glyph_char))
.or_else(|| font8x8::BOX_FONTS.get(glyph_char))
.or_else(|| font8x8::BLOCK_FONTS.get(glyph_char))
.or_else(|| font8x8::GREEK_FONTS.get(glyph_char))
.or_else(|| font8x8::MISC_FONTS.get(glyph_char))
.or_else(|| font8x8::BASIC_FONTS.get('?'))
}
fn block_element_mask(glyph_char: char, cell_w: usize, cell_h: usize) -> Option<Vec<u8>> {
let mut mask = vec![0_u8; cell_w.saturating_mul(cell_h)];
match glyph_char {
'█' => mask.fill(1),
'▀' => {
let cutoff = cell_h.div_ceil(2);
for y in 0..cutoff {
let row = y.saturating_mul(cell_w);
for x in 0..cell_w {
mask[row + x] = 1;
}
}
}
'▄' => {
let start = cell_h / 2;
for y in start..cell_h {
let row = y.saturating_mul(cell_w);
for x in 0..cell_w {
mask[row + x] = 1;
}
}
}
'▌' => {
let cutoff = cell_w.div_ceil(2);
for y in 0..cell_h {
let row = y.saturating_mul(cell_w);
for x in 0..cutoff {
mask[row + x] = 1;
}
}
}
'▐' => {
let start = cell_w / 2;
for y in 0..cell_h {
let row = y.saturating_mul(cell_w);
for x in start..cell_w {
mask[row + x] = 1;
}
}
}
'░' => fill_shade_mask(&mut mask, cell_w, 1),
'▒' => fill_shade_mask(&mut mask, cell_w, 2),
'▓' => fill_shade_mask(&mut mask, cell_w, 3),
_ => return None,
}
Some(mask)
}
fn fill_shade_mask(mask: &mut [u8], cell_w: usize, threshold: usize) {
let threshold = threshold.min(4);
for (idx, value) in mask.iter_mut().enumerate() {
let y = idx / cell_w;
let x = idx % cell_w;
let matrix_value = (x & 1) + ((y & 1) << 1);
if matrix_value < threshold {
*value = 1;
}
}
}
struct RenderOptions {
mode: RecordingRenderMode,
font_families: Vec<String>,
font_paths: Vec<String>,
font_size_px: Option<f32>,
line_height_mult: f32,
background_opacity: f32,
backdrop_rgb: (u8, u8, u8),
}
fn build_render_options(
terminal_profile: Option<&terminal_profile::DetectedTerminalProfile>,
renderer: RecordingRenderMode,
font_family: Option<&str>,
font_size: Option<f32>,
line_height: Option<f32>,
font_path: &[String],
) -> Result<RenderOptions> {
if font_size.is_some_and(|value| value <= 0.0) {
anyhow::bail!("--font-size must be greater than zero")
}
if line_height.is_some_and(|value| value <= 0.0) {
anyhow::bail!("--line-height must be greater than zero")
}
let font_families = font_family
.map(parse_csv_values)
.or_else(|| terminal_profile.map(|profile| profile.font_families.clone()))
.unwrap_or_default();
let font_paths = if font_path.is_empty() {
Vec::new()
} else {
font_path.to_vec()
};
Ok(RenderOptions {
mode: renderer,
font_families,
font_paths,
font_size_px: font_size
.or_else(|| terminal_profile.and_then(|profile| profile.font_size_px.map(f32::from))),
line_height_mult: line_height.unwrap_or(1.0),
background_opacity: terminal_profile
.and_then(|profile| profile.background_opacity_permille)
.map_or(1.0, |permille| {
(f32::from(permille) / 1000.0).clamp(0.0, 1.0)
}),
backdrop_rgb: (0, 0, 0),
})
}
fn parse_csv_values(raw: &str) -> Vec<String> {
raw.split(',')
.map(str::trim)
.filter(|value| !value.is_empty())
.map(std::string::ToString::to_string)
.collect::<Vec<_>>()
}
struct GlyphRenderer {
fonts: Vec<FontArc>,
scale: PxScale,
baseline_offset: f32,
glyph_font_index: HashMap<char, Option<usize>>,
}
impl GlyphRenderer {
fn new(cell_w: u16, cell_h: u16, options: &RenderOptions) -> Option<Self> {
let fonts = load_monospace_fonts(options);
let font = fonts.first()?;
let base_font_size = options
.font_size_px
.unwrap_or_else(|| f32::from(cell_h).max(8.0));
let base_scale = PxScale {
x: base_font_size,
y: base_font_size,
};
let scaled_base = font.as_scaled(base_scale);
let measured_advance = scaled_base.h_advance(font.glyph_id('M')).max(0.01);
let target_advance = (f32::from(cell_w) * 0.92).max(1.0);
let x_scale = base_scale.x * (target_advance / measured_advance);
let scale = PxScale {
x: x_scale,
y: base_scale.y,
};
let scaled = font.as_scaled(scale);
let text_height = (scaled.ascent() - scaled.descent()).max(1.0);
let line_height = (text_height * options.line_height_mult.max(1.0)).max(text_height);
let baseline_offset = ((f32::from(cell_h) - line_height) / 2.0).max(0.0) + scaled.ascent();
Some(Self {
fonts,
scale,
baseline_offset,
glyph_font_index: HashMap::new(),
})
}
fn resolve_font_index(&mut self, glyph_char: char) -> Option<usize> {
if let Some(cached) = self.glyph_font_index.get(&glyph_char) {
return *cached;
}
let resolved = self
.fonts
.iter()
.enumerate()
.find_map(|(index, font)| (font.glyph_id(glyph_char).0 != 0).then_some(index));
self.glyph_font_index.insert(glyph_char, resolved);
resolved
}
#[allow(clippy::too_many_arguments, clippy::cast_precision_loss)]
fn draw_cell(
&mut self,
rgba: &mut [u8],
width: usize,
height: usize,
x0: usize,
y0: usize,
glyph_char: char,
fg_rgb: (u8, u8, u8),
bg_rgb: (u8, u8, u8),
) -> bool {
if glyph_char == ' ' {
return false;
}
let Some(font_index) = self.resolve_font_index(glyph_char) else {
return false;
};
let font = &self.fonts[font_index];
let glyph = font.glyph_id(glyph_char).with_scale_and_position(
self.scale,
point(x0 as f32, y0 as f32 + self.baseline_offset),
);
let Some(outlined) = font.outline_glyph(glyph) else {
return false;
};
outlined.draw(|gx, gy, coverage| {
if coverage <= 0.0 {
return;
}
let x = x0.saturating_add(gx as usize);
let y = y0.saturating_add(gy as usize);
if x >= width || y >= height {
return;
}
let alpha = coverage;
if alpha <= 0.0 {
return;
}
let idx = (y.saturating_mul(width) + x).saturating_mul(4);
rgba[idx] = blend_channel(fg_rgb.0, bg_rgb.0, alpha);
rgba[idx + 1] = blend_channel(fg_rgb.1, bg_rgb.1, alpha);
rgba[idx + 2] = blend_channel(fg_rgb.2, bg_rgb.2, alpha);
rgba[idx + 3] = 255;
});
true
}
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
fn blend_channel(fg: u8, bg: u8, alpha: f32) -> u8 {
f32::from(fg)
.mul_add(alpha, f32::from(bg) * (1.0 - alpha))
.round() as u8
}
fn load_monospace_fonts(options: &RenderOptions) -> Vec<FontArc> {
let preset = font_preset_for_options(options);
let mut fonts = Vec::<FontArc>::new();
for path in &options.font_paths {
let Ok(meta) = std::fs::metadata(path) else {
continue;
};
if !meta.is_file() {
continue;
}
let Ok(bytes) = std::fs::read(path) else {
continue;
};
if let Ok(font) = FontVec::try_from_vec_and_index(bytes, 0) {
fonts.push(FontArc::new(font));
}
}
fonts.extend(bmux_fonts::load_preset_fonts_for_ab_glyph(preset));
let mut db = fontdb::Database::new();
let _ = bmux_fonts::register_preset_fonts(&mut db, preset);
db.load_system_fonts();
for path in &options.font_paths {
let Ok(meta) = std::fs::metadata(path) else {
continue;
};
if meta.is_dir() {
db.load_fonts_dir(path);
} else if meta.is_file() {
let _ = db.load_font_file(path);
}
}
let mut families = Vec::<String>::new();
if !options.font_families.is_empty() {
families.extend(options.font_families.iter().cloned());
}
families.extend(bmux_fonts::default_families_for_preset(preset));
let mut seen = HashSet::<String>::new();
for family in families {
let normalized = family.trim().to_ascii_lowercase();
if normalized.is_empty() || !seen.insert(normalized) {
continue;
}
if let Some(font) = load_font_family_from_db(&db, &family) {
fonts.push(font);
}
}
fonts
}
fn load_font_family_from_db(db: &fontdb::Database, family: &str) -> Option<FontArc> {
let query = fontdb::Query {
families: &[fontdb::Family::Name(family)],
weight: fontdb::Weight::NORMAL,
stretch: fontdb::Stretch::Normal,
style: fontdb::Style::Normal,
};
let face_id = db.query(&query)?;
db.with_face_data(face_id, |font_data, face_index| {
let Ok(font) = FontVec::try_from_vec_and_index(font_data.to_vec(), face_index) else {
return None;
};
Some(FontArc::new(font))
})?
}
const fn font_preset_for_options(_options: &RenderOptions) -> FontPreset {
FontPreset::GhosttyNerd
}
#[derive(Debug, Clone)]
struct ExportPalette {
colors: [(u8, u8, u8); 256],
default_fg: (u8, u8, u8),
default_bg: (u8, u8, u8),
}
type PaletteRgb = (u8, u8, u8);
type PaletteOverride = (u8, PaletteRgb);
impl ExportPalette {
fn xterm() -> Self {
let colors = xterm_256_palette();
Self {
colors,
default_fg: colors[15],
default_bg: colors[0],
}
}
}
#[derive(Debug, Clone, Copy)]
enum ResolvedPaletteSource {
Recording,
Terminal,
Xterm,
}
fn resolve_export_palette(
source: RecordingPaletteSource,
recording_profile: Option<&terminal_profile::DetectedTerminalProfile>,
host_profile: Option<&terminal_profile::DetectedTerminalProfile>,
palette_foreground: Option<&str>,
palette_background: Option<&str>,
palette_color: &[String],
) -> Result<ExportPalette> {
let mut palette = ExportPalette::xterm();
let resolved_source = match source {
RecordingPaletteSource::Auto => {
if recording_profile.is_some_and(profile_has_palette_data) {
ResolvedPaletteSource::Recording
} else if host_profile.is_some_and(profile_has_palette_data) {
ResolvedPaletteSource::Terminal
} else {
ResolvedPaletteSource::Xterm
}
}
RecordingPaletteSource::Recording => {
if recording_profile.is_some_and(profile_has_palette_data) {
ResolvedPaletteSource::Recording
} else {
ResolvedPaletteSource::Xterm
}
}
RecordingPaletteSource::Terminal => {
if host_profile.is_some_and(profile_has_palette_data) {
ResolvedPaletteSource::Terminal
} else {
ResolvedPaletteSource::Xterm
}
}
RecordingPaletteSource::Xterm => ResolvedPaletteSource::Xterm,
};
match resolved_source {
ResolvedPaletteSource::Recording => {
if let Some(profile) = recording_profile {
apply_profile_palette(&mut palette, profile);
}
}
ResolvedPaletteSource::Terminal => {
if let Some(profile) = host_profile {
apply_profile_palette(&mut palette, profile);
}
}
ResolvedPaletteSource::Xterm => {}
}
if let Some(fg) = parse_palette_default_override(palette_foreground, "palette foreground")? {
palette.default_fg = fg;
}
if let Some(bg) = parse_palette_default_override(palette_background, "palette background")? {
palette.default_bg = bg;
}
let overrides = parse_palette_color_overrides(palette_color)?;
for (index, rgb) in overrides {
palette.colors[usize::from(index)] = rgb;
}
Ok(palette)
}
const fn profile_has_palette_data(profile: &terminal_profile::DetectedTerminalProfile) -> bool {
profile.palette_defaults.foreground.is_some()
|| profile.palette_defaults.background.is_some()
|| !profile.palette_defaults.colors.is_empty()
}
fn apply_profile_palette(
palette: &mut ExportPalette,
profile: &terminal_profile::DetectedTerminalProfile,
) {
if let Some(raw) = profile.palette_defaults.foreground.as_deref() {
if let Some(rgb) = parse_rgb_color(raw) {
palette.default_fg = rgb;
} else {
tracing::warn!(
"recording export: ignoring invalid terminal profile foreground color '{raw}'"
);
}
}
if let Some(raw) = profile.palette_defaults.background.as_deref() {
if let Some(rgb) = parse_rgb_color(raw) {
palette.default_bg = rgb;
} else {
tracing::warn!(
"recording export: ignoring invalid terminal profile background color '{raw}'"
);
}
}
for entry in &profile.palette_defaults.colors {
if let Some(rgb) = parse_rgb_color(&entry.color) {
palette.colors[usize::from(entry.index)] = rgb;
} else {
tracing::warn!(
"recording export: ignoring invalid terminal profile palette entry {}='{}'",
entry.index,
entry.color
);
}
}
}
fn parse_palette_default_override(
value: Option<&str>,
field_name: &str,
) -> Result<Option<(u8, u8, u8)>> {
let Some(raw) = value else {
return Ok(None);
};
let trimmed = raw.trim();
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("auto") {
return Ok(None);
}
let Some(rgb) = parse_rgb_color(trimmed) else {
anyhow::bail!("invalid {field_name} '{raw}'; expected auto or a color value")
};
Ok(Some(rgb))
}
fn parse_palette_color_overrides(values: &[String]) -> Result<Vec<PaletteOverride>> {
values
.iter()
.map(|value| parse_palette_color_override(value))
.collect()
}
fn parse_palette_color_override(value: &str) -> Result<PaletteOverride> {
let (index_raw, color_raw) = value.split_once('=').ok_or_else(|| {
anyhow::anyhow!("invalid palette override '{value}'; expected INDEX=COLOR")
})?;
let index = parse_palette_index(index_raw.trim())
.ok_or_else(|| anyhow::anyhow!("invalid palette index '{index_raw}'; expected 0..255"))?;
let color = color_raw.trim();
let rgb = parse_rgb_color(color).ok_or_else(|| {
anyhow::anyhow!("invalid palette color '{color_raw}'; expected a color value")
})?;
Ok((index, rgb))
}
fn parse_palette_index(value: &str) -> Option<u8> {
let trimmed = value.trim();
if trimmed.is_empty() {
return None;
}
for (prefix, radix) in [
("0x", 16),
("0X", 16),
("0b", 2),
("0B", 2),
("0o", 8),
("0O", 8),
] {
if let Some(digits) = trimmed.strip_prefix(prefix) {
if digits.is_empty() {
return None;
}
let parsed = u16::from_str_radix(digits, radix).ok()?;
return u8::try_from(parsed).ok();
}
}
let parsed = trimmed.parse::<u16>().ok()?;
u8::try_from(parsed).ok()
}
fn resolve_grid_color(
color: Option<bmux_terminal_grid::Color>,
foreground: bool,
palette: &ExportPalette,
) -> (u8, u8, u8) {
match color {
None => {
if foreground {
palette.default_fg
} else {
palette.default_bg
}
}
Some(bmux_terminal_grid::Color::Indexed(idx)) => palette.colors[usize::from(idx)],
Some(bmux_terminal_grid::Color::Rgb { r, g, b }) => (r, g, b),
}
}
fn parse_rgb_color(value: &str) -> Option<(u8, u8, u8)> {
parse_hex_rgb(value).or_else(|| parse_osc_rgb(value))
}
fn parse_hex_rgb(value: &str) -> Option<(u8, u8, u8)> {
let trimmed = value.trim();
let hex = trimmed.strip_prefix('#').unwrap_or(trimmed);
if hex.len() != 6 || !hex.chars().all(|ch| ch.is_ascii_hexdigit()) {
return None;
}
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
Some((r, g, b))
}
fn parse_osc_rgb(value: &str) -> Option<(u8, u8, u8)> {
let trimmed = value.trim();
let body = trimmed
.strip_prefix("rgb:")
.or_else(|| trimmed.strip_prefix("RGB:"))?;
let mut channels = body.split('/');
let r = channels.next().and_then(hex_component_to_u8)?;
let g = channels.next().and_then(hex_component_to_u8)?;
let b = channels.next().and_then(hex_component_to_u8)?;
if channels.next().is_some() {
return None;
}
Some((r, g, b))
}
fn hex_component_to_u8(value: &str) -> Option<u8> {
if !(1..=4).contains(&value.len()) || !value.chars().all(|ch| ch.is_ascii_hexdigit()) {
return None;
}
let parsed = u16::from_str_radix(value, 16).ok()?;
let bits = u32::try_from(value.len()).ok()?.saturating_mul(4);
let max = (1_u32 << bits).saturating_sub(1);
if max == 0 {
return None;
}
let scaled = (u32::from(parsed)
.saturating_mul(255)
.saturating_add(max / 2))
/ max;
u8::try_from(scaled).ok()
}
fn xterm_256_palette() -> [(u8, u8, u8); 256] {
let mut colors = [(0_u8, 0_u8, 0_u8); 256];
let base = [
(0x00, 0x00, 0x00),
(0x80, 0x00, 0x00),
(0x00, 0x80, 0x00),
(0x80, 0x80, 0x00),
(0x00, 0x00, 0x80),
(0x80, 0x00, 0x80),
(0x00, 0x80, 0x80),
(0xc0, 0xc0, 0xc0),
(0x80, 0x80, 0x80),
(0xff, 0x00, 0x00),
(0x00, 0xff, 0x00),
(0xff, 0xff, 0x00),
(0x00, 0x00, 0xff),
(0xff, 0x00, 0xff),
(0x00, 0xff, 0xff),
(0xff, 0xff, 0xff),
];
colors[..16].copy_from_slice(&base);
let steps = [0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff];
let mut index = 16_usize;
for r in steps {
for g in steps {
for b in steps {
colors[index] = (r, g, b);
index = index.saturating_add(1);
}
}
}
for i in 0..24_u8 {
let value = 8 + i * 10;
colors[index] = (value, value, value);
index = index.saturating_add(1);
}
colors
}
pub(super) fn parse_ignore_rules(ignore: Option<&str>) -> Vec<String> {
ignore
.map(|raw| {
raw.split(',')
.map(str::trim)
.filter(|entry| !entry.is_empty())
.map(str::to_ascii_lowercase)
.collect::<Vec<_>>()
})
.unwrap_or_default()
}
pub(super) fn apply_ignore_rules(
events: &[RecordingEventEnvelope],
ignore_rules: &[String],
) -> Vec<RecordingEventEnvelope> {
if ignore_rules.is_empty() {
return events.to_vec();
}
events
.iter()
.filter(|event| {
let name = recording_event_kind_name(event.kind);
!ignore_rules.contains(&name)
})
.cloned()
.collect()
}
pub(super) fn recording_event_kind_name(kind: RecordingEventKind) -> String {
match kind {
RecordingEventKind::PaneInputRaw => "pane_input_raw",
RecordingEventKind::PaneOutputRaw => "pane_output_raw",
RecordingEventKind::ProtocolReplyRaw => "protocol_reply_raw",
RecordingEventKind::PaneImage => "pane_image",
RecordingEventKind::ServerEvent => "server_event",
RecordingEventKind::RequestStart => "request_start",
RecordingEventKind::RequestDone => "request_done",
RecordingEventKind::RequestError => "request_error",
RecordingEventKind::Custom => "custom",
}
.to_string()
}
pub(super) fn load_recording_events(recording_id: &str) -> Result<Vec<RecordingEventEnvelope>> {
let recordings = list_recordings_from_disk()?;
let id = resolve_recording_id_prefix(recording_id, &recordings)?;
let recording_dir = recordings_root_dir().join(id.to_string());
let manifest_path = recording_dir.join("manifest.json");
let segments = if manifest_path.exists() {
let manifest_bytes = std::fs::read(&manifest_path)
.with_context(|| format!("failed reading manifest {}", manifest_path.display()))?;
let manifest: serde_json::Value = serde_json::from_slice(&manifest_bytes)?;
manifest["summary"]["segments"].as_array().map_or_else(
|| vec!["events_0.bin".to_string()],
|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect::<Vec<_>>()
},
)
} else {
vec!["events.bin".to_string()]
};
let mut all_frames = Vec::new();
for segment_name in &segments {
let segment_path = recording_dir.join(segment_name);
if !segment_path.exists() {
tracing::warn!(
"recording {id}: segment file {} not found, skipping",
segment_path.display()
);
continue;
}
let bytes = std::fs::read(&segment_path).with_context(|| {
format!(
"failed reading recording segment {}",
segment_path.display()
)
})?;
let result = read_frames(&bytes).map_err(|e| {
anyhow::anyhow!(
"failed parsing recording segment {}: {e}",
segment_path.display()
)
})?;
if result.bytes_remaining > 0 {
tracing::warn!(
"recording {id}: segment {} has {} trailing bytes (truncated?)",
segment_name,
result.bytes_remaining
);
}
all_frames.extend(result.frames);
}
Ok(all_frames)
}
pub(super) fn resolve_recording_id_prefix(
value: &str,
recordings: &[RecordingSummary],
) -> Result<Uuid> {
let query = value.trim();
if query.is_empty() {
anyhow::bail!("recording id/name cannot be empty");
}
if let Ok(id) = Uuid::parse_str(query)
&& recordings.iter().any(|recording| recording.id == id)
{
return Ok(id);
}
let exact_name_matches = recordings
.iter()
.filter_map(|recording| {
recording
.name
.as_deref()
.is_some_and(|name| name.eq_ignore_ascii_case(query))
.then_some(recording.id)
})
.collect::<Vec<_>>();
match exact_name_matches.as_slice() {
[id] => return Ok(*id),
[] => {}
_ => {
let mut options = exact_name_matches
.iter()
.filter_map(|id| recordings.iter().find(|recording| recording.id == *id))
.map(recording_selection_label)
.collect::<Vec<_>>();
options.sort();
anyhow::bail!(
"recording name '{query}' is ambiguous; matches: {}",
options.join(", ")
)
}
}
let normalized = query.to_ascii_lowercase();
let mut seen = HashSet::new();
let matches = recordings
.iter()
.filter_map(|recording| {
let id_match = recording.id.to_string().starts_with(&normalized);
let name_match = recording
.name
.as_ref()
.is_some_and(|name| name.to_ascii_lowercase().starts_with(&normalized));
(id_match || name_match)
.then_some(recording.id)
.filter(|id| seen.insert(*id))
})
.collect::<Vec<_>>();
match matches.as_slice() {
[id] => Ok(*id),
[] => anyhow::bail!("no recording matches id/name '{value}'"),
_ => {
let mut options = matches
.iter()
.filter_map(|id| recordings.iter().find(|recording| recording.id == *id))
.map(recording_selection_label)
.collect::<Vec<_>>();
options.sort();
anyhow::bail!(
"recording id/name '{value}' is ambiguous; matches: {}",
options.join(", ")
)
}
}
}
fn recording_selection_label(recording: &RecordingSummary) -> String {
recording.name.as_ref().map_or_else(
|| recording.id.to_string(),
|name| format!("{} (name={name})", recording.id),
)
}
pub(super) fn delete_recording_dir(recording_id: Uuid) -> Result<()> {
delete_recording_dir_at(&recordings_root_dir(), recording_id)
}
pub(super) fn delete_recording_dir_at(recordings_root: &Path, recording_id: Uuid) -> Result<()> {
let dir = recordings_root.join(recording_id.to_string());
let manifest = dir.join("manifest.json");
if !manifest.exists() {
anyhow::bail!("recording not found: {recording_id}");
}
std::fs::remove_dir_all(&dir)
.with_context(|| format!("failed removing recording directory {}", dir.display()))?;
Ok(())
}
pub(super) fn delete_all_recordings_from_disk() -> Result<usize> {
delete_all_recordings_from_dir(&recordings_root_dir())
}
pub(super) fn delete_all_recordings_from_dir(root: &Path) -> Result<usize> {
if !root.exists() {
return Ok(0);
}
let mut deleted_count = 0_usize;
for entry in std::fs::read_dir(root)
.with_context(|| format!("failed reading recordings dir {}", root.display()))?
{
let entry = entry?;
if !entry.file_type()?.is_dir() {
continue;
}
let manifest = entry.path().join("manifest.json");
if !manifest.exists() {
continue;
}
std::fs::remove_dir_all(entry.path()).with_context(|| {
format!(
"failed removing recording directory {}",
entry.path().display()
)
})?;
deleted_count = deleted_count.saturating_add(1);
}
Ok(deleted_count)
}
pub(super) fn confirm_delete_all_recordings(yes: bool) -> Result<bool> {
if yes {
return Ok(true);
}
if !io::stdin().is_terminal() {
anyhow::bail!("recording delete-all requires --yes in non-interactive mode");
}
println!("Delete all recordings? [y/N]");
let mut answer = String::new();
io::stdin()
.read_line(&mut answer)
.context("failed reading delete-all confirmation")?;
let trimmed = answer.trim().to_ascii_lowercase();
Ok(trimmed == "y" || trimmed == "yes")
}
#[derive(Debug, serde::Deserialize)]
struct RecordingManifest {
summary: RecordingSummary,
}
fn read_recording_manifest(manifest_path: &Path) -> Result<RecordingSummary> {
let bytes = std::fs::read(manifest_path).with_context(|| {
format!(
"failed reading recording manifest {}",
manifest_path.display()
)
})?;
let manifest: RecordingManifest = serde_json::from_slice(&bytes).with_context(|| {
format!(
"failed parsing recording manifest {}",
manifest_path.display()
)
})?;
Ok(manifest.summary)
}
pub(super) fn list_recordings_from_disk() -> Result<Vec<RecordingSummary>> {
list_recordings_from_dir(&recordings_root_dir())
}
pub(super) fn recordings_root_dir() -> PathBuf {
let (_config, root) = recording_config_and_root();
root
}
pub(super) fn list_recordings_from_dir(recordings_root: &Path) -> Result<Vec<RecordingSummary>> {
if !recordings_root.exists() {
return Ok(Vec::new());
}
let mut recordings = Vec::new();
for entry in std::fs::read_dir(recordings_root).with_context(|| {
format!(
"failed reading recordings dir {}",
recordings_root.display()
)
})? {
let entry = entry?;
if !entry.file_type()?.is_dir() {
continue;
}
let manifest_path = entry.path().join("manifest.json");
if !manifest_path.exists() {
continue;
}
if let Ok(summary) = read_recording_manifest(&manifest_path) {
recordings.push(summary);
}
}
recordings.sort_by_key(|recording| std::cmp::Reverse(recording.started_epoch_ms));
Ok(recordings)
}
pub(super) const fn offline_recording_status() -> RecordingStatus {
RecordingStatus {
active: None,
queue_len: 0,
}
}
#[cfg(any(
feature = "image-sixel",
feature = "image-kitty",
feature = "image-iterm2"
))]
fn decode_attach_image_to_rgba(
image: &bmux_attach_image_protocol::AttachPaneImage,
) -> Option<(u32, u32, Vec<u8>)> {
let raw_data =
bmux_ipc::compression::decompress_by_id(&image.raw_data, image.compression).ok()?;
match image.protocol {
#[cfg(feature = "image-sixel")]
bmux_attach_image_protocol::AttachImageProtocol::Sixel => {
let pb = bmux_image::codec::sixel::decode(&raw_data)?;
debug_assert!(
matches!(pb.format, bmux_image::PixelFormat::Rgba8),
"sixel decode should produce RGBA"
);
Some((pb.width, pb.height, pb.data))
}
#[cfg(feature = "image-kitty")]
bmux_attach_image_protocol::AttachImageProtocol::KittyGraphics => {
let w = image.pixel_width;
let h = image.pixel_height;
if w == 0 || h == 0 {
return None;
}
let expected_rgba = (w as usize) * (h as usize) * 4;
let expected_rgb = (w as usize) * (h as usize) * 3;
if raw_data.len() == expected_rgba {
Some((w, h, raw_data))
} else if raw_data.len() == expected_rgb {
let mut rgba = Vec::with_capacity(expected_rgba);
for chunk in raw_data.chunks_exact(3) {
rgba.extend_from_slice(chunk);
rgba.push(255);
}
Some((w, h, rgba))
} else {
decode_image_bytes_to_rgba(&raw_data)
}
}
#[cfg(feature = "image-iterm2")]
bmux_attach_image_protocol::AttachImageProtocol::ITerm2 => {
let (_params, file_bytes) = bmux_image::codec::iterm2::parse_body(&raw_data)?;
decode_image_bytes_to_rgba(&file_bytes)
}
#[allow(unreachable_patterns)]
_ => None,
}
}
#[cfg(any(feature = "image-kitty", feature = "image-iterm2"))]
fn decode_image_bytes_to_rgba(data: &[u8]) -> Option<(u32, u32, Vec<u8>)> {
let img = ::image::load_from_memory(data).ok()?;
let rgba = img.to_rgba8();
let w = rgba.width();
let h = rgba.height();
Some((w, h, rgba.into_raw()))
}
#[cfg(any(
feature = "image-sixel",
feature = "image-kitty",
feature = "image-iterm2"
))]
fn overlay_display_track_images(
frame: &mut [u8],
frame_width: u32,
frame_height: u32,
cell_w: u32,
cell_h: u32,
images: &[bmux_attach_image_protocol::AttachPaneImage],
) {
for image in images {
let Some((img_w, img_h, rgba)) = decode_attach_image_to_rgba(image) else {
continue;
};
let x_px = u32::from(image.position_col) * cell_w;
let y_px = u32::from(image.position_row) * cell_h;
blit_rgba(
frame,
frame_width,
frame_height,
&rgba,
img_w,
img_h,
x_px,
y_px,
);
}
}
#[cfg(any(
feature = "image-sixel",
feature = "image-kitty",
feature = "image-iterm2"
))]
#[allow(clippy::cast_possible_truncation, clippy::too_many_arguments)]
fn blit_rgba(
dst: &mut [u8],
dst_w: u32,
dst_h: u32,
src: &[u8],
src_w: u32,
src_h: u32,
x_off: u32,
y_off: u32,
) {
let dst_stride = (dst_w as usize) * 4;
let src_stride = (src_w as usize) * 4;
for sy in 0..src_h {
let dy = y_off + sy;
if dy >= dst_h {
break;
}
let dst_row_start = (dy as usize) * dst_stride;
let src_row_start = (sy as usize) * src_stride;
for sx in 0..src_w {
let dx = x_off + sx;
if dx >= dst_w {
break;
}
let si = src_row_start + (sx as usize) * 4;
let di = dst_row_start + (dx as usize) * 4;
if si + 3 >= src.len() || di + 3 >= dst.len() {
continue;
}
let sa = src[si + 3];
if sa == 0 {
continue; }
if sa == 255 {
dst[di] = src[si];
dst[di + 1] = src[si + 1];
dst[di + 2] = src[si + 2];
dst[di + 3] = 255;
} else {
let inv_a = 255 - u16::from(sa);
dst[di] =
((u16::from(src[si]) * u16::from(sa) + u16::from(dst[di]) * inv_a) / 255) as u8;
dst[di + 1] = ((u16::from(src[si + 1]) * u16::from(sa)
+ u16::from(dst[di + 1]) * inv_a)
/ 255) as u8;
dst[di + 2] = ((u16::from(src[si + 2]) * u16::from(sa)
+ u16::from(dst[di + 2]) * inv_a)
/ 255) as u8;
dst[di + 3] = (u16::from(sa) + u16::from(dst[di + 3]) * inv_a / 255).min(255) as u8;
}
}
}
}
const DISPLAY_CAPTURE_QUEUE_CAPACITY: usize = 4096;
const DISPLAY_CAPTURE_SEGMENT_MAX_AGE: Duration = Duration::from_secs(2);
const DISPLAY_CAPTURE_PRUNE_GRACE: Duration = Duration::from_secs(5);
const GIF_QUANTIZATION_SAMPLE_FACTOR: i32 = 10;
pub(super) struct DisplayCaptureWriter {
sender: mpsc::SyncSender<DisplayCaptureCommand>,
worker: Option<thread::JoinHandle<()>>,
dropped_events: u64,
}
enum DisplayCaptureCommand {
Event(DisplayTrackEvent),
CursorSnapshot(Option<crate::runtime::attach::state::AttachCursorState>),
Flush(mpsc::Sender<Result<()>>),
Close(mpsc::Sender<Result<()>>),
}
struct DisplayCaptureFileWriter {
recording_path: PathBuf,
client_id: Uuid,
rolling_window: Option<Duration>,
started_at: Instant,
writer: BufWriter<std::fs::File>,
segment_index: u64,
segment_start_ns: u64,
closed_segments: VecDeque<(PathBuf, u64)>,
stream_opened_baseline: DisplayTrackEvent,
latest_resize: Option<(u16, u16)>,
replay_grid: bmux_terminal_grid::TerminalGridStream,
cursor_replay_state: CursorReplayState,
#[cfg(any(
feature = "image-sixel",
feature = "image-kitty",
feature = "image-iterm2"
))]
last_image_count: usize,
}
impl DisplayCaptureWriter {
pub(super) fn open(
recording_id: Uuid,
recording_path: &Path,
client_id: Uuid,
rolling_window_secs: Option<u64>,
) -> Result<Self> {
let mut writer = DisplayCaptureFileWriter::open(
recording_id,
recording_path,
client_id,
rolling_window_secs.map(Duration::from_secs),
)?;
writer.record_stream_opened()?;
let (sender, receiver) = mpsc::sync_channel(DISPLAY_CAPTURE_QUEUE_CAPACITY);
let worker = thread::Builder::new()
.name(format!("bmux-display-capture-{recording_id}"))
.spawn(move || display_capture_writer_loop(&mut writer, receiver))
.context("failed spawning display capture writer thread")?;
Ok(Self {
sender,
worker: Some(worker),
dropped_events: 0,
})
}
pub(super) fn record_resize(&mut self, cols: u16, rows: u16) -> Result<()> {
self.enqueue(DisplayCaptureCommand::Event(DisplayTrackEvent::Resize {
cols,
rows,
}))
}
pub(super) fn record_frame_bytes(&mut self, data: &[u8]) -> Result<()> {
if data.is_empty() {
return Ok(());
}
self.enqueue(DisplayCaptureCommand::Event(
DisplayTrackEvent::FrameBytes {
data: data.to_vec(),
},
))
}
pub(super) fn record_activity(&mut self, kind: DisplayActivityKind) -> Result<()> {
self.enqueue(DisplayCaptureCommand::Event(DisplayTrackEvent::Activity {
kind,
}))
}
pub(super) fn record_cursor_snapshot(
&mut self,
cursor_state: Option<crate::runtime::attach::state::AttachCursorState>,
) -> Result<()> {
self.enqueue(DisplayCaptureCommand::CursorSnapshot(cursor_state))
}
pub(super) fn record_stream_closed(&mut self) -> Result<()> {
let (sender, receiver) = mpsc::channel();
self.sender
.send(DisplayCaptureCommand::Close(sender))
.context("display capture writer is closed")?;
let result = receiver
.recv()
.context("display capture writer closed without acknowledgement")?;
if let Some(worker) = self.worker.take()
&& worker.join().is_err()
{
return Err(anyhow::anyhow!("display capture writer thread panicked"));
}
result
}
#[cfg(any(
feature = "image-sixel",
feature = "image-kitty",
feature = "image-iterm2"
))]
pub(super) fn record_images(
&mut self,
images: &[bmux_attach_image_protocol::AttachPaneImage],
) -> Result<()> {
self.enqueue(DisplayCaptureCommand::Event(
DisplayTrackEvent::ImageUpdate {
images: images.to_vec(),
},
))
}
pub(super) fn flush(&self) -> Result<()> {
let (sender, receiver) = mpsc::channel();
self.sender
.send(DisplayCaptureCommand::Flush(sender))
.context("display capture writer is closed")?;
receiver
.recv()
.context("display capture writer closed without flushing")?
}
fn enqueue(&mut self, command: DisplayCaptureCommand) -> Result<()> {
match self.sender.try_send(command) {
Ok(()) => Ok(()),
Err(mpsc::TrySendError::Full(_)) => {
self.dropped_events = self.dropped_events.saturating_add(1);
if self.dropped_events == 1 || self.dropped_events.is_multiple_of(1024) {
tracing::warn!(
dropped_events = self.dropped_events,
"display capture queue is full; dropping recording display events"
);
}
Ok(())
}
Err(mpsc::TrySendError::Disconnected(_)) => {
Err(anyhow::anyhow!("display capture writer is closed"))
}
}
}
}
impl Drop for DisplayCaptureWriter {
fn drop(&mut self) {
if self.worker.is_some() {
let _ = self.record_stream_closed();
}
}
}
impl DisplayCaptureFileWriter {
fn open(
recording_id: Uuid,
recording_path: &Path,
client_id: Uuid,
rolling_window: Option<Duration>,
) -> Result<Self> {
std::fs::create_dir_all(recording_path).with_context(|| {
format!(
"failed creating recording path {}",
recording_path.display()
)
})?;
let display_track_path =
display_track_output_path(recording_path, client_id, 0, rolling_window);
let file = open_display_track_file(&display_track_path)?;
let stream_opened_baseline = capture_stream_opened_event(recording_id, client_id);
let latest_resize = current_terminal_size();
let (initial_cols, initial_rows) = latest_resize.unwrap_or((80, 24));
let replay_grid = bmux_terminal_grid::TerminalGridStream::new(
initial_cols.max(1),
initial_rows.max(1),
bmux_terminal_grid::GridLimits::default(),
)
.expect("display capture replay grid dimensions are valid");
Ok(Self {
recording_path: recording_path.to_path_buf(),
client_id,
rolling_window,
started_at: Instant::now(),
writer: BufWriter::new(file),
segment_index: 0,
segment_start_ns: 0,
closed_segments: VecDeque::new(),
stream_opened_baseline,
latest_resize,
replay_grid,
cursor_replay_state: CursorReplayState::default(),
#[cfg(any(
feature = "image-sixel",
feature = "image-kitty",
feature = "image-iterm2"
))]
last_image_count: 0,
})
}
fn record_stream_opened(&mut self) -> Result<()> {
self.record_segment_baseline()
}
fn record_segment_baseline(&mut self) -> Result<()> {
self.record(self.stream_opened_baseline.clone())?;
if let Some((cols, rows)) = self.latest_resize {
self.record(DisplayTrackEvent::Resize { cols, rows })?;
}
let repaint = bmux_terminal_grid::full_screen_repaint_bytes(self.replay_grid.grid());
if !repaint.is_empty() {
self.record(DisplayTrackEvent::FrameBytes { data: repaint })?;
}
Ok(())
}
fn record_cursor_snapshot(
&mut self,
cursor_state: Option<crate::runtime::attach::state::AttachCursorState>,
) -> Result<()> {
let (x, y, visible) =
cursor_state.map_or((0, 0, false), |state| (state.x, state.y, state.visible));
self.record(DisplayTrackEvent::CursorSnapshot {
x,
y,
visible,
shape: display_cursor_shape_from_visual(self.cursor_replay_state.shape),
blink_enabled: self.cursor_replay_state.blink_enabled,
})
}
fn record(&mut self, event: DisplayTrackEvent) -> Result<()> {
if let DisplayTrackEvent::Resize { cols, rows } = &event
&& *cols > 0
&& *rows > 0
{
self.latest_resize = Some((*cols, *rows));
let _ = self.replay_grid.resize(*cols, *rows);
}
if let DisplayTrackEvent::FrameBytes { data } = &event {
update_cursor_replay_state(&mut self.cursor_replay_state, data);
self.replay_grid.process(data);
}
#[cfg(any(
feature = "image-sixel",
feature = "image-kitty",
feature = "image-iterm2"
))]
if let DisplayTrackEvent::ImageUpdate { images } = &event {
let count = images.len();
if count == 0 && self.last_image_count == 0 {
return Ok(());
}
self.last_image_count = count;
}
let mono_ns = u64::try_from(
self.started_at
.elapsed()
.as_nanos()
.min(u128::from(u64::MAX)),
)
.unwrap_or(u64::MAX);
let envelope = DisplayTrackEnvelope { mono_ns, event };
write_frame(&mut self.writer, &envelope)
.map_err(|e| anyhow::anyhow!("display track write_frame failed: {e}"))?;
self.maybe_rotate(mono_ns)?;
Ok(())
}
fn flush(&mut self) -> Result<()> {
self.writer
.flush()
.context("failed flushing display capture writer")
}
fn maybe_rotate(&mut self, mono_ns: u64) -> Result<()> {
if self.rolling_window.is_none() {
return Ok(());
}
let segment_age = Duration::from_nanos(mono_ns.saturating_sub(self.segment_start_ns));
if segment_age < DISPLAY_CAPTURE_SEGMENT_MAX_AGE {
return Ok(());
}
self.rotate(mono_ns)
}
fn rotate(&mut self, end_ns: u64) -> Result<()> {
self.flush()?;
let old_path =
display_track_segment_path(&self.recording_path, self.client_id, self.segment_index);
self.closed_segments.push_back((old_path, end_ns));
self.segment_index = self.segment_index.saturating_add(1);
self.segment_start_ns = end_ns;
let new_path =
display_track_segment_path(&self.recording_path, self.client_id, self.segment_index);
self.writer = BufWriter::new(open_display_track_file(&new_path)?);
self.record_segment_baseline()?;
self.prune_closed_segments(end_ns)
}
fn prune_closed_segments(&mut self, now_ns: u64) -> Result<()> {
let Some(window) = self.rolling_window else {
return Ok(());
};
let retention = window.saturating_add(DISPLAY_CAPTURE_PRUNE_GRACE);
let cutoff_ns = now_ns.saturating_sub(duration_nanos_u64(retention));
while self
.closed_segments
.front()
.is_some_and(|(_, end_ns)| *end_ns < cutoff_ns)
{
let Some((path, _)) = self.closed_segments.pop_front() else {
break;
};
if let Err(error) = std::fs::remove_file(&path)
&& error.kind() != std::io::ErrorKind::NotFound
{
return Err(error).with_context(|| {
format!(
"failed removing old display track segment {}",
path.display()
)
});
}
}
Ok(())
}
}
fn display_capture_writer_loop(
writer: &mut DisplayCaptureFileWriter,
receiver: mpsc::Receiver<DisplayCaptureCommand>,
) {
for command in receiver {
match command {
DisplayCaptureCommand::Event(event) => {
if let Err(error) = writer.record(event) {
tracing::warn!(error = %error, "display capture write failed");
}
}
DisplayCaptureCommand::CursorSnapshot(cursor_state) => {
if let Err(error) = writer.record_cursor_snapshot(cursor_state) {
tracing::warn!(error = %error, "display capture cursor snapshot failed");
}
}
DisplayCaptureCommand::Flush(ack) => {
let _ = ack.send(writer.flush());
}
DisplayCaptureCommand::Close(ack) => {
let result = writer
.record(DisplayTrackEvent::StreamClosed)
.and_then(|()| writer.flush());
let _ = ack.send(result);
break;
}
}
}
}
fn capture_stream_opened_event(recording_id: Uuid, client_id: Uuid) -> DisplayTrackEvent {
let (cell_width_px, cell_height_px, window_width_px, window_height_px) =
capture_stream_open_metrics();
let terminal_profile = terminal_profile::detect_render_profile();
let terminal_profile_bytes = terminal_profile
.as_ref()
.and_then(|p| bmux_ipc::encode(p).ok());
DisplayTrackEvent::StreamOpened {
client_id,
recording_id,
cell_width_px,
cell_height_px,
window_width_px,
window_height_px,
terminal_profile: terminal_profile_bytes,
}
}
fn current_terminal_size() -> Option<(u16, u16)> {
let Ok((cols, rows)) = terminal::size() else {
return None;
};
(cols > 0 && rows > 0).then_some((cols, rows))
}
fn open_display_track_file(path: &Path) -> Result<std::fs::File> {
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
.with_context(|| format!("failed opening display track {}", path.display()))
}
fn duration_nanos_u64(duration: Duration) -> u64 {
u64::try_from(duration.as_nanos().min(u128::from(u64::MAX))).unwrap_or(u64::MAX)
}
fn display_track_path(recording_path: &Path, client_id: Uuid) -> PathBuf {
recording_path.join(format!("display-{client_id}.bin"))
}
fn display_track_segment_path(recording_path: &Path, client_id: Uuid, index: u64) -> PathBuf {
recording_path.join(format!("display-{client_id}.part{index}.bin"))
}
fn display_track_output_path(
recording_path: &Path,
client_id: Uuid,
index: u64,
rolling_window: Option<Duration>,
) -> PathBuf {
if rolling_window.is_some() {
display_track_segment_path(recording_path, client_id, index)
} else {
display_track_path(recording_path, client_id)
}
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
fn stream_opened(
cell_width_px: Option<u16>,
cell_height_px: Option<u16>,
window_width_px: Option<u16>,
window_height_px: Option<u16>,
) -> DisplayTrackEnvelope {
DisplayTrackEnvelope {
mono_ns: 1,
event: DisplayTrackEvent::StreamOpened {
client_id: Uuid::nil(),
recording_id: Uuid::nil(),
cell_width_px,
cell_height_px,
window_width_px,
window_height_px,
terminal_profile: None,
},
}
}
fn frame_bytes(mono_ns: u64) -> DisplayTrackEnvelope {
DisplayTrackEnvelope {
mono_ns,
event: DisplayTrackEvent::FrameBytes { data: vec![b'x'] },
}
}
fn cursor_snapshot(mono_ns: u64, x: u16, y: u16) -> DisplayTrackEnvelope {
DisplayTrackEnvelope {
mono_ns,
event: DisplayTrackEvent::CursorSnapshot {
x,
y,
visible: true,
shape: DisplayCursorShape::Block,
blink_enabled: true,
},
}
}
#[test]
fn display_track_envelope_round_trips_through_codec() {
let envelope = DisplayTrackEnvelope {
mono_ns: 1,
event: DisplayTrackEvent::StreamOpened {
client_id: Uuid::nil(),
recording_id: Uuid::nil(),
cell_width_px: Some(8),
cell_height_px: Some(16),
window_width_px: Some(640),
window_height_px: Some(480),
terminal_profile: None,
},
};
let mut buf = Vec::new();
write_frame(&mut buf, &envelope).expect("write should succeed");
let result = read_frames::<DisplayTrackEnvelope>(&buf).expect("read should succeed");
assert_eq!(result.bytes_remaining, 0);
assert_eq!(result.frames.len(), 1);
assert_eq!(result.frames[0].mono_ns, 1);
match &result.frames[0].event {
DisplayTrackEvent::StreamOpened {
cell_width_px,
cell_height_px,
..
} => {
assert_eq!(*cell_width_px, Some(8));
assert_eq!(*cell_height_px, Some(16));
}
_ => panic!("expected stream_opened event"),
}
}
#[test]
fn full_screen_repaint_bytes_reconstructs_visible_text() {
let mut grid = test_grid(80, 24);
grid.process(b"hello\r\nworld");
let repaint = bmux_terminal_grid::full_screen_repaint_bytes(grid.grid());
let mut replay = test_grid(80, 24);
replay.process(&repaint);
let contents = bmux_terminal_grid::visible_text(replay.grid(), 0, replay.grid().height());
assert!(contents.contains("hello"));
assert!(contents.contains("world"));
}
#[test]
fn display_capture_rotation_writes_full_repaint_baseline() {
let root = temp_dir();
let recording_id = Uuid::new_v4();
let client_id = Uuid::new_v4();
let mut writer = DisplayCaptureFileWriter::open(
recording_id,
&root,
client_id,
Some(Duration::from_mins(5)),
)
.expect("writer should open");
writer
.record(DisplayTrackEvent::Resize { cols: 80, rows: 24 })
.expect("resize should record");
writer
.record(DisplayTrackEvent::FrameBytes {
data: b"hello before rotation".to_vec(),
})
.expect("frame should record");
writer
.rotate(2_000_000_000)
.expect("rotation should write baseline");
writer.flush().expect("writer should flush");
let segment = display_track_segment_path(&root, client_id, 1);
let bytes = std::fs::read(segment).expect("second segment should read");
let frames = read_frames::<DisplayTrackEnvelope>(&bytes)
.expect("segment should decode")
.frames;
let repaint = frames
.iter()
.find_map(|frame| match &frame.event {
DisplayTrackEvent::FrameBytes { data } => Some(data),
_ => None,
})
.expect("rotated segment should contain repaint frame");
let mut replay = test_grid(80, 24);
replay.process(repaint);
assert!(
bmux_terminal_grid::visible_text(replay.grid(), 0, replay.grid().height())
.contains("hello before rotation"),
"rotated segment baseline should carry prior visible content"
);
}
#[test]
fn gif_delay_clock_preserves_sixty_fps_duration() {
let mut clock = GifDelayClock::new(60);
let delays: Vec<u16> = (0..60).map(|_| clock.delay_for_frame_span(1)).collect();
let total: u16 = delays.iter().sum();
assert_eq!(total, 100);
assert!(delays.contains(&1));
assert!(delays.contains(&2));
}
#[test]
fn gif_delay_clock_preserves_skipped_frame_duration() {
let mut clock = GifDelayClock::new(60);
assert_eq!(clock.delay_for_frame_span(1), 1);
assert_eq!(clock.delay_for_frame_span(3), 5);
}
#[test]
fn resolve_export_cell_metrics_prefers_cli_then_recording() {
let events = vec![stream_opened(Some(7), Some(14), Some(700), Some(350))];
let resolved = resolve_export_cell_metrics(&events, Some((9, 18)), Some(10), None)
.expect("metrics should resolve");
assert_eq!(resolved.width, 10);
assert_eq!(resolved.height, 18);
}
#[test]
fn resolve_export_cell_metrics_can_infer_from_recorded_window_and_resize() {
let events = vec![
stream_opened(None, None, Some(1200), Some(600)),
DisplayTrackEnvelope {
mono_ns: 2,
event: DisplayTrackEvent::Resize {
cols: 120,
rows: 30,
},
},
];
let resolved =
resolve_export_cell_metrics(&events, None, None, None).expect("metrics should resolve");
assert_eq!(resolved.width, 10);
assert_eq!(resolved.height, 20);
}
#[test]
fn infer_export_terminal_bounds_prefers_resize_events() {
let events = vec![
stream_opened(Some(16), Some(35), Some(3440), Some(2150)),
DisplayTrackEnvelope {
mono_ns: 2,
event: DisplayTrackEvent::Resize {
cols: 120,
rows: 40,
},
},
cursor_snapshot(3, 213, 57),
];
assert_eq!(infer_export_terminal_bounds(&events).unwrap(), (120, 40));
}
#[test]
fn infer_export_terminal_bounds_falls_back_to_stream_metrics_without_resize() {
let events = vec![
stream_opened(Some(16), Some(35), Some(3440), Some(2150)),
frame_bytes(2),
];
assert_eq!(infer_export_terminal_bounds(&events).unwrap(), (215, 61));
}
#[test]
fn infer_export_terminal_bounds_expands_stream_bounds_with_cursor_extent() {
let events = vec![
stream_opened(Some(10), Some(25), Some(800), Some(600)),
cursor_snapshot(2, 100, 30),
];
assert_eq!(infer_export_terminal_bounds(&events).unwrap(), (101, 31));
}
#[test]
fn infer_export_terminal_bounds_errors_when_resize_and_stream_grid_missing() {
let events = vec![
stream_opened(None, None, Some(3440), Some(2150)),
cursor_snapshot(2, 189, 28),
];
let err = infer_export_terminal_bounds(&events).expect_err("missing bounds should fail");
assert!(
err.to_string().contains("cannot infer terminal bounds"),
"error should explain why export cannot proceed"
);
}
#[test]
#[allow(clippy::float_cmp)] fn build_render_options_uses_terminal_profile_defaults() {
let profile = terminal_profile::DetectedTerminalProfile {
terminal_id: "ghostty".to_string(),
font_families: vec!["JetBrains Mono".to_string()],
font_size_px: Some(15),
background_opacity_permille: Some(900),
cursor_defaults: terminal_profile::CursorDefaults::default(),
palette_defaults: terminal_profile::PaletteDefaults::default(),
source: "test".to_string(),
};
let options = build_render_options(
Some(&profile),
RecordingRenderMode::Bitmap,
None,
None,
None,
&[],
)
.expect("options should resolve");
assert_eq!(options.font_families, vec!["JetBrains Mono".to_string()]);
assert_eq!(options.font_size_px, Some(15.0));
assert_eq!(options.line_height_mult, 1.0);
assert_eq!(options.background_opacity, 0.9);
assert_eq!(options.backdrop_rgb, (0, 0, 0));
}
#[test]
fn recording_terminal_profile_reads_stream_opened_profile() {
let profile = terminal_profile::DetectedTerminalProfile {
terminal_id: "ghostty".to_string(),
font_families: vec!["Iosevka".to_string()],
font_size_px: Some(14),
background_opacity_permille: None,
cursor_defaults: terminal_profile::CursorDefaults::default(),
palette_defaults: terminal_profile::PaletteDefaults::default(),
source: "ghostty-config:/tmp/config".to_string(),
};
let events = vec![DisplayTrackEnvelope {
mono_ns: 1,
event: DisplayTrackEvent::StreamOpened {
client_id: Uuid::nil(),
recording_id: Uuid::nil(),
cell_width_px: Some(8),
cell_height_px: Some(16),
window_width_px: Some(800),
window_height_px: Some(600),
terminal_profile: Some(bmux_ipc::encode(&profile).unwrap()),
},
}];
let resolved = recording_terminal_profile(&events).expect("profile should be resolved");
assert_eq!(resolved, profile);
}
#[test]
fn bitmap_glyph_cache_reuses_computed_mask() {
let mut cache = BitmapGlyphCache::new(8, 16);
let first = cache.mask_for('A').expect("mask should exist").to_vec();
let second = cache.mask_for('A').expect("mask should exist").to_vec();
assert_eq!(first, second);
assert_eq!(cache.masks.len(), 1);
}
#[test]
fn estimate_export_progress_counts_events_and_emitted_frames() {
let events = vec![
stream_opened(Some(8), Some(16), Some(800), Some(600)),
frame_bytes(0),
frame_bytes(50_000_000),
frame_bytes(100_000_000),
frame_bytes(200_000_000),
];
let estimate = estimate_export_progress(&events, 1.0, 10, None, None);
assert_eq!(estimate.total_frame_events, 4);
assert_eq!(estimate.estimated_emitted_frames, 3);
}
#[test]
fn estimate_export_progress_respects_max_frames_limit() {
let events = vec![
stream_opened(Some(8), Some(16), Some(800), Some(600)),
frame_bytes(0),
frame_bytes(50_000_000),
frame_bytes(100_000_000),
frame_bytes(200_000_000),
];
let estimate = estimate_export_progress(&events, 1.0, 10, None, Some(2));
assert_eq!(estimate.total_frame_events, 3);
assert_eq!(estimate.estimated_emitted_frames, 2);
}
#[test]
fn estimate_export_progress_uses_timeline_frames_for_sparse_events() {
let events = vec![
stream_opened(Some(8), Some(16), Some(800), Some(600)),
frame_bytes(0),
frame_bytes(450_000_000),
];
let estimate = estimate_export_progress(&events, 1.0, 10, None, None);
assert_eq!(estimate.total_frame_events, 2);
assert_eq!(estimate.estimated_emitted_frames, 5);
}
#[test]
fn compute_cursor_visibility_blinks_on_timeline_clock() {
let options = CursorExportOptions {
mode: RecordingCursorMode::Auto,
shape: RecordingCursorShape::Auto,
blink: RecordingCursorBlinkMode::On,
profile: RecordingCursorProfile::Generic,
blink_period_ns: 500_000_000,
solid_after_input_ns: 0,
solid_after_output_ns: 0,
solid_after_cursor_ns: 0,
paint_mode: RecordingCursorPaintMode::Invert,
text_mode: RecordingCursorTextMode::SwapFgBg,
bar_width_pct: 16,
underline_height_pct: 12,
color_label: "auto".to_string(),
color_override: None,
};
let state = CursorReplayState::default();
let mut blink_anchor_ns = None;
let (on_a, blink_a, _) = compute_cursor_visibility(
&options,
state,
true,
true,
0,
None,
None,
None,
&mut blink_anchor_ns,
);
let (on_b, blink_b, _) = compute_cursor_visibility(
&options,
state,
true,
true,
510_000_000,
None,
None,
None,
&mut blink_anchor_ns,
);
let (on_c, blink_c, _) = compute_cursor_visibility(
&options,
state,
true,
true,
1_020_000_000,
None,
None,
None,
&mut blink_anchor_ns,
);
assert!(on_a && blink_a);
assert!(!on_b && !blink_b);
assert!(on_c && blink_c);
}
#[test]
fn compute_cursor_visibility_aligns_phase_to_first_visible_frame() {
let options = CursorExportOptions {
mode: RecordingCursorMode::Auto,
shape: RecordingCursorShape::Auto,
blink: RecordingCursorBlinkMode::On,
profile: RecordingCursorProfile::Generic,
blink_period_ns: 500_000_000,
solid_after_input_ns: 0,
solid_after_output_ns: 0,
solid_after_cursor_ns: 0,
paint_mode: RecordingCursorPaintMode::Invert,
text_mode: RecordingCursorTextMode::SwapFgBg,
bar_width_pct: 16,
underline_height_pct: 12,
color_label: "auto".to_string(),
color_override: None,
};
let state = CursorReplayState::default();
let mut blink_anchor_ns = None;
let _ = compute_cursor_visibility(
&options,
state,
false,
true,
700_000_000,
None,
None,
None,
&mut blink_anchor_ns,
);
let (on_a, blink_a, _) = compute_cursor_visibility(
&options,
state,
true,
true,
700_000_000,
None,
None,
None,
&mut blink_anchor_ns,
);
let (on_b, blink_b, _) = compute_cursor_visibility(
&options,
state,
true,
true,
1_210_000_000,
None,
None,
None,
&mut blink_anchor_ns,
);
assert!(on_a && blink_a);
assert!(!on_b && !blink_b);
}
#[test]
fn compute_cursor_visibility_stays_solid_while_recent_activity() {
let options = CursorExportOptions {
mode: RecordingCursorMode::Auto,
shape: RecordingCursorShape::Auto,
blink: RecordingCursorBlinkMode::On,
profile: RecordingCursorProfile::Ghostty,
blink_period_ns: 500_000_000,
solid_after_input_ns: 500_000_000,
solid_after_output_ns: 500_000_000,
solid_after_cursor_ns: 500_000_000,
paint_mode: RecordingCursorPaintMode::Invert,
text_mode: RecordingCursorTextMode::SwapFgBg,
bar_width_pct: 16,
underline_height_pct: 12,
color_label: "auto".to_string(),
color_override: None,
};
let state = CursorReplayState::default();
let mut blink_anchor_ns = None;
let (on_a, blink_a, _) = compute_cursor_visibility(
&options,
state,
true,
true,
300_000_000,
Some(250_000_000),
None,
None,
&mut blink_anchor_ns,
);
let (on_b, blink_b, _) = compute_cursor_visibility(
&options,
state,
true,
true,
900_000_000,
Some(250_000_000),
None,
None,
&mut blink_anchor_ns,
);
assert!(on_a && blink_a);
assert!(!on_b && !blink_b);
}
#[test]
fn format_duration_compact_uses_mm_ss_and_hh_mm_ss() {
assert_eq!(
format_duration_compact(std::time::Duration::from_secs(65)),
"01:05"
);
assert_eq!(
format_duration_compact(std::time::Duration::from_secs(3_665)),
"1:01:05"
);
}
#[test]
fn parse_palette_color_override_supports_prefixed_index_radix() {
assert_eq!(
parse_palette_color_override("0x0a=#010203").expect("hex index should parse"),
(10, (1, 2, 3))
);
assert_eq!(
parse_palette_color_override("0b1010=#010203").expect("binary index should parse"),
(10, (1, 2, 3))
);
assert_eq!(
parse_palette_color_override("0o12=#010203").expect("octal index should parse"),
(10, (1, 2, 3))
);
}
#[test]
fn parse_rgb_color_accepts_osc_rgb_format() {
assert_eq!(parse_rgb_color("rgb:ff/00/7f"), Some((255, 0, 127)));
assert_eq!(parse_rgb_color("RGB:ffff/0000/7fff"), Some((255, 0, 127)));
}
#[test]
fn resolve_grid_color_preserves_truecolor_rgb() {
let palette = ExportPalette::xterm();
assert_eq!(
resolve_grid_color(
Some(bmux_terminal_grid::Color::Rgb { r: 1, g: 2, b: 3 }),
true,
&palette,
),
(1, 2, 3)
);
}
#[test]
fn resolve_export_palette_auto_prefers_recording_profile_palette() {
let recording_profile = terminal_profile::DetectedTerminalProfile {
terminal_id: "ghostty".to_string(),
font_families: Vec::new(),
font_size_px: None,
background_opacity_permille: None,
cursor_defaults: terminal_profile::CursorDefaults::default(),
palette_defaults: terminal_profile::PaletteDefaults {
foreground: Some("#f0f0f0".to_string()),
background: Some("#101010".to_string()),
colors: vec![terminal_profile::PaletteColorEntry {
index: 5,
color: "#bb78d9".to_string(),
}],
},
source: "recording".to_string(),
};
let host_profile = terminal_profile::DetectedTerminalProfile {
terminal_id: "ghostty".to_string(),
font_families: Vec::new(),
font_size_px: None,
background_opacity_permille: None,
cursor_defaults: terminal_profile::CursorDefaults::default(),
palette_defaults: terminal_profile::PaletteDefaults {
foreground: Some("#ffffff".to_string()),
background: Some("#000000".to_string()),
colors: vec![terminal_profile::PaletteColorEntry {
index: 5,
color: "#00ff00".to_string(),
}],
},
source: "host".to_string(),
};
let resolved = resolve_export_palette(
RecordingPaletteSource::Auto,
Some(&recording_profile),
Some(&host_profile),
None,
None,
&[],
)
.expect("palette should resolve");
assert_eq!(resolved.default_fg, (0xf0, 0xf0, 0xf0));
assert_eq!(resolved.default_bg, (0x10, 0x10, 0x10));
assert_eq!(resolved.colors[5], (0xbb, 0x78, 0xd9));
}
#[test]
fn parse_cursor_color_accepts_auto_and_hex() {
assert_eq!(parse_cursor_color("auto").expect("auto should parse"), None);
assert_eq!(
parse_cursor_color("#11AAee").expect("hex should parse"),
Some((0x11, 0xaa, 0xee))
);
}
#[test]
fn update_cursor_replay_state_parses_decscusr() {
let mut state = CursorReplayState::default();
update_cursor_replay_state(&mut state, b"\x1b[6 q");
assert!(matches!(state.shape, CursorVisualShape::Bar));
assert!(!state.blink_enabled);
update_cursor_replay_state(&mut state, b"\x1b[3 q");
assert!(matches!(state.shape, CursorVisualShape::Underline));
assert!(state.blink_enabled);
}
#[test]
fn display_cursor_shape_from_visual_maps_shapes() {
assert_eq!(
display_cursor_shape_from_visual(CursorVisualShape::Block),
DisplayCursorShape::Block
);
assert_eq!(
display_cursor_shape_from_visual(CursorVisualShape::Bar),
DisplayCursorShape::Bar
);
assert_eq!(
display_cursor_shape_from_visual(CursorVisualShape::Underline),
DisplayCursorShape::Underline
);
}
#[test]
fn cursor_snapshot_from_grid_fallback_uses_grid_cursor_state() {
let mut grid = test_grid(80, 24);
grid.process(b"\x1b[6;11H");
let snapshot = cursor_snapshot_from_grid_fallback(
grid.grid(),
CursorReplayState {
shape: CursorVisualShape::Bar,
blink_enabled: false,
},
);
assert_eq!(snapshot.x, 10);
assert_eq!(snapshot.y, 5);
assert!(snapshot.visible);
assert_eq!(snapshot.shape, DisplayCursorShape::Bar);
assert!(!snapshot.blink_enabled);
}
fn test_grid(cols: u16, rows: u16) -> bmux_terminal_grid::TerminalGridStream {
bmux_terminal_grid::TerminalGridStream::new(
cols,
rows,
bmux_terminal_grid::GridLimits::default(),
)
.expect("test grid dimensions are valid")
}
fn temp_dir() -> std::path::PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("time should be monotonic for test")
.as_nanos();
let dir = std::env::temp_dir().join(format!("bmux-cli-plugin-test-{nanos}"));
std::fs::create_dir_all(&dir).expect("temp dir should be created");
dir
}
use crate::runtime::recording::{
auto_export_default_dir, auto_export_filename_stem, collect_recording_storage_usage,
confirm_delete_all_recordings, default_event_kinds_for_flags, default_recording_list_order,
delete_all_recordings_from_dir, delete_recording_dir_at, filter_recordings,
format_recording_age, list_recordings_from_dir, offline_recording_status,
recording_status_label, resolve_recording_id_prefix, resolve_recording_list_limit,
sort_recordings, unique_auto_export_path,
};
use bmux_cli_schema::{RecordingListOrderArg, RecordingListSortArg, RecordingListStatusArg};
use std::fs;
use uuid::Uuid;
fn recording_summary_for_list_test(
id: &str,
name: Option<&str>,
started_epoch_ms: u64,
ended_epoch_ms: Option<u64>,
event_count: u64,
payload_bytes: u64,
) -> RecordingSummary {
RecordingSummary {
id: Uuid::parse_str(id).expect("test id should parse"),
name: name.map(str::to_string),
format_version: RECORDING_FORMAT_VERSION,
session_id: None,
capture_input: true,
profile: RecordingProfile::Functional,
event_kinds: vec![RecordingEventKind::PaneOutputRaw],
started_epoch_ms,
ended_epoch_ms,
event_count,
payload_bytes,
path: "/tmp/test-recording".to_string(),
segments: vec!["events_0.bin".to_string()],
total_segment_bytes: payload_bytes,
}
}
#[test]
fn auto_export_filename_stem_uses_macos_like_timestamp() {
let timestamp =
time::OffsetDateTime::from_unix_timestamp(0).expect("timestamp should parse");
assert_eq!(
auto_export_filename_stem(timestamp),
"Recording 1970-01-01 at 12.00.00 AM"
);
}
#[test]
fn auto_export_default_dir_uses_recording_parent_directory() {
let recording_dir = std::path::PathBuf::from("/tmp/bmux/recordings/demo");
assert_eq!(
auto_export_default_dir(&recording_dir),
std::path::PathBuf::from("/tmp/bmux/recordings")
);
}
#[test]
fn unique_auto_export_path_adds_numeric_suffix_when_needed() {
let root = temp_dir();
let stem = "Recording 2026-04-05 at 1.02.03 PM";
fs::write(root.join(format!("{stem}.gif")), b"gif").expect("seed gif should write");
let output = unique_auto_export_path(&root, stem);
assert_eq!(output, root.join(format!("{stem} 2.gif")));
}
#[test]
fn list_recordings_from_dir_returns_empty_when_missing() {
let missing_dir = temp_dir().join("does-not-exist");
let recordings = list_recordings_from_dir(&missing_dir).expect("listing should succeed");
assert!(recordings.is_empty());
}
#[test]
fn list_recordings_from_dir_reads_and_sorts_manifests() {
let root = temp_dir();
let newer_id = Uuid::new_v4();
let older_id = Uuid::new_v4();
let newer_dir = root.join(newer_id.to_string());
let older_dir = root.join(older_id.to_string());
fs::create_dir_all(&newer_dir).expect("newer recording dir should exist");
fs::create_dir_all(&older_dir).expect("older recording dir should exist");
let newer_manifest = serde_json::json!({
"summary": {
"id": newer_id,
"session_id": serde_json::Value::Null,
"capture_input": true,
"started_epoch_ms": 200,
"ended_epoch_ms": serde_json::Value::Null,
"event_count": 12,
"payload_bytes": 1024,
"path": newer_dir.to_string_lossy().to_string()
}
});
let older_manifest = serde_json::json!({
"summary": {
"id": older_id,
"session_id": serde_json::Value::Null,
"capture_input": false,
"started_epoch_ms": 100,
"ended_epoch_ms": 150,
"event_count": 4,
"payload_bytes": 128,
"path": older_dir.to_string_lossy().to_string()
}
});
fs::write(
newer_dir.join("manifest.json"),
serde_json::to_vec(&newer_manifest).expect("newer manifest should encode"),
)
.expect("newer manifest should write");
fs::write(
older_dir.join("manifest.json"),
serde_json::to_vec(&older_manifest).expect("older manifest should encode"),
)
.expect("older manifest should write");
let recordings = list_recordings_from_dir(&root).expect("listing should succeed");
assert_eq!(recordings.len(), 2);
assert_eq!(recordings[0].id, newer_id);
assert_eq!(recordings[1].id, older_id);
}
#[test]
fn offline_recording_status_reports_no_active_recording() {
let status = offline_recording_status();
assert!(status.active.is_none());
assert_eq!(status.queue_len, 0);
}
#[test]
fn default_event_kinds_for_flags_falls_back_to_output() {
let kinds = default_event_kinds_for_flags(false, false, false);
assert_eq!(kinds, vec![RecordingEventKind::PaneOutputRaw]);
}
#[test]
fn recording_status_label_reflects_active_and_done_states() {
assert_eq!(recording_status_label(None), "active");
assert_eq!(recording_status_label(Some(1)), "done");
}
#[test]
fn format_recording_age_uses_compact_units() {
assert_eq!(format_recording_age(1_000, 1_900), "now");
assert_eq!(format_recording_age(1_000, 32_000), "31s ago");
assert_eq!(format_recording_age(1_000, 121_000), "2m ago");
assert_eq!(format_recording_age(1_000, 3_601_000), "1h ago");
assert_eq!(format_recording_age(1_000, 172_801_000), "2d ago");
assert_eq!(format_recording_age(1_000, 691_201_000), "1w ago");
assert_eq!(format_recording_age(1_000, 31_536_001_000), "1y ago");
}
#[test]
fn resolve_recording_list_limit_uses_table_default_and_json_full() {
assert_eq!(resolve_recording_list_limit(false, None, false), Some(10));
assert_eq!(resolve_recording_list_limit(true, None, false), None);
assert_eq!(resolve_recording_list_limit(false, Some(3), false), Some(3));
assert_eq!(resolve_recording_list_limit(true, Some(3), false), Some(3));
assert_eq!(resolve_recording_list_limit(false, Some(3), true), None);
}
#[test]
fn default_recording_list_order_matches_sort_field() {
assert_eq!(
default_recording_list_order(RecordingListSortArg::Started),
RecordingListOrderArg::Desc
);
assert_eq!(
default_recording_list_order(RecordingListSortArg::Name),
RecordingListOrderArg::Asc
);
assert_eq!(
default_recording_list_order(RecordingListSortArg::Events),
RecordingListOrderArg::Desc
);
assert_eq!(
default_recording_list_order(RecordingListSortArg::Size),
RecordingListOrderArg::Desc
);
}
#[test]
fn filter_recordings_applies_status_and_case_insensitive_query() {
let active = recording_summary_for_list_test(
"550e8400-e29b-41d4-a716-446655440000",
Some("Startup Repro"),
3,
None,
12,
512,
);
let done = recording_summary_for_list_test(
"550e8400-e29b-41d4-a716-446655440001",
Some("Latency Sweep"),
2,
Some(9),
8,
256,
);
let filtered = filter_recordings(
vec![active.clone(), done.clone()],
RecordingListStatusArg::Active,
Some("startup"),
);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].id, active.id);
let filtered = filter_recordings(
vec![active, done.clone()],
RecordingListStatusArg::Done,
Some("550e8400-e29b-41d4-a716-446655440001"),
);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].id, done.id);
}
#[test]
fn sort_recordings_supports_name_events_and_size() {
let alpha = recording_summary_for_list_test(
"550e8400-e29b-41d4-a716-446655440000",
Some("alpha"),
10,
Some(11),
2,
100,
);
let beta = recording_summary_for_list_test(
"550e8400-e29b-41d4-a716-446655440001",
Some("beta"),
20,
Some(21),
9,
900,
);
let mut recordings = vec![beta.clone(), alpha.clone()];
sort_recordings(
&mut recordings,
RecordingListSortArg::Name,
RecordingListOrderArg::Asc,
);
assert_eq!(recordings[0].id, alpha.id);
sort_recordings(
&mut recordings,
RecordingListSortArg::Events,
RecordingListOrderArg::Desc,
);
assert_eq!(recordings[0].id, beta.id);
sort_recordings(
&mut recordings,
RecordingListSortArg::Size,
RecordingListOrderArg::Asc,
);
assert_eq!(recordings[0].id, alpha.id);
}
#[test]
fn collect_recording_storage_usage_skips_hidden_rolling_dir() {
let root = temp_dir();
let manual_id = Uuid::new_v4();
let manual_dir = root.join(manual_id.to_string());
fs::create_dir_all(&manual_dir).expect("manual recording dir should exist");
fs::write(
manual_dir.join("manifest.json"),
br#"{"summary":{"id":"00000000-0000-0000-0000-000000000000","session_id":null,"capture_input":true,"started_epoch_ms":1,"ended_epoch_ms":null,"event_count":0,"payload_bytes":0,"path":"x"}}"#,
)
.expect("manual manifest should write");
fs::write(manual_dir.join("events_0.bin"), b"manual-bytes")
.expect("manual events should write");
let rolling_dir = root.join(".rolling").join("active");
fs::create_dir_all(&rolling_dir).expect("rolling dir should exist");
fs::write(
rolling_dir.join("manifest.json"),
br#"{"summary":{"id":"00000000-0000-0000-0000-000000000000","session_id":null,"capture_input":true,"started_epoch_ms":1,"ended_epoch_ms":null,"event_count":0,"payload_bytes":0,"path":"x"}}"#,
)
.expect("rolling manifest should write");
fs::write(rolling_dir.join("events_0.bin"), b"rolling-bytes")
.expect("rolling events should write");
let usage =
collect_recording_storage_usage(&root).expect("usage collection should succeed");
assert_eq!(usage.recording_dirs, 1);
assert_eq!(usage.directories, 1);
assert_eq!(usage.files, 2);
}
#[test]
fn resolve_recording_id_prefix_prefers_exact_match() {
let exact = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000")
.expect("exact uuid should parse");
let other = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440001")
.expect("other uuid should parse");
let recordings = vec![
RecordingSummary {
id: other,
name: None,
format_version: RECORDING_FORMAT_VERSION,
session_id: None,
capture_input: true,
profile: RecordingProfile::Functional,
event_kinds: vec![RecordingEventKind::PaneOutputRaw],
started_epoch_ms: 1,
ended_epoch_ms: Some(2),
event_count: 0,
payload_bytes: 0,
path: "/tmp/other".to_string(),
segments: vec!["events_0.bin".to_string()],
total_segment_bytes: 0,
},
RecordingSummary {
id: exact,
name: None,
format_version: RECORDING_FORMAT_VERSION,
session_id: None,
capture_input: true,
profile: RecordingProfile::Functional,
event_kinds: vec![RecordingEventKind::PaneOutputRaw],
started_epoch_ms: 3,
ended_epoch_ms: Some(4),
event_count: 0,
payload_bytes: 0,
path: "/tmp/exact".to_string(),
segments: vec!["events_0.bin".to_string()],
total_segment_bytes: 0,
},
];
let resolved =
resolve_recording_id_prefix("550e8400-e29b-41d4-a716-446655440000", &recordings)
.expect("exact id should resolve");
assert_eq!(resolved, exact);
}
#[test]
fn resolve_recording_id_prefix_rejects_ambiguous_prefix() {
let first = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000")
.expect("first uuid should parse");
let second = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440001")
.expect("second uuid should parse");
let recordings = vec![
RecordingSummary {
id: first,
name: None,
format_version: RECORDING_FORMAT_VERSION,
session_id: None,
capture_input: true,
profile: RecordingProfile::Functional,
event_kinds: vec![RecordingEventKind::PaneOutputRaw],
started_epoch_ms: 1,
ended_epoch_ms: None,
event_count: 0,
payload_bytes: 0,
path: "/tmp/first".to_string(),
segments: vec!["events_0.bin".to_string()],
total_segment_bytes: 0,
},
RecordingSummary {
id: second,
name: None,
format_version: RECORDING_FORMAT_VERSION,
session_id: None,
capture_input: true,
profile: RecordingProfile::Functional,
event_kinds: vec![RecordingEventKind::PaneOutputRaw],
started_epoch_ms: 2,
ended_epoch_ms: None,
event_count: 0,
payload_bytes: 0,
path: "/tmp/second".to_string(),
segments: vec!["events_0.bin".to_string()],
total_segment_bytes: 0,
},
];
let error = resolve_recording_id_prefix("550e8400", &recordings)
.expect_err("ambiguous prefix should fail");
assert!(error.to_string().contains("ambiguous"));
}
#[test]
fn resolve_recording_id_prefix_accepts_exact_name() {
let named = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000")
.expect("named uuid should parse");
let recordings = vec![RecordingSummary {
id: named,
name: Some("startup regression".to_string()),
format_version: RECORDING_FORMAT_VERSION,
session_id: None,
capture_input: true,
profile: RecordingProfile::Functional,
event_kinds: vec![RecordingEventKind::PaneOutputRaw],
started_epoch_ms: 1,
ended_epoch_ms: Some(2),
event_count: 0,
payload_bytes: 0,
path: "/tmp/named".to_string(),
segments: vec!["events_0.bin".to_string()],
total_segment_bytes: 0,
}];
let resolved = resolve_recording_id_prefix("startup regression", &recordings)
.expect("exact recording name should resolve");
assert_eq!(resolved, named);
}
#[test]
fn resolve_recording_id_prefix_rejects_ambiguous_name_prefix() {
let first = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000")
.expect("first uuid should parse");
let second = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440001")
.expect("second uuid should parse");
let recordings = vec![
RecordingSummary {
id: first,
name: Some("bug repro startup".to_string()),
format_version: RECORDING_FORMAT_VERSION,
session_id: None,
capture_input: true,
profile: RecordingProfile::Functional,
event_kinds: vec![RecordingEventKind::PaneOutputRaw],
started_epoch_ms: 1,
ended_epoch_ms: None,
event_count: 0,
payload_bytes: 0,
path: "/tmp/first".to_string(),
segments: vec!["events_0.bin".to_string()],
total_segment_bytes: 0,
},
RecordingSummary {
id: second,
name: Some("bug repro render".to_string()),
format_version: RECORDING_FORMAT_VERSION,
session_id: None,
capture_input: true,
profile: RecordingProfile::Functional,
event_kinds: vec![RecordingEventKind::PaneOutputRaw],
started_epoch_ms: 2,
ended_epoch_ms: None,
event_count: 0,
payload_bytes: 0,
path: "/tmp/second".to_string(),
segments: vec!["events_0.bin".to_string()],
total_segment_bytes: 0,
},
];
let error = resolve_recording_id_prefix("bug repro", &recordings)
.expect_err("ambiguous name prefix should fail");
assert!(error.to_string().contains("ambiguous"));
}
#[test]
fn delete_recording_helpers_remove_manifest_directories() {
let root = temp_dir();
let first = Uuid::new_v4();
let second = Uuid::new_v4();
fs::create_dir_all(root.join(first.to_string())).expect("first dir should exist");
fs::create_dir_all(root.join(second.to_string())).expect("second dir should exist");
fs::write(
root.join(first.to_string()).join("manifest.json"),
br#"{"summary":{"id":"00000000-0000-0000-0000-000000000000","session_id":null,"capture_input":true,"started_epoch_ms":1,"ended_epoch_ms":null,"event_count":0,"payload_bytes":0,"path":"x"}}"#,
)
.expect("first manifest should write");
fs::write(
root.join(second.to_string()).join("manifest.json"),
br#"{"summary":{"id":"00000000-0000-0000-0000-000000000000","session_id":null,"capture_input":true,"started_epoch_ms":1,"ended_epoch_ms":null,"event_count":0,"payload_bytes":0,"path":"x"}}"#,
)
.expect("second manifest should write");
delete_recording_dir_at(&root, first).expect("single delete should succeed");
assert!(!root.join(first.to_string()).exists());
let deleted_count =
delete_all_recordings_from_dir(&root).expect("delete-all helper should succeed");
assert_eq!(deleted_count, 1);
assert!(!root.join(second.to_string()).exists());
}
#[test]
fn confirm_delete_all_requires_yes_for_non_interactive_mode() {
assert!(confirm_delete_all_recordings(true).expect("--yes should bypass prompt"));
let error = confirm_delete_all_recordings(false).expect_err("non-interactive should fail");
assert!(error.to_string().contains("requires --yes"));
}
fn perf_custom_event(
seq: u64,
name: &str,
ts_epoch_ms: u64,
payload: serde_json::Value,
) -> RecordingEventEnvelope {
let mut payload_object = match payload {
serde_json::Value::Object(map) => map,
other => {
let mut map = serde_json::Map::new();
map.insert("value".to_string(), other);
map
}
};
payload_object.insert(
"ts_epoch_ms".to_string(),
serde_json::Value::from(ts_epoch_ms),
);
payload_object.insert(
"level".to_string(),
serde_json::Value::String("detailed".to_string()),
);
RecordingEventEnvelope {
seq,
mono_ns: seq.saturating_mul(1_000_000),
wall_epoch_ms: ts_epoch_ms,
session_id: None,
pane_id: None,
client_id: None,
kind: RecordingEventKind::Custom,
payload: RecordingPayload::Custom {
source: PERF_RECORDING_SOURCE.to_string(),
name: name.to_string(),
payload: serde_json::to_vec(&serde_json::Value::Object(payload_object))
.expect("perf payload should encode"),
},
}
}
#[test]
fn perf_event_emitter_surfaces_drop_counters_on_next_payload() {
let settings = PerfCaptureSettings {
level: PerfCaptureLevel::Basic,
window_ms: 1_000,
max_events_per_sec: 1,
max_payload_bytes_per_sec: 4_096,
};
let mut emitter = PerfEventEmitter::new(settings);
let payload_one = emitter.normalized_payload(serde_json::json!({"sample": 1}));
let encoded_one = serde_json::to_vec(&payload_one).expect("payload should encode");
assert!(emitter.can_emit_payload(encoded_one.len()));
let payload_two = emitter.normalized_payload(serde_json::json!({"sample": 2}));
let encoded_two = serde_json::to_vec(&payload_two).expect("payload should encode");
assert!(!emitter.can_emit_payload(encoded_two.len()));
let payload_three = emitter.normalized_payload(serde_json::json!({"sample": 3}));
let object = payload_three
.as_object()
.expect("normalized payload should be object");
assert_eq!(
object
.get("dropped_events_since_emit")
.and_then(serde_json::Value::as_u64),
Some(1)
);
assert!(
object
.get("dropped_payload_bytes_since_emit")
.and_then(serde_json::Value::as_u64)
.is_some_and(|bytes| bytes > 0),
"drop payload bytes should be included after a rate-limited emit"
);
}
#[test]
fn analyze_perf_events_computes_percentiles_correlations_and_hints() {
let events = vec![
perf_custom_event(
1,
"iroh.connect.summary",
1_000,
serde_json::json!({"connect_ms": 120_u64, "total_ms": 300_u64}),
),
perf_custom_event(
2,
"attach.first_frame",
1_300,
serde_json::json!({"time_to_first_frame_ms": 300_u64}),
),
perf_custom_event(
3,
"attach.interactive.ready",
1_600,
serde_json::json!({"time_to_interactive_ms": 600_u64}),
),
perf_custom_event(
4,
"attach.window",
1_700,
serde_json::json!({
"render_ms_max": 24_u64,
"drain_ipc_ms_max": 28_u64,
"render_ms_avg": 12_u64,
"drain_ipc_ms_avg": 8_u64,
"dropped_events_since_emit": 2_u64,
"dropped_payload_bytes_since_emit": 64_u64,
"dirty_no_visible_row_change_frames": 3_u64,
"extension_imperative_or_cache_miss_frames": 2_u64,
"terminal_graphic_deletes": 1_u64,
}),
),
perf_custom_event(
5,
"iroh.reconnect.outage",
2_100,
serde_json::json!({"outage_ms": 1_800_u64}),
),
];
let report = analyze_perf_events(&events, Some(true));
assert_eq!(report.perf_events, 5);
assert_eq!(report.connect_to_first_frame_ms, Some(300));
assert_eq!(report.connect_to_interactive_ms, Some(600));
assert_eq!(report.reconnect_outage_max_ms, Some(1_800));
assert_eq!(report.dropped_events_reported, 2);
assert_eq!(report.dropped_payload_bytes_reported, 64);
assert_eq!(
report
.overrender_counters
.get("dirty_no_visible_row_change_frames"),
Some(&3)
);
assert_eq!(
report
.attach_window_counters
.get("terminal_graphic_deletes"),
Some(&1)
);
assert_eq!(
report
.timings_ms
.get("connect_ms")
.map(|timing| timing.p95_ms),
Some(120)
);
assert!(
report
.hints
.iter()
.any(|hint| hint.contains("reconnect outage")),
"expected reconnect outage hint in analysis output"
);
assert!(
report
.hints
.iter()
.any(|hint| hint.contains("no-op invalidations")),
"expected render inefficiency hint in analysis output"
);
assert!(
report
.hints
.iter()
.any(|hint| hint.contains("retained terminal graphics")),
"expected retained graphics hint in analysis output"
);
}
#[test]
fn analyze_perf_events_hints_when_custom_events_were_not_captured() {
let report = analyze_perf_events(&[], Some(false));
assert_eq!(report.perf_events, 0);
assert!(
report
.hints
.iter()
.any(|hint| hint.contains("did not capture `custom` events")),
"expected missing-custom-events guidance"
);
}
}