From c21d6bb2cd3080e96778c7e4a88873b000102f68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Ram=C3=ADrez=20Norambuena?= Date: Mon, 29 Jun 2026 18:54:59 -0400 Subject: [PATCH 1/2] feat(terminal,renderer,config): add OSC 133 shell integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OSC 133 A/B/C/D sequences track shell prompt/command state on the grid. The status bar shows a › indicator (theme.palette[6]) when the shell is waiting for input (state B) and a ✗ N badge (theme.palette[1]) when the last command exited non-zero; the badge clears when a new prompt starts (A). The left status cluster now lays out REC / bell / prompt / exit-code indicators with a running x cursor so they no longer overlap. Gated behind general.shell_integration (default true) and exposed in the TUI config panel as F_SHELL_INTEGRATION = 39. --- CHANGELOG.md | 1 + src/config/config_test.rs | 1 + src/config/mod.rs | 3 ++ src/config/tui_config.rs | 14 ++++++++ src/config/tui_config_test.rs | 30 +++++++++++----- src/renderer/render_ops.rs | 15 ++++++-- src/renderer/text.rs | 68 +++++++++++++++++++++++++++++------ src/renderer/text_test.rs | 66 +++++++++++++++++++++++++++++++++- src/terminal/grid.rs | 19 ++++++++++ src/terminal/grid_test.rs | 17 +++++++++ src/terminal/parser.rs | 26 +++++++++++++- src/terminal/parser_test.rs | 49 +++++++++++++++++++++++++ 12 files changed, 285 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4abdeff..a2d5f2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added - activity indicator: tabs with output while not focused now show the activity dot in the tab bar; the marker clears as soon as the tab is focused +- OSC 133 shell integration with prompt and exit-code status-bar indicators, gated by `general.shell_integration` ### Fixed - scroll the tab bar to keep the active tab visible when many tabs are open; previously tabs overflowed the window width and the header rendered garbled diff --git a/src/config/config_test.rs b/src/config/config_test.rs index 2315511..00b761f 100644 --- a/src/config/config_test.rs +++ b/src/config/config_test.rs @@ -280,4 +280,5 @@ fn general_update_defaults() { let cfg = Config::default(); assert!(cfg.general.auto_update_check); // daily check on by default assert!(!cfg.general.auto_update_install); // silent self-replace opt-in (off) + assert!(cfg.general.shell_integration); // OSC 133 prompt/exit badges on by default } diff --git a/src/config/mod.rs b/src/config/mod.rs index 93b2945..533e189 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -61,6 +61,8 @@ pub struct GeneralConfig { pub auto_update_check: bool, #[serde(default)] pub auto_update_install: bool, + #[serde(default = "default_true")] + pub shell_integration: bool, } impl Default for GeneralConfig { @@ -71,6 +73,7 @@ impl Default for GeneralConfig { visual_bell: false, auto_update_check: default_true(), auto_update_install: false, + shell_integration: default_true(), } } } diff --git a/src/config/tui_config.rs b/src/config/tui_config.rs index 335c45f..7e2d166 100644 --- a/src/config/tui_config.rs +++ b/src/config/tui_config.rs @@ -31,6 +31,7 @@ const F_PALETTE: usize = 20; // F_PALETTE + 0..15 const F_STATUS_BAR_RIGHT: usize = 36; const F_AUTO_UPDATE_CHECK: usize = 37; const F_AUTO_UPDATE_INSTALL: usize = 38; +const F_SHELL_INTEGRATION: usize = 39; const PALETTE_LABELS: [&str; 16] = [ "Palette 0 black", @@ -285,6 +286,14 @@ impl ConfigPanel { section: None, }); + fields.push(Field { + label: "Shell Integration", + hint: "OSC 133: show prompt and exit-code badges in the status bar", + value: cfg.general.shell_integration.to_string(), + kind: FieldKind::Bool, + section: None, + }); + let mut collapsed = HashSet::new(); collapsed.insert("Palette"); @@ -653,6 +662,10 @@ impl ConfigPanel { .parse::() .map_err(|_| "Invalid auto_update_install — use true or false")?; + let shell_integration = get(F_SHELL_INTEGRATION) + .parse::() + .map_err(|_| "Invalid shell_integration — use true or false")?; + Ok(Config { font: FontConfig { family, size }, window: WindowConfig { @@ -683,6 +696,7 @@ impl ConfigPanel { visual_bell, auto_update_check, auto_update_install, + shell_integration, }, }) } diff --git a/src/config/tui_config_test.rs b/src/config/tui_config_test.rs index a85bc7d..d81cc47 100644 --- a/src/config/tui_config_test.rs +++ b/src/config/tui_config_test.rs @@ -10,8 +10,8 @@ fn make_panel() -> ConfigPanel { #[test] fn from_config_has_correct_field_count() { let panel = make_panel(); - // 9 base + 1 scrollback + 2 logging + 1 theme + 4 colors + 16 palette + 1 status_bar + 3 general + 2 updates = 39 - assert_eq!(panel.fields.len(), 39); + // 9 base + 1 scrollback + 2 logging + 1 theme + 4 colors + 16 palette + 1 status_bar + 3 general + 2 updates + 1 shell = 40 + assert_eq!(panel.fields.len(), 40); } #[test] @@ -289,6 +289,18 @@ fn build_config_roundtrip_preserves_font_size() { } } +#[test] +fn build_config_roundtrip_toggles_shell_integration() { + let mut panel = make_panel(); + assert_eq!(panel.fields[F_SHELL_INTEGRATION].value, "true"); + panel.fields[F_SHELL_INTEGRATION].value = "false".to_string(); + if let ConfigAction::Save(cfg) = panel.save() { + assert!(!cfg.general.shell_integration); + } else { + panic!("expected Save action"); + } +} + #[test] fn build_config_shell_empty_becomes_none() { let mut panel = make_panel(); @@ -536,8 +548,8 @@ fn palette_collapsed_by_default() { #[test] fn visible_indices_hides_palette_body() { let panel = make_panel(); - // 39 total - 15 palette body fields = 24 visible - assert_eq!(panel.visible_indices().len(), 24); + // 40 total - 15 palette body fields = 25 visible + assert_eq!(panel.visible_indices().len(), 25); } #[test] @@ -546,7 +558,7 @@ fn toggle_on_palette_header_expands() { panel.selected = F_PALETTE; panel.toggle_collapse(); assert!(!panel.collapsed.contains("Palette")); - assert_eq!(panel.visible_indices().len(), 39); + assert_eq!(panel.visible_indices().len(), 40); } #[test] @@ -556,7 +568,7 @@ fn toggle_twice_restores_collapsed() { panel.toggle_collapse(); panel.toggle_collapse(); assert!(panel.collapsed.contains("Palette")); - assert_eq!(panel.visible_indices().len(), 24); + assert_eq!(panel.visible_indices().len(), 25); } #[test] @@ -611,10 +623,10 @@ fn move_up_skips_collapsed_palette() { #[test] fn move_down_at_last_visible_clamps() { let mut panel = make_panel(); - // F_AUTO_UPDATE_INSTALL is the last field and is always visible - panel.selected = F_AUTO_UPDATE_INSTALL; + // F_SHELL_INTEGRATION is the last field and is always visible + panel.selected = F_SHELL_INTEGRATION; panel.handle_down(); - assert_eq!(panel.selected, F_AUTO_UPDATE_INSTALL); + assert_eq!(panel.selected, F_SHELL_INTEGRATION); } #[test] diff --git a/src/renderer/render_ops.rs b/src/renderer/render_ops.rs index 8a9c7e3..ded6ec4 100644 --- a/src/renderer/render_ops.rs +++ b/src/renderer/render_ops.rs @@ -142,16 +142,23 @@ impl App { // log_file.lock() must not be held while grid.read() is held: // the parser thread acquires log_file.lock first, then grid.write; // holding grid.read + waiting for log_file can stall both threads. - let (cwd_raw, pane_osc_title_raw) = self.state.tabs[self.state.active_tab] + let (cwd_raw, pane_osc_title_raw, shell_state, last_exit_code) = self.state.tabs + [self.state.active_tab] .panes .get(&active_id) .map(|e| { let g = e.pane.grid.read().unwrap(); let cwd = g.cwd.clone().map(|p| statusbar::shorten_home(&p, &home)); let osc_title = g.osc_title.clone(); - (cwd, osc_title) + (cwd, osc_title, g.shell_state, g.last_exit_code) }) - .unwrap_or((None, None)); + .unwrap_or((None, None, crate::terminal::grid::ShellState::Unknown, None)); + // Shell integration UI is gated: when disabled, suppress the indicators. + let (shell_state, last_exit_code) = if self.state.config.general.shell_integration { + (shell_state, last_exit_code) + } else { + (crate::terminal::grid::ShellState::Unknown, None) + }; let is_logging = self.state.tabs[self.state.active_tab] .panes .get(&active_id) @@ -219,6 +226,8 @@ impl App { bell_flash_intensity, self.state.config.general.visual_bell, is_logging, + shell_state, + last_exit_code, &self.state.theme, update_badge.as_ref(), ); diff --git a/src/renderer/text.rs b/src/renderer/text.rs index 4cbfb26..acf5825 100644 --- a/src/renderer/text.rs +++ b/src/renderer/text.rs @@ -3,7 +3,7 @@ pub(super) use super::draw_fns::*; use super::glyph::GlyphCache; use crate::dpi::Physical; use crate::input::InputMode; -use crate::terminal::grid::{Cell, CursorShape}; +use crate::terminal::grid::{Cell, CursorShape, ShellState}; use crate::terminal::sixel::SixelImage; use crate::terminal::{Color, Grid}; use crate::theme::ResolvedTheme; @@ -171,6 +171,8 @@ impl Renderer { bell_flash_intensity: Option, visual_bell: bool, is_logging: bool, + shell_state: ShellState, + last_exit_code: Option, theme: &ResolvedTheme, update_badge: Option<&UpdateBadge>, ) { @@ -223,6 +225,8 @@ impl Renderer { pane_title, bell_flash_intensity.is_some(), is_logging, + shell_state, + last_exit_code, theme, update_badge, ); @@ -715,6 +719,8 @@ impl Renderer { pane_title: Option<&str>, bell_active: bool, is_logging: bool, + shell_state: ShellState, + last_exit_code: Option, theme: &ResolvedTheme, update_badge: Option<&UpdateBadge>, ) { @@ -768,34 +774,76 @@ impl Renderer { color_u32(theme.palette[15]), ); + // Left-cluster secondary indicators — laid out with a running x cursor so + // REC / bell / prompt / exit-code badges never overlap. + let mut left_x = badge_x + badge_w + 8; + // Show ● REC badge when session logging is active. if is_logging { let rec_label = "\u{25cf} REC"; let rec_w = rec_label.len() as u32 * char_w + badge_pad * 2; let rec_color = color_u32(theme.palette[1]); // red/pink - let rec_x = badge_x + badge_w + 8; self.draw_status_badge( - buf, width, height, rec_x, badge_y, rec_w, badge_h, rec_label, rec_color, badge_fg, - char_w, px, badge_pad, + buf, width, height, left_x, badge_y, rec_w, badge_h, rec_label, rec_color, + badge_fg, char_w, px, badge_pad, ); + left_x += rec_w + 4; } // Show "●" bell indicator when BEL was recently received. if bell_active { - let dot = "\u{25cf}"; - let dot_x = badge_x + badge_w + 4; - let dot_y = badge_y + 2; self.draw_str( buf, width, height, - dot_x, - dot_y, - dot, + left_x, + badge_y + 2, + "\u{25cf}", px, false, color_u32(theme.palette[3]), ); + left_x += char_w + 6; + } + + // Show "›" prompt indicator (OSC 133 B — shell waiting for input). + if shell_state == ShellState::Prompt { + self.draw_str( + buf, + width, + height, + left_x, + badge_y + 2, + "\u{203a}", + px, + false, + color_u32(theme.palette[6]), + ); + left_x += char_w + 6; + } + + // Show "✗ N" badge when the last command (OSC 133 D) exited non-zero. + if let Some(code) = last_exit_code + && code != 0 + { + let label = format!("\u{2717} {code}"); + let ecode_w = label.chars().count() as u32 * char_w + badge_pad * 2; + let ecode_color = color_u32(theme.palette[1]); + self.draw_status_badge( + buf, + width, + height, + left_x, + badge_y, + ecode_w, + badge_h, + &label, + ecode_color, + badge_fg, + char_w, + px, + badge_pad, + ); } // Show pane OSC title centered in the status bar (suppressed during search). diff --git a/src/renderer/text_test.rs b/src/renderer/text_test.rs index c4c5b62..020b3a2 100644 --- a/src/renderer/text_test.rs +++ b/src/renderer/text_test.rs @@ -6,7 +6,7 @@ use crate::config::Config; use crate::config::tui_config::ConfigPanel; use crate::dpi::Physical; use crate::terminal::Grid; -use crate::terminal::grid::{Color, CursorShape, GridColors}; +use crate::terminal::grid::{Color, CursorShape, GridColors, ShellState}; use crate::theme::default_theme; // ── Task 20 proxy tests — pure arithmetic, no rendering ────────────────────── @@ -186,6 +186,8 @@ fn draw_empty_buffer_does_not_panic() { None, false, false, + ShellState::Unknown, + None, &theme, None, // update_badge: wired in Task 9 ); @@ -229,6 +231,8 @@ fn draw_pane_fills_background_color() { None, false, false, + ShellState::Unknown, + None, &theme, None, // update_badge: wired in Task 9 ); @@ -260,6 +264,8 @@ fn draw_tab_bar_renders_without_panic() { None, false, false, + ShellState::Unknown, + None, &theme, None, // update_badge: wired in Task 9 ); @@ -290,11 +296,45 @@ fn draw_status_bar_renders_without_panic() { None, false, true, + ShellState::Unknown, + None, &theme, None, // update_badge: wired in Task 9 ); } +#[test] +fn draw_status_bar_shell_indicators_render_without_panic() { + let mut r = make_renderer(); + let mut buf = vec![0u32; 800 * 600]; + let theme = default_theme(); + // Prompt state draws the "›" glyph; a non-zero exit code draws the "✗ N" badge. + r.draw( + &mut buf, + 800, + 600, + &[], + &[], + &InputMode::Insert, + false, + &[], + 0, + 0, + None, + None, + 0.55, + None, + false, + false, + ShellState::Prompt, + Some(1), + &theme, + None, + ); + // Something was drawn in the status-bar band (bottom rows). + assert!(buf.iter().any(|&p| p != 0)); +} + #[test] fn draw_status_bar_pane_title_centered() { let mut r = make_renderer(); @@ -319,6 +359,8 @@ fn draw_status_bar_pane_title_centered() { None, false, false, + ShellState::Unknown, + None, &theme, None, // update_badge: wired in Task 9 ); @@ -341,6 +383,8 @@ fn draw_status_bar_pane_title_centered() { None, false, false, + ShellState::Unknown, + None, &theme, None, // update_badge: wired in Task 9 ); @@ -378,6 +422,8 @@ fn draw_status_bar_pane_title_suppressed_in_search() { None, false, false, + ShellState::Unknown, + None, &theme, None, // update_badge: wired in Task 9 ); @@ -403,6 +449,8 @@ fn draw_status_bar_pane_title_suppressed_in_search() { None, false, false, + ShellState::Unknown, + None, &theme, None, // update_badge: wired in Task 9 ); @@ -462,6 +510,8 @@ fn draw_with_bell_flash_does_not_panic() { Some(1.0), false, false, + ShellState::Unknown, + None, &theme, None, // update_badge: wired in Task 9 ); @@ -490,6 +540,8 @@ fn draw_with_separator_does_not_panic() { None, false, false, + ShellState::Unknown, + None, &theme, None, // update_badge: wired in Task 9 ); @@ -587,6 +639,8 @@ fn do_draw(r: &mut Renderer, panes: &[PaneView<'_>], mode: &InputMode) { None, false, false, + ShellState::Unknown, + None, &theme, None, // update_badge: wired in Task 9 ); @@ -755,6 +809,8 @@ fn draw_pane_osc8_link_paints_underline_without_hover() { None, false, false, + ShellState::Unknown, + None, &theme, None, // update_badge: wired in Task 9 ); @@ -847,6 +903,8 @@ fn draw_pane_reverse_video_swaps_background_to_fg_color() { None, false, false, + ShellState::Unknown, + None, &theme, None, // update_badge: wired in Task 9 ); @@ -1074,6 +1132,8 @@ fn draw_status_bar_search_empty_query_shows_slash() { None, false, false, + ShellState::Unknown, + None, &theme, None, // update_badge: wired in Task 9 ); @@ -1105,6 +1165,8 @@ fn draw_status_bar_search_no_matches_shows_label() { None, false, false, + ShellState::Unknown, + None, &theme, None, // update_badge: wired in Task 9 ); @@ -1281,6 +1343,8 @@ fn pane_padding_leaves_top_left_corner_as_background() { None, false, false, + ShellState::Unknown, + None, &theme, None, // update_badge: wired in Task 9 ); diff --git a/src/terminal/grid.rs b/src/terminal/grid.rs index f6eca4c..e567405 100644 --- a/src/terminal/grid.rs +++ b/src/terminal/grid.rs @@ -15,6 +15,17 @@ pub enum CursorShape { Beam, } +/// OSC 133 shell integration state. +#[derive(Clone, Copy, Debug, PartialEq, Default)] +pub enum ShellState { + #[default] + Unknown, // no OSC 133 received yet + PromptStart, // A — prompt is being drawn + Prompt, // B — shell is at prompt, waiting for input + Running, // C — command submitted, output flowing + Finished, // D — command finished +} + #[derive(Clone, Copy, Debug, PartialEq)] pub struct Color { pub r: u8, @@ -216,6 +227,10 @@ pub struct Grid { // Sixel images anchored to live-grid cell coordinates; cleared on clear_screen // and alternate-screen transitions. pub images: Vec, + // OSC 133 shell integration state machine + pub shell_state: ShellState, + // Last command exit code from OSC 133 ; D ; + pub last_exit_code: Option, } impl Grid { @@ -279,6 +294,8 @@ impl Grid { focus_report: false, autowrap: true, images: Vec::new(), + shell_state: ShellState::Unknown, + last_exit_code: None, } } @@ -1018,6 +1035,8 @@ impl Grid { self.application_cursor_keys = false; self.charset_drawing = false; self.focus_report = false; + self.shell_state = ShellState::Unknown; + self.last_exit_code = None; } } diff --git a/src/terminal/grid_test.rs b/src/terminal/grid_test.rs index d6c6081..40caf5d 100644 --- a/src/terminal/grid_test.rs +++ b/src/terminal/grid_test.rs @@ -1207,3 +1207,20 @@ fn restore_screen_overwrites_existing_live_content() { assert_eq!(live_row_text(&g, 0), "restored"); assert_eq!(g.cursor_row, 1); } + +#[test] +fn shell_state_defaults_to_unknown() { + let g = make_grid(10, 5); + assert_eq!(g.shell_state, ShellState::Unknown); + assert_eq!(g.last_exit_code, None); +} + +#[test] +fn reset_clears_shell_integration_state() { + let mut g = make_grid(10, 5); + g.shell_state = ShellState::Finished; + g.last_exit_code = Some(1); + g.reset(); + assert_eq!(g.shell_state, ShellState::Unknown); + assert_eq!(g.last_exit_code, None); +} diff --git a/src/terminal/parser.rs b/src/terminal/parser.rs index 8d97c3d..8790e3c 100644 --- a/src/terminal/parser.rs +++ b/src/terminal/parser.rs @@ -1,4 +1,4 @@ -use super::grid::{Color, CursorShape, Grid}; +use super::grid::{Color, CursorShape, Grid, ShellState}; fn param_or_one(p: u16) -> usize { p.max(1) as usize @@ -322,6 +322,7 @@ impl Perform for Performer<'_> { osc_set_cwd(self.grid, params); osc_set_hyperlink(self.grid, params); osc_clipboard(self.grid, params); + osc_shell_integration(self.grid, params); } fn hook(&mut self, _params: &Params, intermediates: &[u8], _ignore: bool, action: char) { // Sixel graphics: DCS P...q (final char = 'q', no intermediates) @@ -470,6 +471,29 @@ fn osc_clipboard(grid: &mut Grid, params: &[&[u8]]) { } } +fn osc_shell_integration(grid: &mut Grid, params: &[&[u8]]) { + if params.len() < 2 || params[0] != b"133" { + return; + } + match params[1] { + b"A" => { + grid.last_exit_code = None; + grid.shell_state = ShellState::PromptStart; + } + b"B" => grid.shell_state = ShellState::Prompt, + b"C" => grid.shell_state = ShellState::Running, + b"D" => { + let code = params + .get(2) + .and_then(|p| std::str::from_utf8(p).ok()) + .and_then(|s| s.parse::().ok()); + grid.shell_state = ShellState::Finished; + grid.last_exit_code = code; + } + _ => {} + } +} + fn parse_osc7_uri(uri: &str) -> Option { let rest = uri.strip_prefix("file://")?; if rest.starts_with('/') { diff --git a/src/terminal/parser_test.rs b/src/terminal/parser_test.rs index f623b1e..7ab0bde 100644 --- a/src/terminal/parser_test.rs +++ b/src/terminal/parser_test.rs @@ -1221,3 +1221,52 @@ fn param_or_one_nonzero_returns_value() { assert_eq!(super::param_or_one(5), 5); assert_eq!(super::param_or_one(100), 100); } + +#[test] +fn osc133_prompt_states_track_shell_state() { + use super::super::grid::ShellState; + let mut p = make_parser(80, 24); + + p.process(b"\x1b]133;A\x07"); + assert_eq!(p.grid.shell_state, ShellState::PromptStart); + + p.process(b"\x1b]133;B\x07"); + assert_eq!(p.grid.shell_state, ShellState::Prompt); + + p.process(b"\x1b]133;C\x07"); + assert_eq!(p.grid.shell_state, ShellState::Running); +} + +#[test] +fn osc133_d_with_code_sets_exit_code() { + use super::super::grid::ShellState; + let mut p = make_parser(80, 24); + p.process(b"\x1b]133;D;1\x07"); + assert_eq!(p.grid.shell_state, ShellState::Finished); + assert_eq!(p.grid.last_exit_code, Some(1)); +} + +#[test] +fn osc133_d_without_code_clears_exit_code() { + let mut p = make_parser(80, 24); + p.process(b"\x1b]133;D\x07"); + assert_eq!(p.grid.last_exit_code, None); +} + +#[test] +fn osc133_a_clears_previous_exit_code() { + let mut p = make_parser(80, 24); + p.process(b"\x1b]133;D;127\x07"); + assert_eq!(p.grid.last_exit_code, Some(127)); + // A new prompt resets the exit-code badge. + p.process(b"\x1b]133;A\x07"); + assert_eq!(p.grid.last_exit_code, None); +} + +#[test] +fn osc133_unknown_subcommand_is_ignored() { + use super::super::grid::ShellState; + let mut p = make_parser(80, 24); + p.process(b"\x1b]133;Z\x07"); + assert_eq!(p.grid.shell_state, ShellState::Unknown); +} From da2015be12e7fd1427474c83f49d3cec6eb2e266 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Ram=C3=ADrez=20Norambuena?= Date: Mon, 29 Jun 2026 19:00:44 -0400 Subject: [PATCH 2/2] feat(terminal,config): add OSC 777 desktop notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OSC 777 notify;title;body requests a desktop notification. The parser stamps it onto the grid (pending_notification); the parser thread drains it under the write lock and emits a new ParseEffect::Notification, which drain_effects dispatches on the main thread via notify-send (Linux) / osascript (macOS) from a detached thread. Gated behind its own general.desktop_notifications flag (default true, F_DESKTOP_NOTIFICATIONS = 40) — independent of shell_integration, so prompt markers and desktop popups can be toggled separately. --- CHANGELOG.md | 1 + src/config/config_test.rs | 1 + src/config/mod.rs | 3 ++ src/config/tui_config.rs | 13 +++++++++ src/config/tui_config_test.rs | 30 +++++++++++++------ src/drain.rs | 54 +++++++++++++++++++++++++++++++++-- src/drain_test.rs | 14 +++++++++ src/terminal/grid.rs | 4 +++ src/terminal/parser.rs | 9 ++++++ src/terminal/parser_test.rs | 17 +++++++++++ 10 files changed, 135 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a2d5f2e..13d2349 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added - activity indicator: tabs with output while not focused now show the activity dot in the tab bar; the marker clears as soon as the tab is focused - OSC 133 shell integration with prompt and exit-code status-bar indicators, gated by `general.shell_integration` +- OSC 777 desktop notifications gated by `general.desktop_notifications` ### Fixed - scroll the tab bar to keep the active tab visible when many tabs are open; previously tabs overflowed the window width and the header rendered garbled diff --git a/src/config/config_test.rs b/src/config/config_test.rs index 00b761f..4bd6877 100644 --- a/src/config/config_test.rs +++ b/src/config/config_test.rs @@ -281,4 +281,5 @@ fn general_update_defaults() { assert!(cfg.general.auto_update_check); // daily check on by default assert!(!cfg.general.auto_update_install); // silent self-replace opt-in (off) assert!(cfg.general.shell_integration); // OSC 133 prompt/exit badges on by default + assert!(cfg.general.desktop_notifications); // OSC 777 desktop notifications on by default } diff --git a/src/config/mod.rs b/src/config/mod.rs index 533e189..061393d 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -63,6 +63,8 @@ pub struct GeneralConfig { pub auto_update_install: bool, #[serde(default = "default_true")] pub shell_integration: bool, + #[serde(default = "default_true")] + pub desktop_notifications: bool, } impl Default for GeneralConfig { @@ -74,6 +76,7 @@ impl Default for GeneralConfig { auto_update_check: default_true(), auto_update_install: false, shell_integration: default_true(), + desktop_notifications: default_true(), } } } diff --git a/src/config/tui_config.rs b/src/config/tui_config.rs index 7e2d166..6060b52 100644 --- a/src/config/tui_config.rs +++ b/src/config/tui_config.rs @@ -32,6 +32,7 @@ const F_STATUS_BAR_RIGHT: usize = 36; const F_AUTO_UPDATE_CHECK: usize = 37; const F_AUTO_UPDATE_INSTALL: usize = 38; const F_SHELL_INTEGRATION: usize = 39; +const F_DESKTOP_NOTIFICATIONS: usize = 40; const PALETTE_LABELS: [&str; 16] = [ "Palette 0 black", @@ -293,6 +294,13 @@ impl ConfigPanel { kind: FieldKind::Bool, section: None, }); + fields.push(Field { + label: "Desktop Notifications", + hint: "OSC 777: show desktop notifications requested by programs", + value: cfg.general.desktop_notifications.to_string(), + kind: FieldKind::Bool, + section: None, + }); let mut collapsed = HashSet::new(); collapsed.insert("Palette"); @@ -666,6 +674,10 @@ impl ConfigPanel { .parse::() .map_err(|_| "Invalid shell_integration — use true or false")?; + let desktop_notifications = get(F_DESKTOP_NOTIFICATIONS) + .parse::() + .map_err(|_| "Invalid desktop_notifications — use true or false")?; + Ok(Config { font: FontConfig { family, size }, window: WindowConfig { @@ -697,6 +709,7 @@ impl ConfigPanel { auto_update_check, auto_update_install, shell_integration, + desktop_notifications, }, }) } diff --git a/src/config/tui_config_test.rs b/src/config/tui_config_test.rs index d81cc47..679914b 100644 --- a/src/config/tui_config_test.rs +++ b/src/config/tui_config_test.rs @@ -10,8 +10,8 @@ fn make_panel() -> ConfigPanel { #[test] fn from_config_has_correct_field_count() { let panel = make_panel(); - // 9 base + 1 scrollback + 2 logging + 1 theme + 4 colors + 16 palette + 1 status_bar + 3 general + 2 updates + 1 shell = 40 - assert_eq!(panel.fields.len(), 40); + // 9 base + 1 scrollback + 2 logging + 1 theme + 4 colors + 16 palette + 1 status_bar + 3 general + 2 updates + 2 shell/notify = 41 + assert_eq!(panel.fields.len(), 41); } #[test] @@ -301,6 +301,18 @@ fn build_config_roundtrip_toggles_shell_integration() { } } +#[test] +fn build_config_roundtrip_toggles_desktop_notifications() { + let mut panel = make_panel(); + assert_eq!(panel.fields[F_DESKTOP_NOTIFICATIONS].value, "true"); + panel.fields[F_DESKTOP_NOTIFICATIONS].value = "false".to_string(); + if let ConfigAction::Save(cfg) = panel.save() { + assert!(!cfg.general.desktop_notifications); + } else { + panic!("expected Save action"); + } +} + #[test] fn build_config_shell_empty_becomes_none() { let mut panel = make_panel(); @@ -548,8 +560,8 @@ fn palette_collapsed_by_default() { #[test] fn visible_indices_hides_palette_body() { let panel = make_panel(); - // 40 total - 15 palette body fields = 25 visible - assert_eq!(panel.visible_indices().len(), 25); + // 41 total - 15 palette body fields = 26 visible + assert_eq!(panel.visible_indices().len(), 26); } #[test] @@ -558,7 +570,7 @@ fn toggle_on_palette_header_expands() { panel.selected = F_PALETTE; panel.toggle_collapse(); assert!(!panel.collapsed.contains("Palette")); - assert_eq!(panel.visible_indices().len(), 40); + assert_eq!(panel.visible_indices().len(), 41); } #[test] @@ -568,7 +580,7 @@ fn toggle_twice_restores_collapsed() { panel.toggle_collapse(); panel.toggle_collapse(); assert!(panel.collapsed.contains("Palette")); - assert_eq!(panel.visible_indices().len(), 25); + assert_eq!(panel.visible_indices().len(), 26); } #[test] @@ -623,10 +635,10 @@ fn move_up_skips_collapsed_palette() { #[test] fn move_down_at_last_visible_clamps() { let mut panel = make_panel(); - // F_SHELL_INTEGRATION is the last field and is always visible - panel.selected = F_SHELL_INTEGRATION; + // F_DESKTOP_NOTIFICATIONS is the last field and is always visible + panel.selected = F_DESKTOP_NOTIFICATIONS; panel.handle_down(); - assert_eq!(panel.selected, F_SHELL_INTEGRATION); + assert_eq!(panel.selected, F_DESKTOP_NOTIFICATIONS); } #[test] diff --git a/src/drain.rs b/src/drain.rs index b52ec8f..3be0e46 100644 --- a/src/drain.rs +++ b/src/drain.rs @@ -40,6 +40,12 @@ pub enum ParseEffect { }, /// Parser thread's PTY EOF — pane should be closed. Disconnected, + /// OSC 777 desktop notification request (title, body); dispatched on the + /// main thread, gated by general.desktop_notifications. + Notification { + title: String, + body: String, + }, /// The parser processed a non-empty batch of PTY bytes this iteration. /// Emitted once per batch so the main thread can flag activity on inactive /// tabs (output that only repaints the visible grid produces no other effect). @@ -139,7 +145,16 @@ pub fn spawn_parser_thread(args: ParserThreadArgs) -> thread::JoinHandle<()> { // Parse, scan URLs, optionally resize, and extract side-effects in one write lock. // Resize is applied here so the main thread never calls grid.write() — it just // sets pending_resize and returns, keeping the event loop and renders fluid. - let (old_sb, new_sb, resp, clipboard_write, clipboard_read, bell, resize_effect) = { + let ( + old_sb, + new_sb, + resp, + clipboard_write, + clipboard_read, + bell, + notification, + resize_effect, + ) = { let mut g = grid.write().unwrap(); let old = g.scrollback_len(); parser.process(&batch, &mut g); @@ -156,7 +171,8 @@ pub fn spawn_parser_thread(args: ParserThreadArgs) -> thread::JoinHandle<()> { let cw = g.pending_clipboard_write.take(); let cr = std::mem::take(&mut g.pending_clipboard_read); let b = std::mem::take(&mut g.bell_pending); - (old, new, resp, cw, cr, b, resize_effect) + let notif = g.pending_notification.take(); + (old, new, resp, cw, cr, b, notif, resize_effect) }; if new_sb != old_sb { @@ -180,6 +196,9 @@ pub fn spawn_parser_thread(args: ParserThreadArgs) -> thread::JoinHandle<()> { if bell { let _ = effects_tx.send(ParseEffect::Bell); } + if let Some((title, body)) = notification { + let _ = effects_tx.send(ParseEffect::Notification { title, body }); + } // Signal that this pane produced output this batch. Batches only form // when PTY bytes arrived, so reaching here always means real output. let _ = effects_tx.send(ParseEffect::Output); @@ -305,6 +324,11 @@ impl App { kind: DeferredKind::ClipboardRead, }); } + ParseEffect::Notification { title, body } => { + if self.state.config.general.desktop_notifications { + dispatch_notification(title, body); + } + } ParseEffect::Disconnected => { deferred.push(Deferred { tab_idx, @@ -361,3 +385,29 @@ fn trigger_bell(tab: &mut TabState, now: Instant) { tab.bell_cooldown_until = Some(now + std::time::Duration::from_millis(500)); } } + +/// Dispatch an OSC 777 desktop notification from a detached thread so the event +/// loop never blocks on the external notifier process. +#[cfg(not(test))] +fn dispatch_notification(title: String, body: String) { + #[cfg(target_os = "linux")] + std::thread::spawn(move || { + let _ = std::process::Command::new("notify-send") + .arg(&title) + .arg(&body) + .status(); + }); + #[cfg(target_os = "macos")] + std::thread::spawn(move || { + let script = format!("display notification {body:?} with title {title:?}"); + let _ = std::process::Command::new("osascript") + .arg("-e") + .arg(&script) + .status(); + }); + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + let _ = (title, body); +} + +#[cfg(test)] +fn dispatch_notification(_title: String, _body: String) {} diff --git a/src/drain_test.rs b/src/drain_test.rs index 31918e8..5e28993 100644 --- a/src/drain_test.rs +++ b/src/drain_test.rs @@ -139,6 +139,20 @@ fn bell_effect_sent_on_bell_byte() { assert!(saw_bell, "expected Bell effect from \\x07"); } +#[test] +fn notification_effect_sent_on_osc777() { + let (entry, tx) = make_pane_entry(); + tx.send(b"\x1b]777;notify;Title;Body\x07".to_vec()).unwrap(); + std::thread::sleep(std::time::Duration::from_millis(50)); + let mut got = None; + while let Ok(e) = entry.effects_rx.try_recv() { + if let ParseEffect::Notification { title, body } = e { + got = Some((title, body)); + } + } + assert_eq!(got, Some(("Title".to_string(), "Body".to_string()))); +} + #[test] fn scrollback_delta_sent_when_lines_pushed() { let (entry, tx) = make_pane_entry(); diff --git a/src/terminal/grid.rs b/src/terminal/grid.rs index e567405..a669ce4 100644 --- a/src/terminal/grid.rs +++ b/src/terminal/grid.rs @@ -231,6 +231,8 @@ pub struct Grid { pub shell_state: ShellState, // Last command exit code from OSC 133 ; D ; pub last_exit_code: Option, + // OSC 777 pending desktop notification (title, body); drained once by the parser thread + pub pending_notification: Option<(String, String)>, } impl Grid { @@ -296,6 +298,7 @@ impl Grid { images: Vec::new(), shell_state: ShellState::Unknown, last_exit_code: None, + pending_notification: None, } } @@ -1037,6 +1040,7 @@ impl Grid { self.focus_report = false; self.shell_state = ShellState::Unknown; self.last_exit_code = None; + self.pending_notification = None; } } diff --git a/src/terminal/parser.rs b/src/terminal/parser.rs index 8790e3c..2046d95 100644 --- a/src/terminal/parser.rs +++ b/src/terminal/parser.rs @@ -323,6 +323,7 @@ impl Perform for Performer<'_> { osc_set_hyperlink(self.grid, params); osc_clipboard(self.grid, params); osc_shell_integration(self.grid, params); + osc_notification(self.grid, params); } fn hook(&mut self, _params: &Params, intermediates: &[u8], _ignore: bool, action: char) { // Sixel graphics: DCS P...q (final char = 'q', no intermediates) @@ -494,6 +495,14 @@ fn osc_shell_integration(grid: &mut Grid, params: &[&[u8]]) { } } +fn osc_notification(grid: &mut Grid, params: &[&[u8]]) { + if let [b"777", b"notify", title, body] = params + && let (Ok(t), Ok(b)) = (std::str::from_utf8(title), std::str::from_utf8(body)) + { + grid.pending_notification = Some((t.to_owned(), b.to_owned())); + } +} + fn parse_osc7_uri(uri: &str) -> Option { let rest = uri.strip_prefix("file://")?; if rest.starts_with('/') { diff --git a/src/terminal/parser_test.rs b/src/terminal/parser_test.rs index 7ab0bde..42251b9 100644 --- a/src/terminal/parser_test.rs +++ b/src/terminal/parser_test.rs @@ -1270,3 +1270,20 @@ fn osc133_unknown_subcommand_is_ignored() { p.process(b"\x1b]133;Z\x07"); assert_eq!(p.grid.shell_state, ShellState::Unknown); } + +#[test] +fn osc777_notify_sets_pending_notification() { + let mut p = make_parser(80, 24); + p.process(b"\x1b]777;notify;Build done;exit 0\x07"); + assert_eq!( + p.grid.pending_notification, + Some(("Build done".to_string(), "exit 0".to_string())) + ); +} + +#[test] +fn osc777_without_notify_keyword_is_ignored() { + let mut p = make_parser(80, 24); + p.process(b"\x1b]777;other;Title;Body\x07"); + assert!(p.grid.pending_notification.is_none()); +}