diff options
| author | akbash <akbash@herdr.dev> | 2026-08-08 02:15:56 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-08-08 02:15:56 +0300 |
| commit | 6f311498aeeb27c0973781961ef94e8d0016ed17 (patch) | |
| tree | 7aee2363b21b830fdbcf8dca9a7eda57a3393fb5 /src | |
| parent | 50ddc06f00ec69ede35fd5488a260dec2b74e98a (diff) | |
fix: forward pane terminal bells (#2498)
refs #2453
Co-authored-by: akbash-bot <300245827+akbash-bot@users.noreply.github.com>
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/actions.rs | 3 | ||||
| -rw-r--r-- | src/app/api.rs | 13 | ||||
| -rw-r--r-- | src/client/mod.rs | 7 | ||||
| -rw-r--r-- | src/events.rs | 3 | ||||
| -rw-r--r-- | src/ghostty/mod.rs | 20 | ||||
| -rw-r--r-- | src/main.rs | 1 | ||||
| -rw-r--r-- | src/pane.rs | 56 | ||||
| -rw-r--r-- | src/pane/terminal.rs | 23 | ||||
| -rw-r--r-- | src/protocol/wire.rs | 17 | ||||
| -rw-r--r-- | src/server/headless.rs | 75 | ||||
| -rw-r--r-- | src/terminal_effects.rs | 27 |
11 files changed, 242 insertions, 3 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs index 51de049c..40707cee 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -2902,9 +2902,10 @@ impl AppState { .collect() } } - // Both intercepted before this dispatch — in App::handle_internal_event (monolithic) + // Intercepted before this dispatch — in App::handle_internal_event (monolithic) // or via HeadlessServer forwarding to the foreground client (server); never touch // AppState. Kept for AppEvent exhaustiveness. + AppEvent::TerminalBell { .. } => Vec::new(), AppEvent::ClipboardWrite { .. } => Vec::new(), AppEvent::PrefixInputSource { .. } => Vec::new(), AppEvent::TerminalCwdReported { pane_id, cwd } => { diff --git a/src/app/api.rs b/src/app/api.rs index 5f81a5cb..c2130f36 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -64,6 +64,10 @@ impl App { results, cache_updates, } => self.handle_git_status_refreshed(results, cache_updates), + ev @ AppEvent::TerminalBell { .. } => { + self.handle_internal_event(ev); + false + } ev => { self.handle_internal_event(ev); true @@ -97,6 +101,15 @@ impl App { } pub(crate) fn handle_internal_event(&mut self, ev: AppEvent) { + if let AppEvent::TerminalBell { count, .. } = ev { + if let Err(err) = + crate::terminal_effects::write_terminal_bells(&mut std::io::stdout(), count) + { + tracing::warn!(err = %err, "failed to emit terminal bell"); + } + return; + } + if let AppEvent::ClipboardWrite { content } = ev { #[cfg(not(test))] crate::selection::write_osc52_bytes(&content); diff --git a/src/client/mod.rs b/src/client/mod.rs index b153dd93..7932cb79 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -1571,6 +1571,13 @@ async fn run_client_loop( let _ = stdout.flush(); } } + ServerMessage::TerminalBell { count } => { + if let Err(err) = + crate::terminal_effects::write_terminal_bells(&mut io::stdout(), count) + { + warn!(err = %err, "failed to emit terminal bell"); + } + } ServerMessage::ServerShutdown { reason } => { return Err(ClientError::ServerShutdown { reason }); } diff --git a/src/events.rs b/src/events.rs index a3869ef7..7510ae3c 100644 --- a/src/events.rs +++ b/src/events.rs @@ -124,6 +124,9 @@ pub enum AppEvent { updated: Vec<crate::detect::manifest_update::ManifestUpdateCommit>, status: crate::detect::manifest_update::ManifestUpdateStatus, }, + /// A pane child emitted one or more executable BEL characters. + /// The host-facing process forwards them to its outer terminal. + TerminalBell { pane_id: PaneId, count: u16 }, /// A pane child emitted a valid OSC 52 clipboard write. The main loop /// re-emits it through herdr's own clipboard writer. ClipboardWrite { content: Vec<u8> }, diff --git a/src/ghostty/mod.rs b/src/ghostty/mod.rs index 04ad87af..93efbd76 100644 --- a/src/ghostty/mod.rs +++ b/src/ghostty/mod.rs @@ -470,12 +470,22 @@ const MAX_CLIPBOARD_BYTES: usize = 192 * 1024; #[derive(Default)] struct TerminalCallbackState { write_pty: Option<Box<WritePtyCallback>>, + bell_count: u16, pwd_changes: Vec<Vec<u8>>, clipboard_writes: Vec<Vec<u8>>, size_report: ffi::GhosttySizeReportSize, color_scheme: Option<ColorScheme>, } +unsafe extern "C" fn bell_trampoline(_terminal: ffi::GhosttyTerminal, userdata: *mut c_void) { + if userdata.is_null() { + return; + } + // SAFETY: userdata is the TerminalCallbackState installed with this terminal. + let state = unsafe { &mut *userdata.cast::<TerminalCallbackState>() }; + state.bell_count = state.bell_count.saturating_add(1); +} + unsafe extern "C" fn color_scheme_trampoline( _terminal: ffi::GhosttyTerminal, userdata: *mut c_void, @@ -822,6 +832,12 @@ impl Terminal { .into_result()?; ffi::ghostty_terminal_set( terminal.raw, + ffi::GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_BELL, + (bell_trampoline as *const ()).cast(), + ) + .into_result()?; + ffi::ghostty_terminal_set( + terminal.raw, ffi::GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_PWD_CHANGED, (pwd_changed_trampoline as *const ()).cast(), ) @@ -979,6 +995,10 @@ impl Terminal { mem::replace(&mut self.callback_state.color_scheme, color_scheme) } + pub fn take_bell_count(&mut self) -> u16 { + mem::take(&mut self.callback_state.bell_count) + } + pub fn take_pwd_changes(&mut self) -> Vec<Vec<u8>> { mem::take(&mut self.callback_state.pwd_changes) } diff --git a/src/main.rs b/src/main.rs index 85ff264b..7f79730d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -93,6 +93,7 @@ mod server; mod session; mod sound; mod terminal; +mod terminal_effects; mod terminal_modes; mod terminal_notify; mod terminal_theme; diff --git a/src/pane.rs b/src/pane.rs index 7669fc13..ab984946 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -1499,6 +1499,20 @@ fn usable_reported_cwd(cwd: std::path::PathBuf) -> Option<std::path::PathBuf> { (cwd.is_absolute() && cwd.is_dir()).then_some(cwd) } +fn publish_terminal_bells(pane_id: PaneId, count: u16, events: &mpsc::Sender<AppEvent>) { + if count == 0 { + return; + } + if let Err(err) = events.try_send(AppEvent::TerminalBell { pane_id, count }) { + warn!( + pane = pane_id.raw(), + count, + err = %err, + "failed to queue terminal bell" + ); + } +} + fn publish_reported_cwd( pane_id: PaneId, cwd: std::path::PathBuf, @@ -1868,6 +1882,7 @@ impl PaneRuntime { let shell_pid = child_pid.load(Ordering::Acquire); let result = terminal.process_pty_bytes(pane_id, shell_pid, bytes, &response_writer); + publish_terminal_bells(pane_id, result.terminal_bells, &read_events); observe_detection_content_change(bytes, &detection_content_seq); if result.request_render && render_dirty.request_pty(pane_id) { render_notify.notify_one(); @@ -2028,6 +2043,7 @@ impl PaneRuntime { let shell_pid = child_pid.load(Ordering::Acquire); let result = terminal.process_pty_bytes(pane_id, shell_pid, bytes, &response_writer); + publish_terminal_bells(pane_id, result.terminal_bells, &events); if agent_detection == AgentDetection::Enabled { observe_detection_content_change(bytes, &detection_content_seq); } @@ -4214,6 +4230,46 @@ mod tests { .expect("re-entering active authority should notify detection reset"); } + #[cfg(unix)] + #[tokio::test] + async fn spawned_pty_reader_aggregates_terminal_bells() { + let (events, mut event_rx) = mpsc::channel(8); + let pane_id = PaneId::from_raw(42); + let runtime = PaneRuntime::spawn_shell_command( + pane_id, + 24, + 80, + std::env::temp_dir(), + "printf '\\a\\a'; sleep 0.05", + &PaneLaunchEnv::default(), + AgentDetection::Disabled, + 0, + crate::terminal_theme::TerminalTheme::default(), + None, + events, + Arc::new(Notify::new()), + Arc::new(RenderSignal::new()), + ) + .unwrap(); + + let bell = tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + if let Some(AppEvent::TerminalBell { + pane_id: delivered_pane, + count, + }) = event_rx.recv().await + { + break (delivered_pane, count); + } + } + }) + .await + .expect("PTY reader should publish terminal bells"); + + assert_eq!(bell, (pane_id, 2)); + runtime.shutdown(); + } + #[tokio::test] async fn state_changed_event_waits_for_queue_space_instead_of_dropping() { let (tx, mut rx) = mpsc::channel(1); diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index 67b57153..8de84af3 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -145,6 +145,7 @@ impl InputState { pub(crate) struct ProcessBytesResult { pub request_render: bool, pub render_delay: Option<Duration>, + pub terminal_bells: u16, pub clipboard_writes: Vec<Vec<u8>>, pub reported_cwd: Option<std::path::PathBuf>, pub terminal_responses: Vec<Bytes>, @@ -1201,6 +1202,7 @@ impl GhosttyPaneTerminal { return ProcessBytesResult { request_render: false, render_delay: None, + terminal_bells: 0, clipboard_writes: Vec::new(), reported_cwd: None, terminal_responses: Vec::new(), @@ -1209,7 +1211,8 @@ impl GhosttyPaneTerminal { let _ = core.terminal.take_pwd_changes(); // Restored history may have exercised terminal callbacks before this live PTY write. - // Those writes must not be delivered as live pane output. + // Those effects must not be delivered as live pane output. + let _ = core.terminal.take_bell_count(); let _ = core.terminal.take_clipboard_writes(); let default_color_observation = core.default_color_tracker.observe(bytes); if shell_pid > 0 && default_color_observation { @@ -1276,6 +1279,7 @@ impl GhosttyPaneTerminal { xtgettcap_responses, &mut terminal_responses, ); + let terminal_bells = core.terminal.take_bell_count(); let clipboard_writes = core.terminal.take_clipboard_writes(); let reported_cwd = core .terminal @@ -1332,6 +1336,7 @@ impl GhosttyPaneTerminal { ProcessBytesResult { request_render, render_delay, + terminal_bells, clipboard_writes, reported_cwd, terminal_responses, @@ -3735,6 +3740,21 @@ mod tests { } #[test] + fn process_pty_bytes_surfaces_live_bells_only() { + let (tx, _rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(80, 24, 100).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(); + let pane_id = PaneId::from_raw(1); + + pane.seed_history_ansi("stale\x07"); + let result = pane.process_pty_bytes(pane_id, 0, b"\x07\x1b]0;title\x07\x07", &tx); + + assert_eq!(result.terminal_bells, 2); + let drained = pane.process_pty_bytes(pane_id, 0, b"live output", &tx); + assert_eq!(drained.terminal_bells, 0); + } + + #[test] fn process_pty_bytes_surfaces_clipboard_writes_without_other_results() { let (tx, _rx) = mpsc::channel(4); let terminal = crate::ghostty::Terminal::new(80, 24, 100).unwrap(); @@ -3749,6 +3769,7 @@ mod tests { assert!(result.request_render); assert_eq!(result.render_delay, None); + assert_eq!(result.terminal_bells, 0); assert_eq!(result.clipboard_writes, vec![b"clipboard".to_vec()]); assert_eq!(result.reported_cwd, None); assert!(result.terminal_responses.is_empty()); diff --git a/src/protocol/wire.rs b/src/protocol/wire.rs index 655f47cb..4af2c794 100644 --- a/src/protocol/wire.rs +++ b/src/protocol/wire.rs @@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize}; // --------------------------------------------------------------------------- /// Current protocol version. Bumped when wire format changes incompatibly. -pub const PROTOCOL_VERSION: u32 = 19; +pub const PROTOCOL_VERSION: u32 = 20; /// Maximum allowed frame payload size (2 MB). Frames larger than this are /// rejected to prevent denial-of-service via oversized length prefixes. @@ -711,6 +711,12 @@ pub enum ServerMessage { /// Whether the ASCII input source should be active. active: bool, }, + + /// Ring the foreground client's outer terminal for pane-originated BEL characters. + TerminalBell { + /// Number of BEL characters parsed from one PTY read. + count: u16, + }, } // --------------------------------------------------------------------------- @@ -1555,6 +1561,15 @@ mod tests { } } + #[test] + fn server_terminal_bell_roundtrip() { + let msg = ServerMessage::TerminalBell { count: 3 }; + let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); + let (decoded, _): (ServerMessage, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); + assert_eq!(msg, decoded); + } + // ---- Framing ---- #[test] diff --git a/src/server/headless.rs b/src/server/headless.rs index 859e02af..5a90177b 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -2098,6 +2098,15 @@ impl HeadlessServer { /// Returns true if the event changed visual state (requiring a re-render). fn handle_internal_event_with_forwarding(&mut self, ev: AppEvent) -> bool { match &ev { + AppEvent::TerminalBell { pane_id, count } => { + if !self.send_to_foreground_client(ServerMessage::TerminalBell { count: *count }) { + debug!( + pane = pane_id.raw(), + count, "dropped terminal bell without a foreground client" + ); + } + false + } AppEvent::ClipboardWrite { content } => { // Clipboard writes are client-local side effects. Forward them only to // the foreground client instead of broadcasting to every attached client. @@ -9584,6 +9593,72 @@ next_tab = "" } #[test] + fn terminal_bell_targets_foreground_client_only() { + let mut server = test_headless_server(); + let (background_tx, background_control_rx, _background_rx) = test_client_writer(); + let (foreground_tx, foreground_control_rx, _foreground_rx) = test_client_writer(); + + server.clients.insert( + 1, + ClientConnection::new( + (120, 40), + crate::kitty_graphics::HostCellSize::default(), + crate::terminal_theme::TerminalTheme::default(), + None, + 1, + RenderEncoding::SemanticFrame, + Some(background_tx), + ), + ); + server.clients.insert( + 2, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + crate::terminal_theme::TerminalTheme::default(), + None, + 2, + RenderEncoding::SemanticFrame, + Some(foreground_tx), + ), + ); + server.foreground_client_id = Some(2); + + let changed = server.handle_internal_event_with_forwarding(AppEvent::TerminalBell { + pane_id: crate::layout::PaneId::from_raw(1), + count: 3, + }); + + assert!(!changed); + match read_server_message( + foreground_control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("foreground terminal bell message"), + ) { + ServerMessage::TerminalBell { count } => assert_eq!(count, 3), + other => panic!("expected terminal bell message, got {other:?}"), + } + assert!( + background_control_rx + .recv_timeout(Duration::from_millis(50)) + .is_err(), + "background client should not receive terminal bells" + ); + + server.foreground_client_id = None; + server.handle_internal_event_with_forwarding(AppEvent::TerminalBell { + pane_id: crate::layout::PaneId::from_raw(1), + count: 1, + }); + assert!( + foreground_control_rx + .recv_timeout(Duration::from_millis(50)) + .is_err(), + "bells without a foreground client must not be retained" + ); + } + + #[test] fn clipboard_write_targets_foreground_client_only() { let mut server = test_headless_server(); let (background_tx, background_control_rx, _background_rx) = test_client_writer(); diff --git a/src/terminal_effects.rs b/src/terminal_effects.rs new file mode 100644 index 00000000..88fcf55f --- /dev/null +++ b/src/terminal_effects.rs @@ -0,0 +1,27 @@ +use std::io::{self, Write}; + +const BELL_CHUNK: [u8; 64] = [b'\x07'; 64]; + +pub(crate) fn write_terminal_bells<W: Write>(writer: &mut W, count: u16) -> io::Result<()> { + let full_chunks = usize::from(count) / BELL_CHUNK.len(); + let remainder = usize::from(count) % BELL_CHUNK.len(); + for _ in 0..full_chunks { + writer.write_all(&BELL_CHUNK)?; + } + writer.write_all(&BELL_CHUNK[..remainder])?; + writer.flush() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn writes_exact_terminal_bell_count() { + let mut output = Vec::new(); + + write_terminal_bells(&mut output, 130).unwrap(); + + assert_eq!(output, vec![b'\x07'; 130]); + } +} |
