yoda: notifications de bureau OSC 9/777 et cloche utile, a la Ghostty

vte 0.15 jette les OSC 9 et 777 : Claude Code (canal « ghostty ») ou tout
programme qui veut signaler « j'attends » ne pouvait rien faire remonter,
et la cloche etait un TODO. Avec plusieurs sessions en onglets, rien ne
disait laquelle attendait une reponse.

- osc_tap : le Pty est enveloppe, son lecteur passe chaque lecture a un
  petit automate qui repere OSC 777;notify;titre;corps et OSC 9;texte
  (les commandes ConEmu 9;N, dont la progression 9;4, sont ecartees comme
  dans Ghostty). Ni alacritty_terminal ni vte ne sont forkes.
- desktop_notify : org.freedesktop.Notifications en direct (zbus), pour
  recuperer le jeton ActivationToken au clic : la notification ramene la
  fenetre au premier plan, sur le bon panneau et le bon onglet. Une
  notification par onglet au plus (replaces_id), anti-rafale de Ghostty
  (1/s, identique toutes les 5 s), titre suivi du nom de l'onglet.
- Rien n'est envoye pour l'onglet qu'on a sous les yeux. Sinon l'onglet
  porte une icone d'attention (cloche ou notification), eteinte quand il
  reprend le focus.
- Cloche : indicateur d'onglet seulement. Pas de demande d'attention
  xdg-activation : cosmic-comp donne un jeton valide a toute application
  non sandboxee, la fenetre volerait le focus.
- Focus 1004 : l'onglet quitte recoit enfin son focus-out (seul l'onglet
  actif de chaque panneau etait mis a jour) ; un agent en arriere-plan ne
  croit plus qu'on le regarde.
- Chevrons du corps remplaces par ‹ › : cosmic-notifications lit le corps
  en HTML sans decoder les entites.

7 tests, dont un de bout en bout dans un vrai PTY.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lionel DARNIS 2026-10-01 13:12:28 +02:00
parent 81dc7d3eeb
commit 2414aaece3
8 changed files with 859 additions and 25 deletions

2
Cargo.lock generated
View file

@ -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]]

View file

@ -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 }

View file

@ -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

View file

@ -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

185
src/desktop_notify.rs Normal file
View file

@ -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<Request>),
/// L'utilisateur a cliqué la notification de cet onglet.
Activated { tab: TabKey, token: Option<String> },
}
#[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<u32>;
#[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<Event> {
Subscription::run_with(TypeId::of::<DesktopNotifySubscription>(), |_| {
stream::channel(16, |mut output: Sender<Event>| 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<Request>,
mut output: Sender<Event>,
) -> 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<u32, TabKey> = HashMap::new();
let mut id_by_tab: HashMap<TabKey, u32> = HashMap::new();
let mut pending_tokens: HashMap<u32, String> = 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 `&lt;` serait conforme à la spécification, mais cosmic-notifications ne
/// décode pas les entités (il afficherait `&lt;`), 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('>', "›")
}

View file

@ -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<mpsc::UnboundedSender<(pane_grid::Pane, segmented_button::Entity, TermEvent)>>,
Option<mpsc::UnboundedSender<(pane_grid::Pane, segmented_button::Entity, PtyEvent)>>,
notify_tx_opt: Option<mpsc::UnboundedSender<desktop_notify::Request>>,
window_focused: bool,
startup_options: Option<tty::Options>,
startup_profile_id: Option<ProfileId>,
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::<Mutex<Terminal>>(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<String> {
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::<Mutex<Terminal>>(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::<ConfigSubscription>(),
Self::APP_ID.into(),

407
src/osc_tap.rs Normal file
View file

@ -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<String>, 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<u8>,
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<OscEvent> {
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<OscEvent> {
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::<String>()
.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<dyn FnMut(OscEvent) + Send>,
}
impl Read for TapReader {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
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<dyn FnMut(OscEvent) + Send>) -> io::Result<Self> {
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<Poller>,
interest: Event,
mode: PollMode,
) -> io::Result<()> {
unsafe { self.pty.register(poll, interest, mode) }
}
fn reregister(
&mut self,
poll: &Arc<Poller>,
interest: Event,
mode: PollMode,
) -> io::Result<()> {
self.pty.reregister(poll, interest, mode)
}
fn deregister(&mut self, poll: &Arc<Poller>) -> 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<ChildEvent> {
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<OscEvent> {
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")]);
}
}

View file

@ -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<Size> 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::<Mutex<Terminal>>(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<segmented_button::Entity> = tab_model.iter().collect();
for entity in entities {
let mut icon_update = None;
if let Some(terminal) = tab_model.data::<Mutex<Terminal>>(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::<Mutex<Terminal>>(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::<Mutex<Terminal>>(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<MonitorKind>,
pub monitor_last_output: Instant,
pub monitor_job_running: bool,
pub attention: Option<AttentionKind>,
notify_last: Option<(Instant, u64)>,
pub needs_update: bool,
pub profile_id_opt: Option<ProfileId>,
pub tab_title_override: Option<String>,
@ -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;