Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 37 additions & 3 deletions crates/rmux-app/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -460,13 +460,17 @@ impl eframe::App for RmuxApp {
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(size[0], size[1])));
}

// Process PTY output for all terminal panes (exit detection, grid).
// OSC → notification generation is disabled for now.
// Process PTY output for all terminal panes (exit detection, grid,
// OSC 9/99/777 notifications like "Claude is waiting for your input").
// Wake immediately when bytes arrived so key→nvim paint is not gated
// solely on the 16 ms cursor-blink timer.
if self.workspace_manager.process_all_panes() {
let (any_output, osc_notifications) = self.workspace_manager.process_all_panes();
if any_output {
ctx.request_repaint();
}
for (workspace_id, pane_id, notification) in osc_notifications {
self.add_pane_notification(workspace_id, pane_id, notification);
}

// cmux-style dynamic sidebar titles. The underlying `ps` / `git` / `gh`
// work happens on the probe thread; here we only adopt its results and
Expand Down Expand Up @@ -701,6 +705,36 @@ impl RmuxApp {
let _ = self.api_event_tx.send(ApiEvent::new(event, data));
}

/// Store an OSC-derived notification and publish it (agent "waiting for
/// input" style messages, build-finished banners, etc).
///
/// Shares the exact same [`NotificationManager::add`] path as the CLI's
/// `notification.create` method, so it stores the row, fires the desktop
/// notification, and updates unread counts identically either way.
fn add_pane_notification(
&mut self,
workspace_id: u64,
pane_id: u64,
notification: rmux_terminal::OscNotification,
) {
let id = self.notifications.add(
notification.title.clone(),
notification.body.clone(),
Some(pane_id),
Some(workspace_id),
);
self.publish_event(
"notification",
json!({
"id": id,
"title": notification.title,
"body": notification.body,
"pane_id": pane_id,
"workspace_id": workspace_id,
}),
);
}

/// Create a workspace with a live terminal in its initial pane.
///
/// Shared by the Cmd/Ctrl+N shortcut and the `workspace.create` API
Expand Down
40 changes: 40 additions & 0 deletions crates/rmux-app/src/notifications/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,17 @@ impl DesktopNotifier for SystemNotifier {
// Best-effort: a failed desktop notification must never
// crash the app (e.g. no notification daemon on Linux).
let outcome = std::panic::catch_unwind(|| {
#[cfg(target_os = "macos")]
ensure_macos_identity();

let mut notification = notify_rust::Notification::new();
// No-op on macOS (both notify-rust backends silently ignore
// `appname`) but required on Linux/XDG so the banner groups
// under "rmux" — matching the `rmux.desktop` entry / icon
// name `scripts/install.sh` installs — instead of a blank
// or PID-derived app name.
notification.appname("rmux");
notification.icon("rmux");
notification.summary(&title);
if let Some(body) = &body {
notification.body(body);
Expand All @@ -81,3 +91,33 @@ impl DesktopNotifier for SystemNotifier {
}
}
}

/// Give rmux its own macOS Notification Center identity instead of silently
/// posting as `com.apple.Finder`.
///
/// `notify-rust`/`mac-notification-sys` require a registered bundle
/// identifier to post through `NSUserNotificationCenter`. When the running
/// binary isn't inside a proper `.app` bundle (any debug build, `cargo run`,
/// or a `cargo install`ed binary), the crate silently falls back to
/// `com.apple.Finder`'s identity on first send. Notifications then land in
/// Notification Center's history — proving delivery "worked" — but never
/// pop up as a banner, because Finder posts constant low-priority ejects/
/// trash notifications and most users have long since muted its alert style.
///
/// `com.nakulbh.rmux` matches the `CFBundleIdentifier` `scripts/install.sh`
/// registers for `~/Applications/rmux.app`. Launch Services keys identity by
/// bundle id, not by which binary is currently executing, so this succeeds
/// even from a raw dev binary as long as that `.app` has been installed once.
/// If it hasn't (fresh clone, never ran the installer), fall back to
/// `com.apple.Terminal` — always registered, and a more sensible identity for
/// a terminal multiplexer than Finder in the meantime.
///
/// Idempotent and cheap to call repeatedly: `mac_notification_sys` guards the
/// underlying `setApplication` call with a process-wide `Once`, so only the
/// very first call (across every notification ever sent) does any work.
#[cfg(target_os = "macos")]
fn ensure_macos_identity() {
if notify_rust::set_application("com.nakulbh.rmux").is_err() {
let _ = notify_rust::set_application("com.apple.Terminal");
}
}
31 changes: 19 additions & 12 deletions crates/rmux-app/src/ui/terminal_pane.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ use anyhow::Result;
use image::ImageEncoder;
use image::codecs::png::PngEncoder;
use rmux_terminal::{
CoalescedSize, InputMapper, PtyBackend, PtyError, TermState, TerminalRenderer,
CoalescedSize, InputMapper, OscNotification, OscScanner, PtyBackend, PtyError, TermState,
TerminalRenderer,
};
use std::path::{Path, PathBuf};
use std::sync::mpsc;
Expand Down Expand Up @@ -83,6 +84,8 @@ pub struct TerminalPane {
input_mapper: InputMapper,
/// Channel receiver for PTY output from background thread.
pty_rx: mpsc::Receiver<Vec<u8>>,
/// Scans raw PTY bytes for OSC 9/99/777 notification sequences.
osc_scanner: OscScanner,
/// Lets the PTY reader thread request an immediate UI frame.
repaint: RepaintHandle,
/// Whether this pane currently has keyboard focus.
Expand Down Expand Up @@ -213,6 +216,7 @@ impl TerminalPane {
snapshot: rmux_terminal::GridSnapshot::default(),
input_mapper,
pty_rx: rx,
osc_scanner: OscScanner::new(),
repaint,
has_focus: false,
show_cursor: true,
Expand Down Expand Up @@ -295,17 +299,18 @@ impl TerminalPane {

/// Process any new PTY output from the background reader thread.
///
/// Drains the channel and feeds bytes into the terminal state.
/// Should be called once per frame before rendering.
/// Drains the channel, feeds bytes into the terminal state, and scans the
/// same bytes for OSC 9/99/777 notification sequences. Should be called
/// once per frame before rendering.
///
/// Returns `true` if any PTY bytes were applied (caller may request repaint).
pub fn process_pty_output(&mut self) -> bool {
/// Returns `(got_output, notifications)`: `got_output` is `true` if any
/// PTY bytes were applied (caller may request repaint); `notifications`
/// holds any OSC notifications completed in this call.
pub fn process_pty_output(&mut self) -> (bool, Vec<OscNotification>) {
let mut got_output = false;
let mut notifications = Vec::new();
while let Ok(data) = self.pty_rx.try_recv() {
// OSC notification scanning is intentionally disabled: OSC 9 is also
// used by iTerm2 progress bars (`OSC 9;4;…`), which produced junk
// entries like "4;0;" in the notification panel. Re-enable only with
// a tighter parser that rejects progress / non-notify sequences.
notifications.extend(self.osc_scanner.feed(&data));
self.state.feed_bytes(&data);
got_output = true;
}
Expand Down Expand Up @@ -336,7 +341,7 @@ impl TerminalPane {
// background probe worker and handed over via [`Self::apply_probe`] —
// this function must never fork a process.

got_output
(got_output, notifications)
}

/// Send a queued startup command once the shell looks ready.
Expand Down Expand Up @@ -444,8 +449,10 @@ impl TerminalPane {
// Reader thread can wake us; keep Context current for that path.
self.repaint.bind(ui.ctx());

// Process any new PTY output (also done at app level for all panes).
if self.process_pty_output() {
// Process any new PTY output (also done at app level for all panes,
// which is the authoritative pass for OSC notifications — this call
// will almost always find the channel already drained).
if self.process_pty_output().0 {
ui.ctx().request_repaint();
}

Expand Down
16 changes: 12 additions & 4 deletions crates/rmux-app/src/workspace/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -327,13 +327,21 @@ impl WorkspaceManager {

/// Process PTY output for all panes across all workspaces.
///
/// Returns `true` if any pane applied new PTY bytes (request a repaint).
pub fn process_all_panes(&mut self) -> bool {
/// Returns `(any_output, notifications)`: `any_output` is `true` if any
/// pane applied new PTY bytes (request a repaint); `notifications` is
/// every OSC notification completed this call, tagged with the raw
/// `(workspace_id, pane_id)` of the pane that raised it.
pub fn process_all_panes(&mut self) -> (bool, Vec<(u64, u64, rmux_terminal::OscNotification)>) {
let mut any = false;
let mut notifications = Vec::new();
for workspace in &mut self.workspaces {
any |= workspace.process_pty_outputs();
let (got, ws_notes) = workspace.process_pty_outputs();
any |= got;
let workspace_id = workspace.id.0;
notifications
.extend(ws_notes.into_iter().map(|(pane_id, note)| (workspace_id, pane_id, note)));
}
any
(any, notifications)
}

/// Shell pids of every terminal in every workspace (probe batch input).
Expand Down
4 changes: 2 additions & 2 deletions crates/rmux-app/src/workspace/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,8 +211,8 @@ impl Workspace {

/// Process PTY output for all panes in this workspace.
///
/// Returns `true` if any pane applied new PTY bytes.
pub fn process_pty_outputs(&mut self) -> bool {
/// Returns `(any_output, notifications)` — see [`PaneNode::process_pty_outputs`].
pub fn process_pty_outputs(&mut self) -> (bool, Vec<(u64, rmux_terminal::OscNotification)>) {
self.root.process_pty_outputs()
}

Expand Down
33 changes: 22 additions & 11 deletions crates/rmux-app/src/workspace/splits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

#![allow(dead_code)]

use rmux_terminal::OscNotification;
use thiserror::Error;

use super::surface::Surface;
Expand Down Expand Up @@ -269,28 +270,38 @@ impl PaneNode {
/// Walks both the legacy `terminal` slot and every multi-surface tab
/// so exit detection works for Cmd+T terminals too.
///
/// OSC → notification generation is disabled (iTerm2 progress OSC 9;4
/// was mis-parsed as junk notifications).
/// Returns `true` if any pane consumed PTY output this call.
pub fn process_pty_outputs(&mut self) -> bool {
/// Returns `(any_output, notifications)`: `any_output` is `true` if any
/// pane consumed PTY output this call; `notifications` tags each OSC
/// notification with the raw id of the leaf pane that raised it (a
/// multi-surface leaf tags every tab's notifications with the shared
/// leaf id — surfaces don't have their own notification routing yet).
pub fn process_pty_outputs(&mut self) -> (bool, Vec<(u64, OscNotification)>) {
match self {
Self::Leaf { terminal, surfaces, .. } => {
Self::Leaf { id, terminal, surfaces, .. } => {
let mut any = false;
let mut notes = Vec::new();
if let Some(t) = terminal.as_mut() {
any |= t.process_pty_output();
let (got, found) = t.process_pty_output();
any |= got;
notes.extend(found.into_iter().map(|n| (id.0, n)));
}
for surface in surfaces.iter_mut() {
any |= surface.terminal.process_pty_output();
let (got, found) = surface.terminal.process_pty_output();
any |= got;
notes.extend(found.into_iter().map(|n| (id.0, n)));
}
any
(any, notes)
}
Self::Browser { .. } => false,
Self::Browser { .. } => (false, Vec::new()),
Self::Split { children, .. } => {
let mut any = false;
let mut notes = Vec::new();
for child in children.iter_mut() {
any |= child.process_pty_outputs();
let (got, found) = child.process_pty_outputs();
any |= got;
notes.extend(found);
}
any
(any, notes)
}
}
}
Expand Down
85 changes: 84 additions & 1 deletion crates/rmux-terminal/src/glyph_cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,18 +33,55 @@ pub(crate) struct GlyphCache {
map: HashMap<GlyphKey, Arc<Galley>>,
/// Memoised `has_glyph` answers per `(char, bold)`.
coverage: HashMap<(char, bool), bool>,
/// Identity of the font texture atlas the cached entries were produced
/// against (see [`Self::ensure_fresh`]). `None` before the first frame.
atlas_identity: Option<usize>,
}

impl GlyphCache {
pub(crate) fn with_capacity(cap: usize) -> Self {
Self { map: HashMap::with_capacity(cap), coverage: HashMap::with_capacity(cap) }
Self {
map: HashMap::with_capacity(cap),
coverage: HashMap::with_capacity(cap),
atlas_identity: None,
}
}

pub(crate) fn clear(&mut self) {
self.map.clear();
self.coverage.clear();
}

/// Drop every cached entry if egui recreated its font texture atlas
/// since the last call. Call this once per `draw()`, before looking
/// anything up.
///
/// egui rebuilds the whole atlas (and its own internal galley cache)
/// when `pixels_per_point` changes, `max_texture_side` changes, or the
/// atlas fills past ~80% (`epaint::text::Fonts::begin_pass`). Any of
/// those can happen after a display/DPI change, and in practice also
/// after the OS suspends and resumes the GPU context — eframe rebuilds
/// font textures on the way back up. Our own cached `Arc<Galley>`s bake
/// in mesh UV coordinates for the *old* atlas packing; painting them
/// against a freshly repacked atlas samples whatever now happens to
/// live at those old coordinates, which is exactly what "all the text
/// scrambled up after sleep" looks like. Comparing the atlas's `Arc`
/// identity (not its contents) catches every recreation reason at once,
/// on any platform, without needing to special-case sleep/wake events.
pub(crate) fn ensure_fresh(&mut self, ui: &Ui) {
let identity = ui.fonts(|f| Arc::as_ptr(&f.texture_atlas()) as *const () as usize);
self.invalidate_if_changed(identity);
}

/// Core of [`Self::ensure_fresh`], split out so the invalidation logic
/// is testable without a live `egui::Ui`.
fn invalidate_if_changed(&mut self, atlas_identity: usize) {
if self.atlas_identity.replace(atlas_identity) != Some(atlas_identity) {
self.map.clear();
self.coverage.clear();
}
}

/// Whether the font cascade can render `c`, cached across frames.
///
/// `Fonts::has_glyph` takes the shared font lock and walks the family's
Expand Down Expand Up @@ -113,6 +150,52 @@ mod tests {
assert_eq!(a, d);
}

#[test]
fn same_atlas_identity_keeps_cached_entries() {
let mut cache = GlyphCache::with_capacity(8);
// Establish the baseline identity first — the very first call always
// "changes" it (from `None`), which would otherwise wipe the entries
// inserted below before the real assertion even runs.
cache.invalidate_if_changed(0xDEAD_BEEF);
cache.map.insert(GlyphKey::new('a', false, 14.0, Color32::WHITE), fake_galley());
cache.coverage.insert(('a', false), true);

cache.invalidate_if_changed(0xDEAD_BEEF);

assert_eq!(cache.map.len(), 1, "same atlas identity must not evict entries");
assert_eq!(cache.coverage.len(), 1);
}

#[test]
fn atlas_identity_change_clears_cache() {
// A resized/recreated font atlas (DPI change, or the GPU context
// rebuilt after sleep/wake) invalidates every cached galley's UVs —
// this is the guard against "scrambled text after resume".
let mut cache = GlyphCache::with_capacity(8);
cache.map.insert(GlyphKey::new('a', false, 14.0, Color32::WHITE), fake_galley());
cache.coverage.insert(('a', false), true);
cache.invalidate_if_changed(0x1111_1111);

cache.invalidate_if_changed(0x2222_2222);

assert!(cache.map.is_empty(), "atlas swap must evict stale galleys");
assert!(cache.coverage.is_empty());
}

#[test]
fn first_call_does_not_panic_on_empty_cache() {
let mut cache = GlyphCache::with_capacity(8);
cache.invalidate_if_changed(0x1234);
assert!(cache.map.is_empty());
}

fn fake_galley() -> Arc<Galley> {
let ctx = egui::Context::default();
// Fonts aren't initialized until the first frame runs.
let _ = ctx.run(Default::default(), |_| {});
ctx.fonts(|f| f.layout_no_wrap("x".to_string(), FontId::monospace(14.0), Color32::WHITE))
}

#[test]
fn clear_is_idempotent() {
let mut cache = GlyphCache::with_capacity(8);
Expand Down
Loading
Loading