mirror of
https://github.com/rust-kotlin/ashell.git
synced 2026-09-22 00:00:59 +00:00
fix(terminal): 对齐标签活动状态并修正链接边界
- 标签 Loading 仅响应后台标签最近两秒的真实输出 - 排除 OSC 控制序列并补充终端活动状态回归测试 - 修正 URL 在中文标点和未配对括号后的高亮范围
This commit is contained in:
+3
-4
@@ -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:
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user