diff --git a/Cargo.lock b/Cargo.lock index a8eed3d..fb67632 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1446,6 +1446,7 @@ dependencies = [ "log", "open", "paste", + "polling", "regex", "reqwest", "ron 0.11.0", @@ -1459,6 +1460,7 @@ dependencies = [ "tokio", "url", "xdgen", + "zbus", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index b796364..c95c38f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,9 @@ ron = "0.11" serde = { version = "1", features = ["serde_derive"] } shlex = "1" tokio = { version = "1", features = ["sync", "rt", "macros", "time"] } +# Notifications de bureau (OSC 9 / 777) et enveloppe du PTY (yoda) +polling = "3.11" +zbus = "5.19" # HTTP client for Modjo AI integration reqwest = { version = "0.12", features = ["json", "stream"], optional = true } serde_json = { version = "1", optional = true } diff --git a/i18n/en/cosmic_term.ftl b/i18n/en/cosmic_term.ftl index 2ff6c3c..d4a8c90 100644 --- a/i18n/en/cosmic_term.ftl +++ b/i18n/en/cosmic_term.ftl @@ -140,6 +140,9 @@ monitor-activity = Monitor for activity monitor-silence = Monitor for silence monitor-process-finished = Monitor for process finishing +## Notifications +notification-show = Show tab + ## Open open-link = Open Link copy-link = Copy Link diff --git a/i18n/fr/cosmic_term.ftl b/i18n/fr/cosmic_term.ftl index 834c800..350158b 100644 --- a/i18n/fr/cosmic_term.ftl +++ b/i18n/fr/cosmic_term.ftl @@ -109,6 +109,9 @@ monitor-activity = Surveiller l'activité monitor-silence = Surveiller le silence monitor-process-finished = Surveiller la fin du processus +## Notifications +notification-show = Afficher l'onglet + ## View view = Affichage diff --git a/src/desktop_notify.rs b/src/desktop_notify.rs new file mode 100644 index 0000000..4ad9821 --- /dev/null +++ b/src/desktop_notify.rs @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: GPL-3.0-only + +//! Notifications de bureau des OSC 9 / 777 (yoda). +//! +//! On parle directement à `org.freedesktop.Notifications` plutôt que de passer +//! par notify-rust : au clic, cosmic-notifications émet `ActivationToken` puis +//! `ActionInvoked`, et c'est ce jeton xdg-activation qui permet de ramener la +//! fenêtre au premier plan sur le bon onglet. +//! +//! Une notification par onglet au plus : la suivante remplace la précédente +//! (`replaces_id`). + +use cosmic::{ + Application, + iced::{ + Subscription, + futures::{SinkExt, channel::mpsc::Sender}, + stream, + }, + widget::{pane_grid, segmented_button}, +}; +use std::{any::TypeId, collections::HashMap}; +use tokio::sync::mpsc; + +use crate::fl; + +/// Onglet à l'origine d'une notification. +pub type TabKey = (pane_grid::Pane, segmented_button::Entity); + +/// Demande envoyée par l'application à la tâche D-Bus. +#[derive(Debug)] +pub enum Request { + Show { + tab: TabKey, + summary: String, + body: String, + }, +} + +/// Événement renvoyé par la tâche D-Bus à l'application. +#[derive(Clone, Debug)] +pub enum Event { + /// Canal prêt : l'application peut envoyer ses demandes. + Ready(mpsc::UnboundedSender), + /// L'utilisateur a cliqué la notification de cet onglet. + Activated { tab: TabKey, token: Option }, +} + +#[zbus::proxy( + interface = "org.freedesktop.Notifications", + default_service = "org.freedesktop.Notifications", + default_path = "/org/freedesktop/Notifications" +)] +trait Notifications { + #[allow(clippy::too_many_arguments)] + fn notify( + &self, + app_name: &str, + replaces_id: u32, + app_icon: &str, + summary: &str, + body: &str, + actions: &[&str], + hints: HashMap<&str, zbus::zvariant::Value<'_>>, + expire_timeout: i32, + ) -> zbus::Result; + + #[zbus(signal)] + fn action_invoked(&self, id: u32, action_key: String) -> zbus::Result<()>; + + #[zbus(signal)] + fn activation_token(&self, id: u32, activation_token: String) -> zbus::Result<()>; + + #[zbus(signal)] + fn notification_closed(&self, id: u32, reason: u32) -> zbus::Result<()>; +} + +struct DesktopNotifySubscription; + +pub fn subscription() -> Subscription { + Subscription::run_with(TypeId::of::(), |_| { + stream::channel(16, |mut output: Sender| async move { + let (request_tx, request_rx) = mpsc::unbounded_channel(); + if output.send(Event::Ready(request_tx)).await.is_err() { + return; + } + if let Err(err) = run(request_rx, output).await { + log::warn!("desktop notifications unavailable: {err}"); + } + }) + }) +} + +async fn run( + mut request_rx: mpsc::UnboundedReceiver, + mut output: Sender, +) -> zbus::Result<()> { + use cosmic::iced::futures::StreamExt; + + let connection = zbus::Connection::session().await?; + let proxy = NotificationsProxy::new(&connection).await?; + let mut tokens = proxy.receive_activation_token().await?; + let mut invoked = proxy.receive_action_invoked().await?; + let mut closed = proxy.receive_notification_closed().await?; + + let app_name = fl!("cosmic-terminal"); + let show_label = fl!("notification-show"); + let mut tab_by_id: HashMap = HashMap::new(); + let mut id_by_tab: HashMap = HashMap::new(); + let mut pending_tokens: HashMap = HashMap::new(); + + loop { + tokio::select! { + request = request_rx.recv() => { + let Some(Request::Show { tab, summary, body }) = request else { + return Ok(()); + }; + let replaces_id = id_by_tab.get(&tab).copied().unwrap_or(0); + let body = neutralize_markup(&body); + let hints = HashMap::from([ + ("desktop-entry", zbus::zvariant::Value::from(crate::App::APP_ID)), + ("urgency", zbus::zvariant::Value::U8(1)), + ]); + match proxy + .notify( + &app_name, + replaces_id, + crate::App::APP_ID, + &summary, + &body, + &["default", &show_label], + hints, + -1, + ) + .await + { + Ok(id) => { + if replaces_id != 0 && replaces_id != id { + tab_by_id.remove(&replaces_id); + } + tab_by_id.insert(id, tab); + id_by_tab.insert(tab, id); + } + Err(err) => log::warn!("failed to send desktop notification: {err}"), + } + } + Some(signal) = tokens.next() => { + if let Ok(args) = signal.args() + && tab_by_id.contains_key(args.id()) + { + pending_tokens.insert(*args.id(), args.activation_token().clone()); + } + } + Some(signal) = invoked.next() => { + if let Ok(args) = signal.args() + && let Some(tab) = tab_by_id.get(args.id()).copied() + { + let token = pending_tokens.remove(args.id()); + if output.send(Event::Activated { tab, token }).await.is_err() { + return Ok(()); + } + } + } + Some(signal) = closed.next() => { + if let Ok(args) = signal.args() + && let Some(tab) = tab_by_id.remove(args.id()) + { + if id_by_tab.get(&tab) == Some(args.id()) { + id_by_tab.remove(&tab); + } + pending_tokens.remove(args.id()); + } + } + } + } +} + +/// Le corps d'une notification est du balisage (`body-markup`). Échapper `<` +/// en `<` serait conforme à la spécification, mais cosmic-notifications ne +/// décode pas les entités (il afficherait `<`), et un `<` brut y avale le +/// texte qui suit. Les chevrons deviennent donc des guillemets simples +/// d'aspect voisin, lisibles sur tous les serveurs. +fn neutralize_markup(body: &str) -> String { + body.replace('<', "‹").replace('>', "›") +} diff --git a/src/main.rs b/src/main.rs index 1a72235..06cc965 100644 --- a/src/main.rs +++ b/src/main.rs @@ -68,9 +68,17 @@ mod localize; use menu::menu_bar; mod menu; -use terminal::{MONITOR_SILENCE_SECONDS, MonitorKind, Terminal, TerminalPaneGrid, TerminalScroll}; +use terminal::{ + AttentionKind, MONITOR_SILENCE_SECONDS, MonitorKind, PtyEvent, Terminal, TerminalPaneGrid, + TerminalScroll, +}; mod terminal; +mod desktop_notify; + +use osc_tap::OscEvent; +mod osc_tap; + use terminal_box::terminal_box; use crate::dnd::DndDrop; @@ -94,7 +102,13 @@ pub fn icon_cache_get(name: &'static str, size: u16) -> widget::icon::Icon { /// Tab bar indicator for the monitor state of a terminal: /// an alert icon if one fired, an "armed" icon while monitoring, else none. +/// Un appel d'attention (cloche, notification) passe avant les moniteurs. fn monitor_icon_name(terminal: &Terminal) -> Option<&'static str> { + match terminal.attention { + Some(AttentionKind::Bell) => return Some("notification-alert-symbolic"), + Some(AttentionKind::Notification) => return Some("notification-new-symbolic"), + None => {} + } match terminal.monitor_alert { Some(MonitorKind::Activity) => Some("dialog-information-symbolic"), Some(MonitorKind::Silence) => Some("alarm-symbolic"), @@ -592,7 +606,9 @@ pub enum Message { TabRenameCancel, TabRenameResetToDefault, TermEvent(pane_grid::Pane, segmented_button::Entity, TermEvent), - TermEventTx(mpsc::UnboundedSender<(pane_grid::Pane, segmented_button::Entity, TermEvent)>), + TermEventTx(mpsc::UnboundedSender<(pane_grid::Pane, segmented_button::Entity, PtyEvent)>), + TermOsc(pane_grid::Pane, segmented_button::Entity, OscEvent), + DesktopNotify(desktop_notify::Event), ToggleFullscreen, ToggleContextPage(ContextPage), UpdateDefaultProfile((bool, ProfileId)), @@ -658,7 +674,9 @@ pub struct App { find_search_id: widget::Id, find_search_value: String, term_event_tx_opt: - Option>, + Option>, + notify_tx_opt: Option>, + window_focused: bool, startup_options: Option, startup_profile_id: Option, term_config: term::Config, @@ -686,6 +704,51 @@ pub struct App { } impl App { + /// L'onglet est-il sous les yeux de l'utilisateur : onglet actif du panneau + /// focalisé, dans une fenêtre qui a le focus. + fn tab_in_view(&self, pane: pane_grid::Pane, entity: segmented_button::Entity) -> bool { + self.window_focused + && self.pane_model.focused() == pane + && self + .pane_model + .panes + .get(pane) + .is_some_and(|tab_model| tab_model.active() == entity) + } + + /// Allume l'indicateur d'attention d'un onglet ; il s'éteint quand + /// l'onglet reprend le focus (voir `TerminalPaneGrid::update_terminal_focus`). + fn set_tab_attention( + &mut self, + pane: pane_grid::Pane, + entity: segmented_button::Entity, + kind: AttentionKind, + ) { + let Some(tab_model) = self.pane_model.panes.get_mut(pane) else { + return; + }; + let mut icon_update = None; + if let Some(terminal) = tab_model.data::>(entity) { + let mut terminal = terminal.lock().unwrap(); + // Une notification n'est pas rétrogradée en simple cloche + if terminal.attention != Some(AttentionKind::Notification) { + terminal.attention = Some(kind); + icon_update = Some(monitor_icon_name(&terminal)); + } + } + if let Some(icon_name) = icon_update { + monitor_update_tab_icon(tab_model, entity, icon_name); + } + } + + /// Recale le focus des terminaux après un changement d'onglet : l'onglet + /// quitté reçoit son focus-out (mode 1004), le nouvel onglet son focus-in. + fn sync_terminal_focus(&mut self) { + if self.window_focused && !self.core.window.show_context && self.rename_tab.is_none() { + self.pane_model.update_terminal_focus(); + } + } + fn theme_names(&self, color_scheme_kind: ColorSchemeKind) -> &Vec { match color_scheme_kind { ColorSchemeKind::Dark => &self.theme_names_dark, @@ -1990,6 +2053,7 @@ impl App { log::warn!("tried to create new tab before having event channel"); } } + self.sync_terminal_focus(); self.update_title(Some(pane)) } } @@ -2185,6 +2249,8 @@ impl Application for App { startup_profile_id: flags.startup_profile_id, term_config: flags.term_config, term_event_tx_opt: None, + notify_tx_opt: None, + window_focused: true, color_scheme_errors: Vec::new(), color_scheme_expanded: None, color_scheme_renaming: None, @@ -3367,6 +3433,7 @@ impl Application for App { monitor_update_tab_icon(tab_model, entity, icon_name); } } + self.sync_terminal_focus(); return self.update_title(None); } Message::TabActivateJump(pos) => { @@ -3420,6 +3487,7 @@ impl Application for App { } } + self.sync_terminal_focus(); return self.update_title(None); } Message::TabContextAction(entity, action) => { @@ -3589,7 +3657,13 @@ impl Application for App { Message::TermEvent(pane, entity, event) => { match event { TermEvent::Bell => { - //TODO: audible or visible bell options? + // Comme le défaut de Ghostty (`bell-features = attention,title`), + // sans la demande d'attention xdg-activation : cosmic-comp donne + // un jeton valide à toute application non sandboxée, la fenêtre + // volerait le focus. Il reste l'indicateur d'onglet. + if !self.tab_in_view(pane, entity) { + self.set_tab_attention(pane, entity, AttentionKind::Bell); + } } TermEvent::ClipboardLoad(kind, callback) => { match kind { @@ -3749,6 +3823,76 @@ impl Application for App { // Spawn first tab return self.update(Message::TabNew); } + Message::TermOsc(pane, entity, event) => match event { + OscEvent::Notify { title, body } => { + // Rien à signaler pour l'onglet qu'on a sous les yeux + if self.tab_in_view(pane, entity) { + return Task::none(); + } + self.set_tab_attention(pane, entity, AttentionKind::Notification); + + let Some(tab_model) = self.pane_model.panes.get(pane) else { + return Task::none(); + }; + let allowed = tab_model + .data::>(entity) + .is_some_and(|terminal| { + terminal + .lock() + .unwrap() + .notify_allowed(title.as_deref(), &body) + }); + if !allowed { + return Task::none(); + } + + // Titre de la séquence, suivi de l'onglet d'où elle vient : + // plusieurs sessions Claude Code se distinguent ainsi. + let tab_title = tab_model.text(entity).unwrap_or_default().to_string(); + let summary = match title { + Some(title) if !tab_title.is_empty() && title != tab_title => { + format!("{title} · {tab_title}") + } + Some(title) => title, + None if !tab_title.is_empty() => tab_title, + None => fl!("cosmic-terminal"), + }; + if let Some(notify_tx) = &self.notify_tx_opt { + let _ = notify_tx.send(desktop_notify::Request::Show { + tab: (pane, entity), + summary, + body, + }); + } + } + }, + Message::DesktopNotify(event) => match event { + desktop_notify::Event::Ready(notify_tx) => { + self.notify_tx_opt = Some(notify_tx); + } + desktop_notify::Event::Activated { + tab: (pane, entity), + token, + } => { + // Clic sur la notification : retour à l'onglet d'origine, + // puis la fenêtre au premier plan avec le jeton + // xdg-activation fourni par le serveur de notifications. + let mut tasks = Vec::new(); + if let Some(tab_model) = self.pane_model.panes.get_mut(pane) + && tab_model.contains_item(entity) + { + tab_model.activate(entity); + self.pane_model.set_focus(pane); + tasks.push(self.update(Message::TabActivate(entity))); + } + if let Some(token) = token { + tasks.push(Task::done(cosmic::Action::Cosmic( + cosmic::app::Action::Activate(token), + ))); + } + return Task::batch(tasks); + } + }, Message::ToggleContextPage(context_page) => { if self.context_page == context_page { self.core.window.show_context = !self.core.window.show_context; @@ -3839,12 +3983,14 @@ impl Application for App { } }, Message::WindowFocused => { + self.window_focused = true; if !self.core.window.show_context { self.pane_model.update_terminal_focus(); } return self.update_focus(); } Message::WindowUnfocused => { + self.window_focused = false; self.pane_model.unfocus_all_terminals(); } Message::ZoomIn => { @@ -4264,16 +4410,18 @@ impl Application for App { output.send(Message::TermEventTx(event_tx)).await.unwrap(); while let Some((pane, entity, event)) = event_rx.recv().await { - output - .send(Message::TermEvent(pane, entity, event)) - .await - .unwrap(); + let message = match event { + PtyEvent::Term(event) => Message::TermEvent(pane, entity, event), + PtyEvent::Osc(event) => Message::TermOsc(pane, entity, event), + }; + output.send(message).await.unwrap(); } panic!("terminal event channel closed"); }, ) }), + desktop_notify::subscription().map(Message::DesktopNotify), cosmic_config::config_subscription( TypeId::of::(), Self::APP_ID.into(), diff --git a/src/osc_tap.rs b/src/osc_tap.rs new file mode 100644 index 0000000..641fab1 --- /dev/null +++ b/src/osc_tap.rs @@ -0,0 +1,407 @@ +// SPDX-License-Identifier: GPL-3.0-only + +//! Séquences OSC que vte ignore, interceptées à la sortie du PTY (yoda). +//! +//! alacritty_terminal 0.25 / vte 0.15 jettent les OSC 9 et 777 (« unhandled +//! osc_dispatch ») : l'application ne les voit jamais. On les repère donc dans +//! les octets lus sur le PTY, juste avant que vte ne les analyse, en +//! enveloppant le `Pty` : ni alacritty_terminal ni vte ne sont forkés. +//! +//! Formes reconnues, comme dans Ghostty : +//! - `OSC 777 ; notify ; titre ; corps` (forme rxvt, canal « ghostty » de +//! Claude Code) ; +//! - `OSC 9 ; texte` (forme iTerm2), sauf les commandes ConEmu +//! `OSC 9 ; N [; …]` (barre de progression `9;4` comprise), qui ne sont pas +//! des notifications. + +/// Taille maximale retenue pour le contenu d'une séquence OSC ; au-delà, la +/// séquence est ignorée jusqu'à son terminateur. +const OSC_MAX_LEN: usize = 4096; +/// Longueur maximale (en caractères) du titre et du corps d'une notification. +const TITLE_MAX_CHARS: usize = 200; +const BODY_MAX_CHARS: usize = 1000; + +/// Événement extrait d'une séquence OSC. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum OscEvent { + /// Notification de bureau demandée par le programme (OSC 9 ou 777). + Notify { title: Option, body: String }, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum State { + #[default] + Ground, + Escape, + Osc, +} + +/// Automate minimal qui suit le flux d'octets du PTY et ne retient que le +/// contenu des séquences OSC. Les séquences peuvent être coupées entre deux +/// lectures : l'état est conservé d'un appel à l'autre. +#[derive(Debug, Default)] +pub struct OscScanner { + state: State, + buf: Vec, + overflow: bool, +} + +impl OscScanner { + pub fn feed(&mut self, bytes: &[u8], emit: &mut dyn FnMut(OscEvent)) { + for &byte in bytes { + match self.state { + State::Ground => { + if byte == 0x1b { + self.state = State::Escape; + } + } + State::Escape => { + self.state = match byte { + b']' => { + self.buf.clear(); + self.overflow = false; + State::Osc + } + 0x1b => State::Escape, + _ => State::Ground, + }; + } + State::Osc => match byte { + // BEL termine la séquence. + 0x07 => { + self.dispatch(emit); + self.state = State::Ground; + } + // ESC termine aussi la séquence, comme dans vte : c'est le + // début de ST (`ESC \`) ou d'une nouvelle séquence. + 0x1b => { + self.dispatch(emit); + self.state = State::Escape; + } + // CAN et SUB annulent la séquence. + 0x18 | 0x1a => self.state = State::Ground, + _ => { + if self.buf.len() < OSC_MAX_LEN { + self.buf.push(byte); + } else { + self.overflow = true; + } + } + }, + } + } + } + + fn dispatch(&mut self, emit: &mut dyn FnMut(OscEvent)) { + if !self.overflow + && let Some(event) = parse_osc(&self.buf) + { + emit(event); + } + self.buf.clear(); + self.overflow = false; + } +} + +/// Interprète le contenu d'une séquence OSC (entre `ESC ]` et le terminateur). +pub fn parse_osc(payload: &[u8]) -> Option { + if let Some(rest) = payload.strip_prefix(b"777;notify;") { + match rest.iter().position(|&b| b == b';') { + Some(i) => notify(&rest[..i], &rest[i + 1..]), + None => notify(b"", rest), + } + } else if let Some(rest) = payload.strip_prefix(b"9;") { + if is_conemu_command(rest) { + None + } else { + notify(b"", rest) + } + } else { + None + } +} + +/// `OSC 9 ; N` ou `OSC 9 ; N ; …` avec N numérique : commande ConEmu +/// (progression, titre d'onglet, répertoire…), pas une notification. +fn is_conemu_command(rest: &[u8]) -> bool { + let digits = rest.iter().take_while(|b| b.is_ascii_digit()).count(); + digits > 0 && matches!(rest.get(digits), None | Some(b';')) +} + +fn notify(title: &[u8], body: &[u8]) -> Option { + let title = clean(title, TITLE_MAX_CHARS); + let body = clean(body, BODY_MAX_CHARS); + match (title.is_empty(), body.is_empty()) { + (true, true) => None, + // Un titre seul devient le corps. + (false, true) => Some(OscEvent::Notify { + title: None, + body: title, + }), + (true, false) => Some(OscEvent::Notify { title: None, body }), + (false, false) => Some(OscEvent::Notify { + title: Some(title), + body, + }), + } +} + +/// UTF-8 tolérant, caractères de contrôle remplacés par des espaces, longueur +/// bornée. +fn clean(bytes: &[u8], max_chars: usize) -> String { + String::from_utf8_lossy(bytes) + .chars() + .map(|c| if c.is_control() { ' ' } else { c }) + .take(max_chars) + .collect::() + .trim() + .to_string() +} + +#[cfg(unix)] +pub use self::unix::OscTapPty; + +#[cfg(unix)] +mod unix { + use super::{OscEvent, OscScanner}; + use alacritty_terminal::{ + event::{OnResize, WindowSize}, + tty::{ChildEvent, EventedPty, EventedReadWrite, Pty}, + }; + use polling::{Event, PollMode, Poller}; + use std::{ + fs::File, + io::{self, Read}, + sync::Arc, + }; + + /// Lecteur du PTY qui passe chaque lecture au [`OscScanner`] avant de la + /// rendre à la boucle d'alacritty_terminal. + pub struct TapReader { + file: File, + scanner: OscScanner, + sink: Box, + } + + impl Read for TapReader { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + let len = self.file.read(buf)?; + self.scanner.feed(&buf[..len], &mut *self.sink); + Ok(len) + } + } + + /// `Pty` d'alacritty_terminal dont la lecture est observée par un + /// [`TapReader`]. Le lecteur lit un duplicata du descripteur maître (même + /// description de fichier, donc même mode non bloquant) ; l'enregistrement + /// auprès du `Poller`, l'écriture, le redimensionnement et la fin du + /// processus restent ceux du `Pty`. + pub struct OscTapPty { + // Déclaré avant `pty` : le duplicata est fermé avant le maître. + reader: TapReader, + pty: Pty, + } + + impl OscTapPty { + pub fn new(pty: Pty, sink: Box) -> io::Result { + let file = pty.file().try_clone()?; + Ok(Self { + reader: TapReader { + file, + scanner: OscScanner::default(), + sink, + }, + pty, + }) + } + } + + impl EventedReadWrite for OscTapPty { + type Reader = TapReader; + type Writer = File; + + unsafe fn register( + &mut self, + poll: &Arc, + interest: Event, + mode: PollMode, + ) -> io::Result<()> { + unsafe { self.pty.register(poll, interest, mode) } + } + + fn reregister( + &mut self, + poll: &Arc, + interest: Event, + mode: PollMode, + ) -> io::Result<()> { + self.pty.reregister(poll, interest, mode) + } + + fn deregister(&mut self, poll: &Arc) -> io::Result<()> { + self.pty.deregister(poll) + } + + fn reader(&mut self) -> &mut TapReader { + &mut self.reader + } + + fn writer(&mut self) -> &mut File { + self.pty.writer() + } + } + + impl EventedPty for OscTapPty { + fn next_child_event(&mut self) -> Option { + self.pty.next_child_event() + } + } + + impl OnResize for OscTapPty { + fn on_resize(&mut self, window_size: WindowSize) { + self.pty.on_resize(window_size); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scan(chunks: &[&[u8]]) -> Vec { + let mut scanner = OscScanner::default(); + let mut events = Vec::new(); + for chunk in chunks { + scanner.feed(chunk, &mut |event| events.push(event)); + } + events + } + + fn notify(title: Option<&str>, body: &str) -> OscEvent { + OscEvent::Notify { + title: title.map(str::to_string), + body: body.to_string(), + } + } + + #[test] + fn osc_777_with_bel_and_st() { + assert_eq!( + scan(&[b"x\x1b]777;notify;Claude Code;En attente\x07y"]), + [notify(Some("Claude Code"), "En attente")] + ); + assert_eq!( + scan(&[b"\x1b]777;notify;T;corps; avec point-virgule\x1b\\"]), + [notify(Some("T"), "corps; avec point-virgule")] + ); + } + + #[test] + fn osc_9_text_but_not_conemu() { + assert_eq!( + scan(&[b"\x1b]9;Claude Code: termine\x07"]), + [notify(None, "Claude Code: termine")] + ); + // Commandes ConEmu : progression, avec ou sans argument. + assert!(scan(&[b"\x1b]9;4;1;50\x07\x1b]9;4;0;\x07\x1b]9;12\x07"]).is_empty()); + // Un chiffre suivi d'autre chose qu'un `;` reste du texte. + assert_eq!( + scan(&[b"\x1b]9;3 tests en echec\x07"]), + [notify(None, "3 tests en echec")] + ); + } + + #[test] + fn sequence_split_across_reads() { + assert_eq!( + scan(&[b"\x1b", b"]777;no", b"tify;Ti", b"tre;Co", b"rps\x1b", b"\\"]), + [notify(Some("Titre"), "Corps")] + ); + } + + #[test] + fn other_osc_and_noise_are_ignored() { + assert!(scan(&[b"\x1b]0;titre\x07\x1b]8;;https://x\x1b\\lien\x1b]8;;\x1b\\"]).is_empty()); + assert!(scan(&[b"\x1b]777;notify;;\x07\x1b]9;\x07"]).is_empty()); + // CAN annule la séquence en cours. + assert!(scan(&[b"\x1b]9;annulee\x18\x07"]).is_empty()); + } + + #[test] + fn control_chars_cleaned_and_title_alone_becomes_body() { + assert_eq!( + scan(&[b"\x1b]777;notify;Seul\x07"]), + [notify(None, "Seul")] + ); + assert_eq!( + scan(&[b"\x1b]9;a\tb\x01c\x07"]), + [notify(None, "a b c")] + ); + } + + /// De bout en bout : un vrai processus écrit dans un vrai PTY, lu par la + /// boucle d'alacritty_terminal à travers l'enveloppe. + #[cfg(unix)] + #[test] + fn tap_sees_osc_written_by_child() { + use alacritty_terminal::{ + Term, + event::{Event, EventListener, WindowSize}, + event_loop::EventLoop, + sync::FairMutex, + term::{Config, test::TermSize}, + tty::{self, Options, Shell}, + }; + use std::{sync::Arc, sync::mpsc, time::Duration}; + + #[derive(Clone)] + struct Listener; + impl EventListener for Listener { + fn send_event(&self, _event: Event) {} + } + + let options = Options { + shell: Some(Shell::new( + "printf".into(), + vec!["avant\x1b]777;notify;Titre;Corps\x07apres".into()], + )), + drain_on_exit: true, + ..Default::default() + }; + let window_size = WindowSize { + num_lines: 24, + num_cols: 80, + cell_width: 8, + cell_height: 16, + }; + let pty = tty::new(&options, window_size, 0).unwrap(); + let (tx, rx) = mpsc::channel(); + let pty = OscTapPty::new( + pty, + Box::new(move |event| { + let _ = tx.send(event); + }), + ) + .unwrap(); + let term = Arc::new(FairMutex::new(Term::new( + Config::default(), + &TermSize::new(80, 24), + Listener, + ))); + let event_loop = EventLoop::new(term, Listener, pty, true, false).unwrap(); + let _join = event_loop.spawn(); + + assert_eq!( + rx.recv_timeout(Duration::from_secs(5)).unwrap(), + notify(Some("Titre"), "Corps") + ); + } + + #[test] + fn oversized_sequence_is_dropped() { + let mut long = b"\x1b]9;".to_vec(); + long.extend(std::iter::repeat_n(b'a', OSC_MAX_LEN + 10)); + long.extend(b"\x07\x1b]9;ensuite\x07"); + assert_eq!(scan(&[&long]), [notify(None, "ensuite")]); + } +} diff --git a/src/terminal.rs b/src/terminal.rs index 683ac9a..356e009 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -27,13 +27,15 @@ use indexmap::IndexSet; use std::{ borrow::Cow, collections::HashMap, - fs, io, mem, + fs, + hash::{DefaultHasher, Hash, Hasher}, + io, mem, path::PathBuf, sync::{ Arc, Mutex, Weak, atomic::{AtomicU32, Ordering}, }, - time::Instant, + time::{Duration, Instant}, }; use tokio::sync::mpsc; @@ -43,6 +45,7 @@ use crate::{ config::{ColorSchemeKind, Config as AppConfig, ProfileId}, menu::MenuState, mouse_reporter::MouseReporter, + osc_tap::OscEvent, }; /// Minimum contrast between a fixed cursor color and the cell's background. @@ -65,6 +68,28 @@ pub enum MonitorKind { /// Seconds without output before a silence alert fires (Konsole default). pub const MONITOR_SILENCE_SECONDS: u64 = 10; +/// Appel d'attention d'un onglet en arrière-plan (yoda). +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AttentionKind { + /// Cloche (BEL). + Bell, + /// Notification de bureau (OSC 9 / 777). + Notification, +} + +/// Anti-rafale des notifications d'un onglet, aux valeurs de Ghostty : au plus +/// une par seconde, et une notification identique au plus toutes les 5 s. +const NOTIFY_MIN_INTERVAL: Duration = Duration::from_secs(1); +const NOTIFY_SAME_INTERVAL: Duration = Duration::from_secs(5); + +/// Événement remonté d'un terminal : celui d'alacritty_terminal, ou une +/// séquence OSC interceptée à la sortie du PTY (voir [`crate::osc_tap`]). +#[derive(Debug)] +pub enum PtyEvent { + Term(Event), + Osc(OscEvent), +} + /// https://github.com/alacritty/alacritty/blob/4a7728bf7fac06a35f27f6c4f31e0d9214e5152b/alacritty/src/config/ui_config.rs#L36-L39 fn url_regex_search() -> RegexSearch { let url_regex = "(ipfs:|ipns:|magnet:|mailto:|gemini://|gopher://|https://|http://|news:|file:|git://|ssh:|ftp://)\ @@ -109,13 +134,13 @@ impl From for WindowSize { pub struct EventProxy( pane_grid::Pane, segmented_button::Entity, - mpsc::UnboundedSender<(pane_grid::Pane, segmented_button::Entity, Event)>, + mpsc::UnboundedSender<(pane_grid::Pane, segmented_button::Entity, PtyEvent)>, ); impl EventListener for EventProxy { fn send_event(&self, event: Event) { //TODO: handle error - let _ = self.2.send((self.0, self.1, event)); + let _ = self.2.send((self.0, self.1, PtyEvent::Term(event))); } } @@ -196,23 +221,47 @@ impl TerminalPaneGrid { self.focus } - pub fn update_terminal_focus(&self) { - for (pane, tab_model) in self.panes.panes.iter() { - let entity = tab_model.active(); - if let Some(terminal) = tab_model.data::>(entity) { - let mut terminal = terminal.lock().unwrap(); - terminal.set_focused(self.focus == *pane); - terminal.update(); + pub fn update_terminal_focus(&mut self) { + let focus = self.focus; + for (pane, tab_model) in self.panes.panes.iter_mut() { + let active = tab_model.active(); + let entities: Vec = tab_model.iter().collect(); + for entity in entities { + let mut icon_update = None; + if let Some(terminal) = tab_model.data::>(entity) { + let mut terminal = terminal.lock().unwrap(); + if entity == active { + let focused = focus == *pane; + terminal.set_focused(focused); + terminal.update(); + // Regarder l'onglet acquitte son appel d'attention + if focused && terminal.attention.take().is_some() { + icon_update = Some(crate::monitor_icon_name(&terminal)); + } + } else { + // Onglet en arrière-plan : il perd le focus (rapport + // 1004), sinon un agent qui s'y trouve croit encore + // qu'on le regarde. + terminal.set_focused(false); + } + } + if let Some(icon_name) = icon_update { + crate::monitor_update_tab_icon(tab_model, entity, icon_name); + } } } } pub fn unfocus_all_terminals(&self) { for tab_model in self.panes.panes.values() { - let entity = tab_model.active(); - if let Some(terminal) = tab_model.data::>(entity) { - let mut terminal = terminal.lock().unwrap(); - terminal.set_focused(false); - terminal.update(); + let active = tab_model.active(); + for entity in tab_model.iter() { + if let Some(terminal) = tab_model.data::>(entity) { + let mut terminal = terminal.lock().unwrap(); + terminal.set_focused(false); + if entity == active { + terminal.update(); + } + } } } } @@ -331,6 +380,8 @@ pub struct Terminal { pub monitor_alert: Option, pub monitor_last_output: Instant, pub monitor_job_running: bool, + pub attention: Option, + notify_last: Option<(Instant, u64)>, pub needs_update: bool, pub profile_id_opt: Option, pub tab_title_override: Option, @@ -361,7 +412,7 @@ impl Terminal { pub fn new( pane: pane_grid::Pane, entity: segmented_button::Entity, - event_tx: mpsc::UnboundedSender<(pane_grid::Pane, segmented_button::Entity, Event)>, + event_tx: mpsc::UnboundedSender<(pane_grid::Pane, segmented_button::Entity, PtyEvent)>, config: Config, options: Options, app_config: &AppConfig, @@ -413,6 +464,8 @@ impl Terminal { cell_width, cell_height, }; + #[cfg(unix)] + let osc_tx = event_tx.clone(); let event_proxy = EventProxy(pane, entity, event_tx); let term = Arc::new(FairMutex::new(Term::new( config, @@ -427,6 +480,15 @@ impl Terminal { #[cfg(windows)] let shell_pid = pty.child_watcher().pid().map(|pid| pid.get()); + // OSC 9 / 777 : interceptés avant vte, qui les ignore + #[cfg(unix)] + let pty = crate::osc_tap::OscTapPty::new( + pty, + Box::new(move |event| { + let _ = osc_tx.send((pane, entity, PtyEvent::Osc(event))); + }), + )?; + let pty_event_loop = EventLoop::new(term.clone(), event_proxy, pty, options.drain_on_exit, false)?; let notifier = Notifier(pty_event_loop.channel()); @@ -450,6 +512,8 @@ impl Terminal { monitor_alert: None, monitor_last_output: Instant::now(), monitor_job_running: false, + attention: None, + notify_last: None, mouse_reporter: Default::default(), needs_update: true, notifier, @@ -513,6 +577,25 @@ impl Terminal { self.zoom_adj = value; } + /// Anti-rafale des notifications de bureau de cet onglet ; `true` si + /// celle-ci peut partir. + pub fn notify_allowed(&mut self, title: Option<&str>, body: &str) -> bool { + let mut hasher = DefaultHasher::new(); + (title, body).hash(&mut hasher); + let hash = hasher.finish(); + let now = Instant::now(); + if let Some((last, last_hash)) = self.notify_last { + let elapsed = now.duration_since(last); + if elapsed < NOTIFY_MIN_INTERVAL + || (hash == last_hash && elapsed < NOTIFY_SAME_INTERVAL) + { + return false; + } + } + self.notify_last = Some((now, hash)); + true + } + fn set_focused(&mut self, is_focused: bool) { let focus_changed = self.is_focused != is_focused; self.is_focused = is_focused;