fix(terminal): 对齐标签活动状态并修正链接边界

- 标签 Loading 仅响应后台标签最近两秒的真实输出
- 排除 OSC 控制序列并补充终端活动状态回归测试
- 修正 URL 在中文标点和未配对括号后的高亮范围
This commit is contained in:
Realm
2026-08-29 19:55:58 +08:00
parent c9f722071a
commit e1d39d8dc4
7 changed files with 175 additions and 71 deletions
+3 -4
View File
@@ -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:
+2 -3
View File
@@ -106,9 +106,8 @@ OSC 9 携带通知正文;OSC 777 和 OSC 99 可以同时携带标题与正文
符合显示时机的通知来自后台或非当前可见标签时,ashell 会显示系统通知,标签按钮以
闪动铃铛标记未读状态,并在 macOS Dock 或 Windows 任务栏图标上显示红色徽标。点击
系统通知会激活 ashell 并切换到对应的终端标签,查看对应标签后自动清除未读状态。
如果应用已激活且通知来自当前可见标签,则不重复提醒。标签标题左侧的 Loading 动画
优先识别 OSC 133/633 shell integration 的命令开始和结束标记;未提供协议标记时根据
近期终端输出活动回退显示。
如果应用已激活且通知来自当前可见标签,则不重复提醒。未选中的标签在最近 2 秒内收到
终端输出时,会在标题左侧显示 Loading 动画;持续输出会刷新显示时间,当前标签不会显示。
可以分别使用下面的命令测试四种提醒:
+1 -1
View File
@@ -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());
+7 -6
View File
@@ -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()
+78 -19
View File
@@ -953,32 +953,69 @@ fn find_urls(text: &str) -> Vec<usize> {
}
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<usize> {
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);
+2 -2
View File
@@ -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();
+82 -36
View File
@@ -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<String, PendingOsc99Notification>,
}
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<OscTerminalEvent> {
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<Instant>,
command_running: bool,
shell_integration_available: bool,
processor: Processor,
term: Term<TerminalListener>,
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<TerminalNotification> {
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() {