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:
parent
81dc7d3eeb
commit
2414aaece3
8 changed files with 859 additions and 25 deletions
164
src/main.rs
164
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<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(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue