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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ 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
Expand Down
2 changes: 2 additions & 0 deletions src/config/config_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -280,4 +280,6 @@ 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
assert!(cfg.general.desktop_notifications); // OSC 777 desktop notifications on by default
}
6 changes: 6 additions & 0 deletions src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ pub struct GeneralConfig {
pub auto_update_check: bool,
#[serde(default)]
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 {
Expand All @@ -71,6 +75,8 @@ impl Default for GeneralConfig {
visual_bell: false,
auto_update_check: default_true(),
auto_update_install: false,
shell_integration: default_true(),
desktop_notifications: default_true(),
}
}
}
Expand Down
27 changes: 27 additions & 0 deletions src/config/tui_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ 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 F_DESKTOP_NOTIFICATIONS: usize = 40;

const PALETTE_LABELS: [&str; 16] = [
"Palette 0 black",
Expand Down Expand Up @@ -285,6 +287,21 @@ 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,
});
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");

Expand Down Expand Up @@ -653,6 +670,14 @@ impl ConfigPanel {
.parse::<bool>()
.map_err(|_| "Invalid auto_update_install — use true or false")?;

let shell_integration = get(F_SHELL_INTEGRATION)
.parse::<bool>()
.map_err(|_| "Invalid shell_integration — use true or false")?;

let desktop_notifications = get(F_DESKTOP_NOTIFICATIONS)
.parse::<bool>()
.map_err(|_| "Invalid desktop_notifications — use true or false")?;

Ok(Config {
font: FontConfig { family, size },
window: WindowConfig {
Expand Down Expand Up @@ -683,6 +708,8 @@ impl ConfigPanel {
visual_bell,
auto_update_check,
auto_update_install,
shell_integration,
desktop_notifications,
},
})
}
Expand Down
42 changes: 33 additions & 9 deletions src/config/tui_config_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 + 2 shell/notify = 41
assert_eq!(panel.fields.len(), 41);
}

#[test]
Expand Down Expand Up @@ -289,6 +289,30 @@ 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_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();
Expand Down Expand Up @@ -536,8 +560,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);
// 41 total - 15 palette body fields = 26 visible
assert_eq!(panel.visible_indices().len(), 26);
}

#[test]
Expand All @@ -546,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(), 39);
assert_eq!(panel.visible_indices().len(), 41);
}

#[test]
Expand All @@ -556,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(), 24);
assert_eq!(panel.visible_indices().len(), 26);
}

#[test]
Expand Down Expand Up @@ -611,10 +635,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_DESKTOP_NOTIFICATIONS is the last field and is always visible
panel.selected = F_DESKTOP_NOTIFICATIONS;
panel.handle_down();
assert_eq!(panel.selected, F_AUTO_UPDATE_INSTALL);
assert_eq!(panel.selected, F_DESKTOP_NOTIFICATIONS);
}

#[test]
Expand Down
54 changes: 52 additions & 2 deletions src/drain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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);
Expand All @@ -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 {
Expand All @@ -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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) {}
14 changes: 14 additions & 0 deletions src/drain_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
15 changes: 12 additions & 3 deletions src/renderer/render_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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(),
);
Expand Down
Loading