diff --git a/README.en.md b/README.en.md index fa3df6d..cc4072d 100644 --- a/README.en.md +++ b/README.en.md @@ -107,10 +107,9 @@ native notification and a flashing bell on the corresponding tab. Unread notific red badge to the macOS Dock icon or a red overlay to the Windows taskbar icon. Clicking the native notification activates ashell and switches to the originating terminal tab; viewing that tab clears its unread state. No reminder is shown when ashell is active and the notification originates in the -currently visible tab. The loading indicator beside a tab title prioritizes OSC 133/633 -shell-integration command lifecycle markers. Shells and CLI tools without those markers fall back to -recent terminal output activity, while an OSC 9 completion notification clears the task's loading -state immediately. +currently visible tab. An unselected tab shows a loading indicator beside its title for two seconds +after receiving terminal output. Continued output refreshes the indicator, while the selected tab +does not show it. You can test a generic OSC 9 notification with: diff --git a/README.md b/README.md index 9d4c7cc..bed296c 100644 --- a/README.md +++ b/README.md @@ -106,9 +106,8 @@ OSC 9 携带通知正文;OSC 777 和 OSC 99 可以同时携带标题与正文 符合显示时机的通知来自后台或非当前可见标签时,ashell 会显示系统通知,标签按钮以 闪动铃铛标记未读状态,并在 macOS Dock 或 Windows 任务栏图标上显示红色徽标。点击 系统通知会激活 ashell 并切换到对应的终端标签,查看对应标签后自动清除未读状态。 -如果应用已激活且通知来自当前可见标签,则不重复提醒。标签标题左侧的 Loading 动画 -优先识别 OSC 133/633 shell integration 的命令开始和结束标记;未提供协议标记时根据 -近期终端输出活动回退显示。 +如果应用已激活且通知来自当前可见标签,则不重复提醒。未选中的标签在最近 2 秒内收到 +终端输出时,会在标题左侧显示 Loading 动画;持续输出会刷新显示时间,当前标签不会显示。 可以分别使用下面的命令测试四种提醒: diff --git a/src/app/mod.rs b/src/app/mod.rs index c55ec26..c9c7b60 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1565,7 +1565,7 @@ impl Ashell { continue; } if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) { - tab.clear_command_activity(); + tab.clear_terminal_activity(); tab.connected = false; tab.status = reason.clone(); tab.disconnected_reason = Some(reason.clone()); diff --git a/src/app/ui.rs b/src/app/ui.rs index 639d7ba..9d2242e 100644 --- a/src/app/ui.rs +++ b/src/app/ui.rs @@ -4351,12 +4351,13 @@ impl Ashell { pane_ids.iter().any(|id| { self.unread_terminal_notifications.contains(id) }); - let output_active = pane_ids.iter().any(|id| { - self.tabs - .iter() - .find(|tab| tab.id == *id) - .is_some_and(TerminalTab::is_command_active) - }); + let output_active = ix != selected + && pane_ids.iter().any(|id| { + self.tabs + .iter() + .find(|tab| tab.id == *id) + .is_some_and(TerminalTab::has_recent_output) + }); h_flex() .id(("ashell-tab", ix)) .relative() diff --git a/src/terminal/highlight.rs b/src/terminal/highlight.rs index bd499ba..9198275 100644 --- a/src/terminal/highlight.rs +++ b/src/terminal/highlight.rs @@ -953,32 +953,69 @@ fn find_urls(text: &str) -> Vec { } fn find_url_len(text: &str) -> usize { - let end = text - .find(|c: char| c.is_ascii_whitespace()) - .unwrap_or(text.len()); - let mut url = &text[..end]; - - loop { - let Some(&closing) = url.as_bytes().last() else { - break; - }; - let opening = match closing { - b')' => b'(', - b']' => b'[', - b'}' => b'{', - _ => break, - }; - let opening_count = url.bytes().filter(|byte| *byte == opening).count(); - let closing_count = url.bytes().filter(|byte| *byte == closing).count(); - if closing_count <= opening_count { + let mut end = text.len(); + let mut expected_closings = Vec::new(); + for (index, character) in text.char_indices() { + if is_url_terminator(character) { + end = index; break; } - url = &url[..url.len() - 1]; + + match character { + '(' => expected_closings.push(')'), + '[' => expected_closings.push(']'), + '{' => expected_closings.push('}'), + ')' | ']' | '}' => { + if expected_closings.last() == Some(&character) { + expected_closings.pop(); + } else { + end = index; + break; + } + } + _ => {} + } + } + + let mut url = &text[..end]; + while let Some(character) = url.chars().next_back() { + if !matches!(character, ',' | '.' | ';' | ':' | '!' | '?') { + break; + } + url = &url[..url.len() - character.len_utf8()]; } url.len() } +fn is_url_terminator(character: char) -> bool { + character.is_whitespace() + || matches!( + character, + '"' | '\'' + | '`' + | '<' + | '>' + | ',' + | '。' + | ';' + | ':' + | '!' + | '?' + | '、' + | '(' + | ')' + | '【' + | '】' + | '《' + | '》' + | '“' + | '”' + | '‘' + | '’' + ) +} + fn find_ports(text: &str) -> Vec { let mut positions = Vec::new(); let bytes = text.as_bytes(); @@ -1175,6 +1212,28 @@ mod tests { ); } + #[test] + fn stops_before_text_after_an_unmatched_closing_delimiter() { + assert_eq!( + detected_url( + "https://github.com/gnachman/iTerm2/blob/master/sources/PTYSession/PTYSession.m#L6371-L6380)、PTYTab.m" + ), + "https://github.com/gnachman/iTerm2/blob/master/sources/PTYSession/PTYSession.m#L6371-L6380" + ); + } + + #[test] + fn excludes_chinese_and_ascii_trailing_punctuation() { + assert_eq!( + detected_url("https://example.com/path、后续文字"), + "https://example.com/path" + ); + assert_eq!( + detected_url("https://example.com/path, next"), + "https://example.com/path" + ); + } + #[test] fn overlapping_keywords_keep_the_longest_match_color() { let long_color = hsla(10, 20, 30); diff --git a/src/terminal/input.rs b/src/terminal/input.rs index 45671c2..17bedec 100644 --- a/src/terminal/input.rs +++ b/src/terminal/input.rs @@ -209,7 +209,7 @@ impl Ashell { } tab.clear_selection(); - tab.record_terminal_input(&bytes); + tab.prepare_for_terminal_input(); let encoded = tab.encode_input(&bytes); tab.send_backend(BackendCommand::Input(encoded)); window.prevent_default(); @@ -325,7 +325,7 @@ impl Ashell { } tab.clear_selection(); self.terminal_marked_text = None; - tab.record_terminal_input(&bytes); + tab.prepare_for_terminal_input(); let encoded = tab.encode_input(&bytes); tab.send_backend(BackendCommand::Input(encoded)); window.invalidate_character_coordinates(); diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index b79c698..ec22bd9 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -37,7 +37,7 @@ pub enum TabKind { Serial, } -const TERMINAL_ACTIVITY_GRACE: Duration = Duration::from_millis(750); +const TERMINAL_ACTIVITY_GRACE: Duration = Duration::from_secs(2); const CLICK_CURSOR_PREDICTION_TTL: Duration = Duration::from_millis(750); const MAX_OSC_PAYLOAD_BYTES: usize = 4096; const MAX_NOTIFICATION_TEXT_BYTES: usize = 8192; @@ -242,27 +242,39 @@ struct OscTerminalParser { pending_osc99: HashMap, } +struct OscTerminalScan { + events: Vec<(usize, OscTerminalEvent)>, + has_terminal_output: bool, +} + impl OscTerminalParser { /// Scans decoded terminal output without consuming it from the terminal emulator. #[cfg(test)] fn advance(&mut self, bytes: &[u8]) -> Vec { self.advance_with_offsets(bytes) + .events .into_iter() .map(|(_, event)| event) .collect() } - fn advance_with_offsets(&mut self, bytes: &[u8]) -> Vec<(usize, OscTerminalEvent)> { + fn advance_with_offsets(&mut self, bytes: &[u8]) -> OscTerminalScan { let mut events = Vec::new(); + let mut has_terminal_output = false; for (index, &byte) in bytes.iter().enumerate() { match self.state { OscTerminalState::Ground => { if byte == 0x1b { self.state = OscTerminalState::Escape; + } else { + has_terminal_output = true; } } OscTerminalState::Escape => { + if byte != b']' && byte != 0x1b { + has_terminal_output = true; + } self.state = match byte { b']' => { self.command.clear(); @@ -333,7 +345,10 @@ impl OscTerminalParser { } } - events + OscTerminalScan { + events, + has_terminal_output, + } } fn push_payload_byte(&mut self, byte: u8) { @@ -869,8 +884,6 @@ pub struct TerminalTab { output_decoder: StreamingDecoder, osc_terminal_parser: OscTerminalParser, output_activity_until: Option, - command_running: bool, - shell_integration_available: bool, processor: Processor, term: Term, pub cols: u16, @@ -1283,7 +1296,7 @@ mod terminal_tab_backend_tests { ); tab.note_click_cursor_move_at(predicted, now); - tab.record_terminal_input(b"x"); + tab.prepare_for_terminal_input(); assert_eq!(tab.cursor_state_for_click_at(now), actual); } } @@ -1542,6 +1555,60 @@ mod osc_terminal_tests { ); } + #[test] + fn distinguishes_terminal_output_from_osc_signaling() { + let mut parser = OscTerminalParser::default(); + + let shell_marker = parser.advance_with_offsets(b"\x1b]133;C\x07"); + assert!(!shell_marker.has_terminal_output); + assert_eq!( + shell_marker.events, + vec![(b"\x1b]133;C\x07".len(), OscTerminalEvent::CommandStarted)] + ); + + let output = parser.advance_with_offsets(b"server ready\r\n"); + assert!(output.has_terminal_output); + assert!(output.events.is_empty()); + + let mixed = parser.advance_with_offsets(b"built\r\n\x1b]133;D;0\x07"); + assert!(mixed.has_terminal_output); + assert_eq!(mixed.events.len(), 1); + } + + #[test] + fn excludes_osc_signaling_split_across_output_chunks() { + let mut parser = OscTerminalParser::default(); + + assert!( + !parser + .advance_with_offsets(b"\x1b]133;") + .has_terminal_output + ); + let completed = parser.advance_with_offsets(b"C\x07"); + assert!(!completed.has_terminal_output); + assert_eq!(completed.events.len(), 1); + } + + #[test] + fn shell_integration_markers_do_not_drive_tab_output_activity() { + let (events_tx, _events_rx) = mpsc::channel(); + let mut tab = TerminalTab::new_local( + "tab-1".into(), + "Local".into(), + BackendTx::Pending, + GuardedBackendEventSender::new(events_tx), + ); + + tab.feed(b"\x1b]133;C\x07"); + assert!(!tab.has_recent_output()); + + tab.feed(b"server ready\r\n"); + assert!(tab.has_recent_output()); + + tab.feed(b"\x1b]133;D;0\x07"); + assert!(tab.has_recent_output()); + } + #[test] fn parses_prompt_click_modes_and_preserves_event_offsets() { let mut parser = OscTerminalParser::default(); @@ -1549,6 +1616,7 @@ mod osc_terminal_tests { let (event_end, event) = parser .advance_with_offsets(bytes) + .events .into_iter() .next() .expect("prompt marker event"); @@ -1807,8 +1875,6 @@ impl TerminalTab { output_decoder: StreamingDecoder::new(TextEncoding::Utf8), osc_terminal_parser: OscTerminalParser::default(), output_activity_until: None, - command_running: false, - shell_integration_available: false, processor: Processor::new(), term: new_term(100, 30, shared_backend.clone(), id, events.clone()), cols: 100, @@ -1825,12 +1891,13 @@ impl TerminalTab { pub fn feed(&mut self, bytes: &[u8]) -> Vec { let decoded = self.output_decoder.decode(bytes); - if !decoded.is_empty() { + let scan = self.osc_terminal_parser.advance_with_offsets(&decoded); + if scan.has_terminal_output { self.output_activity_until = Some(Instant::now() + TERMINAL_ACTIVITY_GRACE); } let mut notifications = Vec::new(); let mut processed_until = 0; - for (event_end, event) in self.osc_terminal_parser.advance_with_offsets(&decoded) { + for (event_end, event) in scan.events { self.processor .advance(&mut self.term, &decoded[processed_until..event_end]); self.handle_osc_terminal_event(event, &mut notifications); @@ -1850,8 +1917,6 @@ impl TerminalTab { ) { match event { OscTerminalEvent::Notification(notification) => { - self.command_running = false; - self.output_activity_until = None; notifications.push(notification); } OscTerminalEvent::ProtocolReply(reply) => { @@ -1862,9 +1927,6 @@ impl TerminalTab { secondary, } => { self.clear_click_cursor_prediction(); - self.shell_integration_available = true; - self.command_running = false; - self.output_activity_until = None; let prompt_start = self.buffer_cursor_position().unwrap_or((0, 0)); if secondary { if let Some(prompt_input) = self.prompt_input.as_mut() { @@ -1907,15 +1969,10 @@ impl TerminalTab { } OscTerminalEvent::CommandStarted => { self.clear_click_cursor_prediction(); - self.shell_integration_available = true; - self.command_running = true; self.prompt_input = None; } OscTerminalEvent::CommandFinished => { self.clear_click_cursor_prediction(); - self.shell_integration_available = true; - self.command_running = false; - self.output_activity_until = None; self.prompt_input = None; } } @@ -1944,9 +2001,8 @@ impl TerminalTab { true } - pub(crate) fn is_command_active(&self) -> bool { - (self.command_running && !self.is_alternate_screen_active()) - || self.output_activity_until.is_some() + pub(crate) fn has_recent_output(&self) -> bool { + self.output_activity_until.is_some() } pub(crate) fn expire_output_activity(&mut self, now: Instant) -> bool { @@ -1961,22 +2017,13 @@ impl TerminalTab { } } - pub(crate) fn record_terminal_input(&mut self, bytes: &[u8]) { + pub(crate) fn prepare_for_terminal_input(&mut self) { self.clear_click_cursor_prediction(); - if self.shell_integration_available - && !self.is_alternate_screen_active() - && bytes.iter().any(|byte| matches!(byte, b'\r' | b'\n')) - { - self.command_running = true; - self.output_activity_until = None; - } } - pub(crate) fn clear_command_activity(&mut self) -> bool { - let changed = self.command_running || self.output_activity_until.is_some(); - self.command_running = false; + pub(crate) fn clear_terminal_activity(&mut self) -> bool { + let changed = self.output_activity_until.is_some(); self.output_activity_until = None; - self.shell_integration_available = false; self.prompt_input = None; self.clear_click_cursor_prediction(); self.osc_terminal_parser = OscTerminalParser::default(); @@ -2005,7 +2052,6 @@ impl TerminalTab { self.output_decoder = StreamingDecoder::new(encoding); self.osc_terminal_parser = OscTerminalParser::default(); self.output_activity_until = None; - self.command_running = false; self.prompt_input = None; self.clear_click_cursor_prediction(); if let Some(session) = self.session.as_mut() {