终端通知:程序请求的通知(OSC 9 / 777)和响铃提醒

- 程序用 OSC 9 或 OSC 777 notify 请求通知,终端响铃时也提醒;OSC 9 里
  ConEmu 的进度条、当前目录等命令不算。
- ShellRS 不在前台时发系统通知,点了回到那个终端;在前台但终端在别的标签
  时弹应用内通知,点一下切过去。响铃在终端就在眼前时不提醒。
- 标题是标签名,同一终端程序通知 2 秒、响铃 10 秒内只提醒一次;设置 ›
  终端 › 通知可分别关掉。
- 启动时设置应用标识 com.shellrs.ShellRS,Windows 安装程序的快捷方式用
  同一个 AppUserModelID。
- 不做 OSC 133 长命令通知:要每台服务器装 shell 集成,收益不抵成本。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
XuzandClaude Opus 5.5 committed 2026-10-06 14:37:53 +08:00
1 parent befdfc7b02
commit a9e8d8dbef
16 files changed
+799 -13

No files matched your search

+2
View File
@@ -56,6 +56,7 @@ ShellRS(crate 与二进制都叫 `shellrs`)是 Xshell / WinSCP 式的 SSH
- 双击、回车、F4、右键「编辑」都打开它,本地文件也行;新建文件后直接打开。
- 只认 UTF-8(不做 GBK),上限 5 MB,约 15 种语言语法高亮。
- 远程文件原地改写:截断后写原文件,不走 `.filepart` 加改名。保存前核对,被改过就问「覆盖」。
- **终端通知**:只做程序请求的(OSC 9 / 777)和响铃,不做 OSC 133 长命令通知:那要每台服务器装 shell 集成,收益不抵成本。不在前台发系统通知,在前台而终端在别的标签发应用内通知;响铃在终端就在眼前时不提醒。
- **预览**:图片和 Markdown(不做 HTML),显示在快速查看式的大对话框里,不是标签。
- 双击图片预览;Markdown 和 SVG 是文本,双击编辑,右键另有「预览」。
- 图片默认适合窗口居中,可放大、缩小、看原图、滚动;比例只写百分比。
@@ -249,6 +250,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
- **链接**:正则找出的地址和 OSC 8 标出的都算,只认 http / https(`links.rs`);OSC 8 的格子优先于正则。一直画成蓝色(`theme.blue`,主题的 `link` 和正文同色)加下划线,⌘ / Ctrl 单击才打开,普通单击照旧选文字。
- **鼠标上报**(`mouse.rs` 是纯编码):按住 Shift 不上报,右键始终是自己的菜单。写入走 `engine.write`,不算用户输入。
- **OSC 52 只写不读**(`Osc52::OnlyCopy`),只认剪贴板 `c`,主选择区忽略。
- **通知**:OSC 9 / 777 由 `terminal/notices.rs` 的扫描器在解析线程上认(alacritty 会丢掉),OSC 9 里「数字;」开头的是 ConEmu 的命令,不算通知。投递在 `workspace/notices.rs`:窗口不在前台走 GPUI 的 `show_system_notification`(tag 是 `terminal:local:<id>` / `terminal:remote:<id>`,点击回调按 tag 切标签),在前台走应用内通知。`shellrs::init` 里的 `set_app_identity` 用 bundle id,测试平台没有它就不发系统通知;macOS 上 `cargo run` 没有 bundle,看不到系统通知。
## 测试约定
+1
View File
@@ -173,6 +173,7 @@ ShellRS /sessionname=标签名 sftp://用户[:密码]@地址[:端口]
- **清屏**(⌘K,其他平台 Ctrl+Shift+K;焦点不在终端时 ⌘K 仍是聚焦侧栏的搜索框):清掉屏幕和回滚历史,只留下光标所在的提示符行,包括已输入但未回车的内容。清屏只在本地进行,不向服务器发送任何内容。vim、top 等全屏程序运行时不可用。
- **链接**:输出里的 `http://`、`https://` 地址,以及程序明确标出的链接(如 `ls --hyperlink`、gcc 的报错),显示为带下划线的蓝色。按住 ⌘(其他平台 Ctrl)单击,用系统浏览器打开;不按时单击照旧是选择文字。鼠标在链接上停一会儿会显示它真正的地址:程序标出的链接,显示的文字可以和地址不同。地址末尾的句号、逗号和不成对的右括号不算在内;被折成两行的长地址仍是一个链接。其他协议(如 `file://`)不当作链接。
- **鼠标**:vim(`:set mouse=a`)、htop、tmux(`set -g mouse on`)、mc 这类程序要求鼠标时,单击、拖动和滚轮都交给程序。按住 Shift 拖动仍是选择文字,按住 Shift 滚动仍是翻看回滚历史;右键始终是 ShellRS 的菜单。
- **通知**:程序可以请求通知(OSC 9,或 OSC 777 的 `notify;标题;正文`),终端响铃时也会提醒。ShellRS 不在前台时发系统通知(macOS 第一次会询问是否允许),点通知回到那个终端;ShellRS 在前台但终端在别的标签时,在窗口右上角提示,点一下切过去。程序请求的通知即使终端就在眼前也会提示;响铃只在看不见那个终端时提醒(Tab 补全等也会响铃)。通知标题是标签名,看得出是哪台主机发的。同一终端的程序通知 2 秒内、响铃 10 秒内只提醒一次。设置 › 终端 › 通知可以分别关掉。命令跑完想被提醒,可以在后面加上 `; printf '\e]9;完成\a'` 或 `; tput bel`;Claude Code 等命令行工具需要你处理时也会这样提醒。
- **远程复制**:远程程序用 OSC 52 复制的内容(如 tmux 的 `set -g set-clipboard on`、vim 的 OSC 52 插件)直接进本机剪贴板。远程程序读不了本机剪贴板。
## 右侧栏
+5 -2
View File
@@ -60,8 +60,11 @@ Source: "{#BuildDir}\shellrs.exe"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#BuildDir}\shellrs-cli.exe"; DestDir: "{app}"; Flags: ignoreversion
[Icons]
Name: "{autoprograms}\ShellRS"; Filename: "{app}\shellrs.exe"
Name: "{autodesktop}\ShellRS"; Filename: "{app}\shellrs.exe"; Tasks: desktopicon
; The AppUserModelID ShellRS gives itself (`set_app_identity`, for its
; notifications): the same here keeps a pinned shortcut and the running
; window one taskbar button.
Name: "{autoprograms}\ShellRS"; Filename: "{app}\shellrs.exe"; AppUserModelID: "com.shellrs.ShellRS"
Name: "{autodesktop}\ShellRS"; Filename: "{app}\shellrs.exe"; AppUserModelID: "com.shellrs.ShellRS"; Tasks: desktopicon
[Run]
; Interactive installs only: an update is restarted by ShellRS's own helper,
+4
View File
@@ -38,5 +38,9 @@ mod testing;
/// Initialize GPUI Kit, locale, key bindings and global actions. Call once
/// inside `Application::run` before creating any view.
pub fn init(cx: &mut gpui_kit::App) {
// The identity the system shows notifications under, set before any
// window opens: on Windows the AppUserModelID, the same as the macOS
// bundle's.
cx.set_app_identity("com.shellrs.ShellRS", "ShellRS");
app::init(cx);
}
+2 -2
View File
@@ -7,8 +7,8 @@ mod store;
pub use apply::apply;
pub use model::{
AppSettings, Appearance, Choice, ExternalCliSettings, InterfaceLanguage, TerminalFontSettings,
UpdateSettings,
AppSettings, Appearance, Choice, ExternalCliSettings, InterfaceLanguage, NotificationSettings,
TerminalFontSettings, UpdateSettings,
};
pub use settings_panel::{SettingsPanel, SettingsPanelEvent};
pub use store::{SettingsStore, SettingsStoreEvent};
+31
View File
@@ -23,6 +23,28 @@ pub struct AppSettings {
/// 显示隐藏文件 in the SFTP lists, each side on its own, switched from
/// their toolbars and menus.
pub show_hidden: ShowHiddenFiles,
/// 终端 → 通知.
pub notifications: NotificationSettings,
}
/// 终端 → 通知: what a terminal may tell the user about while they look
/// elsewhere. Both on until the user turns them off.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct NotificationSettings {
/// 程序发送的通知: OSC 9 and 777.
pub programs: bool,
/// 响铃时通知.
pub bell: bool,
}
impl Default for NotificationSettings {
fn default() -> Self {
Self {
programs: true,
bell: true,
}
}
}
/// 外部 CLI.
@@ -280,6 +302,15 @@ mod tests {
assert!(!settings.show_hidden.local);
}
#[test]
fn notifications_are_on_unless_the_file_says_otherwise() {
let settings: AppSettings = serde_json::from_str("{}").unwrap();
assert!(settings.notifications.programs && settings.notifications.bell);
let settings: AppSettings =
serde_json::from_str(r#"{"notifications":{"bell":false}}"#).unwrap();
assert!(settings.notifications.programs && !settings.notifications.bell);
}
#[test]
fn automatic_updates_are_on_unless_the_file_says_otherwise() {
let settings: AppSettings = serde_json::from_str("{}").unwrap();
+40 -1
View File
@@ -27,7 +27,7 @@ use crate::terminal::{
};
use crate::update::{Phase, Tone, UpdateSnapshot, UpdateStep, Updater, build_info, platform};
use super::{AppSettings, Choice, SettingsStore};
use super::{AppSettings, Choice, NotificationSettings, SettingsStore};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SettingsPanelEvent {
@@ -315,9 +315,48 @@ fn terminal_groups(panel: &SettingsPanel, cx: &App) -> Vec<SettingGroup> {
})
.keywords(["预览", "字体", "字号", "行高"]),
]),
notification_group(store),
]
}
/// 终端 → 通知: when the window is not in front, the system's notification;
/// when another tab is, one in the window.
fn notification_group(store: &Entity<SettingsStore>) -> SettingGroup {
let switch = |read: fn(&NotificationSettings) -> bool,
write: fn(&mut NotificationSettings, bool)| {
let (reader, writer) = (store.clone(), store.clone());
SettingField::switch(
move |cx| read(&reader.read(cx).settings().notifications),
move |on, cx| {
writer.update(cx, |store, cx| {
store.update(|settings| write(&mut settings.notifications, on), cx)
});
},
)
.default_value(true)
};
SettingGroup::new()
.title("通知")
.description(
"ShellRS 不在前台时发系统通知;在前台但终端在别的标签时,在窗口右上角提示,点一下切过去。",
)
.items([
SettingItem::new(
"程序发送的通知",
switch(
|settings| settings.programs,
|settings, on| settings.programs = on,
),
)
.description("远程或本地程序用 OSC 9 / 777 请求的通知,例如 printf '\\e]9;完成\\a'。"),
SettingItem::new(
"响铃时通知",
switch(|settings| settings.bell, |settings, on| settings.bell = on),
)
.description("终端就在当前标签时不提示。"),
])
}
/// 外部 CLI: whether the `shellrs` command may use the saved hosts, the
/// command itself, and the skill that teaches agents to use it.
fn external_cli_groups(panel: &SettingsPanel, cx: &App) -> Vec<SettingGroup> {
+67 -1
View File
@@ -20,6 +20,7 @@ use crate::connection::{ConnectionPrompt, ConnectionPromptReply, Latency};
use crate::host::HostOs;
use super::links::{LinkMarker, url_search, visible_links};
use super::notices::{NoticeScanner, ProgramNotice, TerminalNotice};
use super::search::{MatchMarker, SearchDirection, SearchMark, SearchPosition, TerminalSearch};
use super::{
ExecRequest, ExecResult, SharedTerminalTransportFactory, TerminalLifecycle, TerminalSize,
@@ -186,6 +187,13 @@ impl TerminalEngine {
TerminalUiEventKind::ClipboardStore(text) => {
cx.write_to_clipboard(gpui_kit::ClipboardItem::new_string(text));
}
TerminalUiEventKind::Notice(notice) => {
return Some(TerminalEvent::Notice(notice.cleaned()));
}
TerminalUiEventKind::Bell => {
let line = cursor_line_text(&self.runtime.term.lock());
return Some(TerminalEvent::Notice(TerminalNotice::bell(&line)));
}
}
None
}
@@ -508,6 +516,25 @@ impl TerminalEngine {
}
}
/// The text of the line the cursor is on, or of the nearest line above it
/// on screen that has any: where a program that rings for an answer has
/// its question.
fn cursor_line_text(term: &AlacrittyTerm) -> String {
let mut line = term.grid().cursor.point.line;
loop {
let text: String = term.grid()[line]
.into_iter()
.filter(|cell| !cell.flags.contains(Flags::WIDE_CHAR_SPACER))
.map(|cell| cell.c)
.collect();
let text = text.trim();
if !text.is_empty() || line.0 <= 0 {
return text.to_string();
}
line = Line(line.0 - 1);
}
}
/// Keep the leading cell and spacer of a full-width glyph visually atomic,
/// for the selection and for find highlights alike.
/// Alacritty stores CJK glyphs in one `WIDE_CHAR` cell followed by a
@@ -546,6 +573,8 @@ impl Drop for TerminalEngine {
pub enum TerminalEvent {
PromptRequested(ConnectionPrompt),
HostOsDetected(HostOs),
/// Something to show the user while they look elsewhere.
Notice(TerminalNotice),
}
struct TerminalRuntime {
@@ -582,6 +611,8 @@ impl TerminalRuntime {
.name("shellrs-terminal-parser".into())
.spawn(move || {
let mut processor = Processor::new();
// Alacritty drops the notifications programs ask for.
let mut notices = NoticeScanner::default();
while let Ok(event) = transport_receiver.recv_blocking() {
match event {
TerminalTransportEvent::Started => {
@@ -589,6 +620,9 @@ impl TerminalRuntime {
}
TerminalTransportEvent::Output(bytes) => {
processor.advance(&mut *parser_term.lock(), &bytes);
for notice in notices.scan(&bytes) {
parser_proxy.send(TerminalUiEventKind::Notice(notice));
}
parser_proxy.wakeup();
}
TerminalTransportEvent::HostOsDetected(os) => {
@@ -731,10 +765,13 @@ impl EventListener for TerminalEventProxy {
.commands
.send(TerminalTransportCommand::Write(response.into_bytes()));
}
Event::Bell => {
self.send(TerminalUiEventKind::Bell);
self.wakeup();
}
Event::Wakeup
| Event::MouseCursorDirty
| Event::CursorBlinkingChange
| Event::Bell
| Event::Exit
| Event::ChildExit(_) => self.wakeup(),
// Only the clipboard: a program setting the primary selection
@@ -784,6 +821,9 @@ enum TerminalUiEventKind {
ColorRequest(usize, Arc<dyn Fn(Rgb) -> String + Send + Sync>),
/// A program's copy, by OSC 52.
ClipboardStore(String),
/// A notification a program asked for, by OSC 9 or 777.
Notice(ProgramNotice),
Bell,
}
fn append_message(term: &Arc<FairMutex<AlacrittyTerm>>, message: &str) {
@@ -1018,6 +1058,32 @@ mod tests {
);
}
#[test]
fn notifications_and_the_bell_reach_the_ui() {
let (mut term, _, ui_events) = test_term_with_channels(40, 4);
// The parser thread scans what it feeds the terminal.
let output = "构建中\r\n\x1b]777;notify;构建;完成\x07ok?\x07".as_bytes();
let mut scanner = NoticeScanner::default();
let found = scanner.scan(output);
feed(&mut term, output);
assert_eq!(
found,
[ProgramNotice {
title: Some("构建".into()),
body: "完成".into()
}]
);
let bells = ui_events
.try_iter()
.filter(|event| matches!(event.kind, TerminalUiEventKind::Bell))
.count();
assert_eq!(bells, 1, "the BEL ending the OSC does not ring");
assert_eq!(cursor_line_text(&term), "ok?");
// An empty cursor line gives the nearest line above with text.
feed(&mut term, b"\r\n\r\n");
assert_eq!(cursor_line_text(&term), "ok?");
}
#[test]
fn programs_may_copy_to_the_clipboard_but_not_read_it() {
let (mut term, commands, ui_events) = test_term_with_channels(20, 2);
+20 -6
View File
@@ -13,8 +13,8 @@ use crate::app::{CatalogIcon, CenterTab, CloseLocalTerminal, RestartLocalTermina
use crate::shared::{ClosableTabTitle, close_tab_items};
use super::{
LocalTerminalId, SharedTerminalTransportFactory, TerminalLifecycle, TerminalStatus,
TerminalView,
LocalTerminalId, SharedTerminalTransportFactory, TerminalEvent, TerminalLifecycle,
TerminalNotice, TerminalStatus, TerminalView,
};
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -22,6 +22,7 @@ pub enum LocalTerminalPanelEvent {
Activated(LocalTerminalId),
Closed(LocalTerminalId),
StatusChanged(LocalTerminalId),
Notice(LocalTerminalId, TerminalNotice),
}
/// A local login-shell Dock tab backed by the shared terminal view.
@@ -50,10 +51,17 @@ impl LocalTerminalPanel {
cx,
)
});
let subscriptions = vec![cx.observe(&terminal, move |_, _, cx| {
cx.emit(LocalTerminalPanelEvent::StatusChanged(id));
cx.notify();
})];
let subscriptions = vec![
cx.observe(&terminal, move |_, _, cx| {
cx.emit(LocalTerminalPanelEvent::StatusChanged(id));
cx.notify();
}),
cx.subscribe(&terminal, move |_, _, event: &TerminalEvent, cx| {
if let TerminalEvent::Notice(notice) = event {
cx.emit(LocalTerminalPanelEvent::Notice(id, notice.clone()));
}
}),
];
Self {
id,
@@ -64,6 +72,12 @@ impl LocalTerminalPanel {
}
}
/// 「本地终端 1」: what a notification calls it, whatever title the
/// shell gives the tab.
pub fn default_title(&self) -> &str {
&self.default_title
}
pub fn tab_group(&self) -> Option<WeakEntity<TabGroup>> {
self.tab_group.clone()
}
+2
View File
@@ -8,6 +8,7 @@ mod local_pty;
mod local_terminal_panel;
mod model;
mod mouse;
mod notices;
mod search;
mod terminal_panel;
mod terminal_view;
@@ -24,6 +25,7 @@ pub use local_terminal_panel::{LocalTerminalPanel, LocalTerminalPanelEvent};
pub use model::{
LocalTerminalId, RemoteTerminalId, TerminalLifecycle, TerminalSize, TerminalStatus,
};
pub use notices::TerminalNotice;
pub use search::{SearchDirection, SearchMark, SearchPosition};
pub use terminal_panel::{TerminalPanel, TerminalPanelEvent};
pub(crate) use terminal_view::terminal_key_bindings;
+202
View File
@@ -0,0 +1,202 @@
//! What a terminal has to tell the user while they look elsewhere: a
//! notification a program asks for (OSC 9 as iTerm2 has it, OSC 777 as urxvt
//! and VTE have it), or the bell.
//!
//! Alacritty drops both OSC sequences, so the parser thread runs the output
//! through `NoticeScanner` too.
use alacritty_terminal::vte::{Parser, Perform};
/// The longest title and body a notification shows, in characters.
const TITLE_LIMIT: usize = 80;
const BODY_LIMIT: usize = 240;
/// Something a terminal asks the user to look at.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum TerminalNotice {
/// A program asked for a notification. Its title, when it gave one.
Program { title: Option<String>, body: String },
/// The bell rang. The line the cursor is on, which a program waiting
/// for an answer usually has its question on.
Bell { line: String },
}
impl TerminalNotice {
/// The bell, with the line the cursor is on.
pub fn bell(line: &str) -> Self {
TerminalNotice::Bell {
line: clean_text(line, BODY_LIMIT),
}
}
}
/// A notification a program asked for, as it asked.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ProgramNotice {
pub title: Option<String>,
pub body: String,
}
impl ProgramNotice {
/// The notice to show: control characters out, long text cut.
pub fn cleaned(self) -> TerminalNotice {
TerminalNotice::Program {
title: self
.title
.map(|title| clean_text(&title, TITLE_LIMIT))
.filter(|title| !title.is_empty()),
body: clean_text(&self.body, BODY_LIMIT),
}
}
}
/// Finds the notifications programs ask for in terminal output, however the
/// output is split into chunks.
pub struct NoticeScanner {
parser: Parser,
found: Vec<ProgramNotice>,
}
impl Default for NoticeScanner {
fn default() -> Self {
Self {
parser: Parser::new(),
found: Vec::new(),
}
}
}
impl NoticeScanner {
/// The notifications that end in `bytes`.
pub fn scan(&mut self, bytes: &[u8]) -> Vec<ProgramNotice> {
let mut found = Found(&mut self.found);
self.parser.advance(&mut found, bytes);
std::mem::take(&mut self.found)
}
}
struct Found<'a>(&'a mut Vec<ProgramNotice>);
impl Perform for Found<'_> {
fn osc_dispatch(&mut self, params: &[&[u8]], _: bool) {
if let Some(notice) = program_notice(params) {
self.0.push(notice);
}
}
}
/// The notification an OSC sequence asks for, if it is one:
/// - `9;body`, unless the body is a ConEmu command (`9;4;50` is a progress
/// bar, `9;9;/tmp` the working directory);
/// - `777;notify;title;body`.
fn program_notice(params: &[&[u8]]) -> Option<ProgramNotice> {
// The text after the first parameters, `;` and all: a body may hold one.
let rest = |from: usize| -> String {
params
.get(from..)
.unwrap_or_default()
.iter()
.map(|param| String::from_utf8_lossy(param))
.collect::<Vec<_>>()
.join(";")
};
let notice = match *params.first()? {
b"9" => {
let body = rest(1);
let conemu = params.len() > 1
&& !params[1].is_empty()
&& params[1].iter().all(u8::is_ascii_digit);
ProgramNotice {
title: None,
body: (!conemu).then_some(body)?,
}
}
b"777" if params.get(1) == Some(&&b"notify"[..]) => ProgramNotice {
title: Some(String::from_utf8_lossy(params.get(2)?).into_owned()),
body: rest(3),
},
_ => return None,
};
(!notice.body.trim().is_empty()).then_some(notice)
}
/// `text` on one line, as a notification shows it: control characters and
/// runs of white space become one space, and what is longer than `max`
/// characters is cut with an ellipsis.
pub fn clean_text(text: &str, max: usize) -> String {
let words: Vec<&str> = text
.split(|character: char| character.is_whitespace() || character.is_control())
.filter(|word| !word.is_empty())
.collect();
let line = words.join(" ");
if line.chars().count() <= max {
return line;
}
let mut cut: String = line.chars().take(max.saturating_sub(1)).collect();
cut.push('…');
cut
}
#[cfg(test)]
mod tests {
use super::*;
fn scan(chunks: &[&[u8]]) -> Vec<ProgramNotice> {
let mut scanner = NoticeScanner::default();
chunks
.iter()
.flat_map(|chunk| scanner.scan(chunk))
.collect()
}
fn notice(title: Option<&str>, body: &str) -> ProgramNotice {
ProgramNotice {
title: title.map(str::to_string),
body: body.to_string(),
}
}
#[test]
fn osc_9_is_a_notification_unless_it_is_a_conemu_command() {
assert_eq!(
scan(&[b"x\x1b]9;\xe9\x83\xa8\xe7\xbd\xb2\xe5\xae\x8c\xe6\x88\x90\x07y"]),
[notice(None, "部署完成")]
);
// A body may hold a `;`.
assert_eq!(scan(&[b"\x1b]9;a;b\x1b\\"]), [notice(None, "a;b")]);
assert!(scan(&[b"\x1b]9;4;1;50\x07\x1b]9;9;/tmp\x07\x1b]9;\x07\x1b]9; \x07"]).is_empty());
}
#[test]
fn osc_777_notify_has_a_title_and_a_body() {
assert_eq!(
scan(&["\x1b]777;notify;构建;完成\x07".as_bytes()]),
[notice(Some("构建"), "完成")]
);
assert!(scan(&[b"\x1b]777;preexec\x07\x1b]777;notify;t\x07"]).is_empty());
// Other sequences are not notifications.
assert!(scan(&[b"\x1b]0;title\x07\x1b]52;c;aGk=\x07\x07"]).is_empty());
}
#[test]
fn a_sequence_split_between_chunks_is_found_once_it_ends() {
assert_eq!(
scan(&[b"\x1b]777;noti", b"fy;t;", b"done\x1b", b"\\"]),
[notice(Some("t"), "done")]
);
}
#[test]
fn notification_text_is_one_line_and_cut_when_long() {
assert_eq!(clean_text(" a\tb\n\x1bc ", 10), "a b c");
assert_eq!(clean_text("一二三四五", 4), "一二三…");
assert_eq!(clean_text("一二三四", 4), "一二三四");
assert_eq!(
notice(Some(" "), "x").cleaned(),
TerminalNotice::Program {
title: None,
body: "x".into()
}
);
}
}
+5 -1
View File
@@ -18,7 +18,7 @@ use crate::shared::{ClosableTabTitle, HostMark, LatencyLabel, RenamableTab, clos
use super::{
RemoteTerminalId, SharedRemoteTerminalTransportProvider, TerminalEvent, TerminalLifecycle,
TerminalMenuItems, TerminalStatus, TerminalView,
TerminalMenuItems, TerminalNotice, TerminalStatus, TerminalView,
};
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -28,6 +28,7 @@ pub enum TerminalPanelEvent {
StatusChanged(RemoteTerminalId, HostId),
PromptRequested(RemoteTerminalId, HostId, ConnectionPrompt),
HostOsDetected(HostId, HostOs),
Notice(RemoteTerminalId, TerminalNotice),
}
/// A remote-host Dock panel backed by the shared terminal engine. The
@@ -93,6 +94,9 @@ impl TerminalPanel {
TerminalEvent::HostOsDetected(os) => {
cx.emit(TerminalPanelEvent::HostOsDetected(this.host_id, *os));
}
TerminalEvent::Notice(notice) => {
cx.emit(TerminalPanelEvent::Notice(this.id, notice.clone()));
}
},
),
];
+1
View File
@@ -7,6 +7,7 @@ mod dock_skin;
mod editors;
mod forwards;
mod links;
mod notices;
mod recent_hosts;
mod sidebar;
mod snippets;
+281
View File
@@ -0,0 +1,281 @@
//! What terminals tell the user while they look elsewhere: notifications
//! programs ask for and the bell. The system's notification when the window
//! is not in front, one in the window when another tab is.
use std::time::{Duration, Instant};
use gpui_kit::component::{WindowExt as _, notification::Notification};
use gpui_kit::*;
use super::Workspace;
use crate::app::CenterTab;
use crate::settings::NotificationSettings;
use crate::shared::RenamableTab as _;
use crate::terminal::{LocalTerminalId, RemoteTerminalId, TerminalNotice};
/// How often one terminal may notify: a program at most every 2 seconds,
/// the bell every 10. More in between are dropped.
const PROGRAM_INTERVAL: Duration = Duration::from_secs(2);
const BELL_INTERVAL: Duration = Duration::from_secs(10);
/// Where a notice goes.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum Delivery {
/// The system's notification center: the window is not in front.
System,
/// A notification in the window.
InApp,
None,
}
/// Where `notice` goes. A program asked to notify, so it does so even over
/// its own terminal; the bell rings for typing too (a Tab with nothing to
/// complete), so it only tells of a terminal out of sight.
pub(super) fn delivery(
notice: &TerminalNotice,
settings: NotificationSettings,
window_active: bool,
in_front: bool,
) -> Delivery {
let (allowed, quiet_in_front) = match notice {
TerminalNotice::Program { .. } => (settings.programs, false),
TerminalNotice::Bell { .. } => (settings.bell, true),
};
if !allowed {
Delivery::None
} else if !window_active {
Delivery::System
} else if in_front && quiet_in_front {
Delivery::None
} else {
Delivery::InApp
}
}
/// The title and body: the tab's name first, so a notification says which
/// terminal it is from, and a program cannot pass for another application.
pub(super) fn notice_text(tab: &str, notice: &TerminalNotice) -> (String, String) {
match notice {
TerminalNotice::Program {
title: Some(title),
body,
} => (format!("{tab}:{title}"), body.clone()),
TerminalNotice::Program { title: None, body } => (tab.to_string(), body.clone()),
TerminalNotice::Bell { line } => (format!("{tab}:响铃"), line.clone()),
}
}
/// Whether a notice may go out now, the last of its kind having gone at
/// `last`.
pub(super) fn due(last: Option<Instant>, now: Instant, interval: Duration) -> bool {
last.is_none_or(|last| now.saturating_duration_since(last) >= interval)
}
/// The system notification's tag: one terminal's newest replaces its last,
/// and a click on it says which terminal to show.
pub(super) fn notice_tag(tab: CenterTab) -> Option<String> {
match tab {
CenterTab::Terminal(id) => Some(format!("terminal:remote:{}", id.0)),
CenterTab::LocalTerminal(id) => Some(format!("terminal:local:{}", id.0)),
_ => None,
}
}
pub(super) fn tab_of_tag(tag: &str) -> Option<CenterTab> {
let rest = tag.strip_prefix("terminal:")?;
let (kind, id) = rest.split_once(':')?;
let id = id.parse().ok()?;
match kind {
"remote" => Some(CenterTab::Terminal(RemoteTerminalId(id))),
"local" => Some(CenterTab::LocalTerminal(LocalTerminalId(id))),
_ => None,
}
}
impl Workspace {
/// A terminal has something to tell: say it where the user will see it.
pub(super) fn on_terminal_notice(
&mut self,
tab: CenterTab,
notice: &TerminalNotice,
window: &mut Window,
cx: &mut Context<Self>,
) {
let settings = self.settings().read(cx).settings().notifications;
let in_front = self.active_tab == Some(tab);
let delivery = delivery(notice, settings, window.is_window_active(), in_front);
let (Some(tag), Some(tab_title)) = (notice_tag(tab), self.notice_tab_title(tab, cx)) else {
return;
};
if delivery == Delivery::None {
return;
}
let (kind, interval) = match notice {
TerminalNotice::Program { .. } => ("program", PROGRAM_INTERVAL),
TerminalNotice::Bell { .. } => ("bell", BELL_INTERVAL),
};
let key = format!("{tag}:{kind}");
// The executor's clock, which tests can move on.
let now = cx.background_executor().now();
if !due(self.notice_times.get(&key).copied(), now, interval) {
return;
}
self.notice_times.insert(key, now);
let (title, body) = notice_text(&tab_title, notice);
match delivery {
Delivery::System => cx.show_system_notification(SystemNotification {
tag: tag.into(),
title: title.into(),
body: body.into(),
actions: Vec::new(),
}),
Delivery::InApp => {
let workspace = cx.weak_entity();
let notification = Notification::new()
.id1::<TerminalNoticeTag>(SharedString::from(tag))
.title(title)
.message(body)
.on_click(move |_, window, cx| {
_ = workspace.update(cx, |workspace, cx| {
workspace.show_terminal_tab(tab, window, cx)
});
});
window.push_notification(notification, cx);
}
Delivery::None => {}
}
}
/// A system notification was clicked: bring the window forward on the
/// terminal it came from, if that is still open.
pub(super) fn on_notice_clicked(
&mut self,
tag: &str,
window: &mut Window,
cx: &mut Context<Self>,
) {
crate::app::bring_forward(window, cx);
if let Some(tab) = tab_of_tag(tag) {
self.show_terminal_tab(tab, window, cx);
}
}
fn show_terminal_tab(&self, tab: CenterTab, window: &mut Window, cx: &mut Context<Self>) {
let target = match tab {
CenterTab::Terminal(id) => self
.terminals
.get(&id)
.map(|panel| (panel.read(cx).tab_group(), panel.entity_id())),
CenterTab::LocalTerminal(id) => self
.local_terminals
.get(&id)
.map(|panel| (panel.read(cx).tab_group(), panel.entity_id())),
_ => None,
};
if let Some((group, panel)) = target {
self.activate_tab(group, panel, window, cx);
}
}
fn notice_tab_title(&self, tab: CenterTab, cx: &App) -> Option<String> {
match tab {
CenterTab::Terminal(id) => Some(self.terminals.get(&id)?.read(cx).tab_title(cx).into()),
CenterTab::LocalTerminal(id) => Some(
self.local_terminals
.get(&id)?
.read(cx)
.default_title()
.to_string(),
),
_ => None,
}
}
}
/// The in-window notifications of terminals, one a terminal at a time.
struct TerminalNoticeTag;
#[cfg(test)]
mod tests {
use super::{
BELL_INTERVAL, CenterTab, Delivery, Duration, Instant, LocalTerminalId,
NotificationSettings, RemoteTerminalId, TerminalNotice, delivery, due, notice_tag,
notice_text, tab_of_tag,
};
fn program(title: Option<&str>) -> TerminalNotice {
TerminalNotice::Program {
title: title.map(str::to_string),
body: "完成".into(),
}
}
fn bell() -> TerminalNotice {
TerminalNotice::Bell {
line: "y/N?".into(),
}
}
#[test]
fn the_system_tells_of_a_window_away_and_the_window_of_another_tab() {
let on = NotificationSettings::default();
for notice in [program(None), bell()] {
assert_eq!(delivery(&notice, on, false, true), Delivery::System);
assert_eq!(delivery(&notice, on, true, false), Delivery::InApp);
}
// In sight, a program still notifies; the bell does not.
assert_eq!(delivery(&program(None), on, true, true), Delivery::InApp);
assert_eq!(delivery(&bell(), on, true, true), Delivery::None);
let off = NotificationSettings {
programs: false,
bell: false,
};
assert_eq!(delivery(&program(None), off, false, false), Delivery::None);
assert_eq!(delivery(&bell(), off, false, false), Delivery::None);
}
#[test]
fn a_notice_is_titled_with_its_tab() {
assert_eq!(
notice_text("web-01", &program(Some("构建"))),
("web-01:构建".to_string(), "完成".to_string())
);
assert_eq!(
notice_text("web-01", &program(None)),
("web-01".to_string(), "完成".to_string())
);
assert_eq!(
notice_text("本地终端 1", &bell()),
("本地终端 1:响铃".to_string(), "y/N?".to_string())
);
}
#[test]
fn notices_are_spaced_out() {
let start = Instant::now();
assert!(due(None, start, BELL_INTERVAL));
assert!(!due(
Some(start),
start + Duration::from_secs(9),
BELL_INTERVAL
));
assert!(due(
Some(start),
start + Duration::from_secs(10),
BELL_INTERVAL
));
}
#[test]
fn a_tag_names_its_terminal() {
for tab in [
CenterTab::Terminal(RemoteTerminalId(3)),
CenterTab::LocalTerminal(LocalTerminalId(12)),
] {
assert_eq!(tab_of_tag(&notice_tag(tab).unwrap()), Some(tab));
}
assert_eq!(notice_tag(CenterTab::Settings), None);
assert_eq!(tab_of_tag("terminal:local:x"), None);
assert_eq!(tab_of_tag("other:local:1"), None);
}
}
+25
View File
@@ -189,6 +189,9 @@ pub struct Workspace {
/// Editor tabs, one per file.
pub(super) editors: HashMap<EditorId, Entity<EditorPanel>>,
pub(super) next_editor_id: u64,
/// When each terminal last notified, of each kind, keyed by
/// `notices::notice_tag` and the kind.
pub(super) notice_times: HashMap<String, std::time::Instant>,
pub(super) _subscriptions: Vec<Subscription>,
}
@@ -439,6 +442,16 @@ impl Workspace {
.filter(|host| host.state.is_connected())
.map(|host| host.id)
.collect();
// A click on a terminal's system notification shows that terminal.
let this = cx.weak_entity();
let handle = window.window_handle();
cx.on_system_notification_response(move |response, cx| {
_ = handle.update(cx, |_, window, cx| {
_ = this.update(cx, |this, cx| {
this.on_notice_clicked(&response.tag, window, cx)
});
});
});
// ⌘Q (and on Windows and Linux the window's close button) asks first
// when an editor has changes not saved yet.
let this = cx.weak_entity();
@@ -534,10 +547,16 @@ impl Workspace {
focus_handle,
editors: HashMap::new(),
next_editor_id: 1,
notice_times: HashMap::new(),
_subscriptions: subscriptions,
}
}
/// The center tab in front.
pub fn active_tab(&self) -> Option<CenterTab> {
self.active_tab
}
/// The host store, for tests and for panels created later.
pub fn store(&self) -> &Entity<HostStore> {
&self.store
@@ -2151,6 +2170,9 @@ fn new_terminal_panel(
this.store
.update(cx, |store, cx| store.set_host_os(host_id, Some(os), cx));
}
TerminalPanelEvent::Notice(terminal_id, notice) => {
this.on_terminal_notice(CenterTab::Terminal(*terminal_id), notice, window, cx)
}
},
);
(panel, subscription)
@@ -2304,6 +2326,9 @@ fn new_local_terminal_panel(
cx.notify();
}
LocalTerminalPanelEvent::StatusChanged(_) => cx.notify(),
LocalTerminalPanelEvent::Notice(id, notice) => {
this.on_terminal_notice(CenterTab::LocalTerminal(*id), notice, window, cx)
}
},
);
(panel, subscription)
+111
View File
@@ -889,3 +889,114 @@ async fn a_program_copies_to_the_local_clipboard(cx: &mut TestAppContext) {
})
.await;
}
/// Have local terminal `id` print `text`: the fake shell prints back what it
/// is sent, and a paste outside bracketed paste mode sends it as it is.
fn print_into(cx: &mut TestAppContext, workspace: &Entity<Workspace>, id: u64, text: &str) {
cx.update(|cx| {
cx.write_to_clipboard(ClipboardItem::new_string(text.to_string()));
let terminal = workspace
.read(cx)
.local_terminal(LocalTerminalId(id))
.expect("the terminal is open")
.read(cx)
.terminal()
.clone();
terminal.update(cx, |terminal, cx| terminal.paste_clipboard(cx));
});
}
/// The system notifications shown once there are `count` of them.
async fn system_notifications(
cx: &mut TestAppContext,
count: usize,
) -> Vec<gpui_kit::SystemNotification> {
for _ in 0..200 {
let shown = cx.shown_system_notifications();
if shown.len() >= count {
return shown;
}
cx.executor().timer(Duration::from_millis(10)).await;
}
panic!(
"expected {count} system notifications, got {:?}",
cx.shown_system_notifications()
);
}
fn in_window(cx: &mut TestAppContext, handle: WindowHandle<Root>) -> usize {
cx.update_window(handle.into(), |_, window, cx| {
window.notifications(cx).len()
})
.unwrap()
}
#[gpui_kit::test]
async fn terminals_notify_where_the_user_will_see_it(cx: &mut TestAppContext) {
let factory = Arc::new(FakeTerminalFactory::default());
let (handle, workspace) = open_running_local_terminal(cx, factory).await;
// Test windows start out not active.
in_frame(cx, handle, |window, _| window.activate_window());
assert!(cx.shown_system_notifications().is_empty());
// In sight, a program's notice shows in the window; the bell says nothing.
print_into(cx, &workspace, 1, "\x1b]777;notify;构建;完成\x07\x07");
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.notifications(cx).len() == 1
})
.await;
cx.background_executor
.advance_clock(Duration::from_millis(200));
cx.run_until_parked();
assert_eq!(in_window(cx, handle), 1);
assert!(cx.shown_system_notifications().is_empty());
// Its notice gone, the first terminal rings behind a second one.
cx.background_executor
.advance_clock(Duration::from_secs(10));
cx.run_until_parked();
assert_eq!(in_window(cx, handle), 0);
in_frame(cx, handle, |window, cx| {
window.click("new-local-terminal", cx)
});
cx.wait_for(handle.into(), Duration::from_secs(2), |_, cx| {
workspace
.read(cx)
.local_terminal(LocalTerminalId(2))
.is_some_and(|terminal| {
terminal.read(cx).status(cx).lifecycle() == &TerminalLifecycle::Running
})
})
.await;
print_into(cx, &workspace, 1, "y/N\x07");
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.notifications(cx).len() == 1
})
.await;
// With the window away, a notice goes to the system, under the tab's
// name, and clicking it shows that terminal.
cx.update(|cx| {
assert_eq!(
workspace.read(cx).active_tab(),
Some(shellrs::app::CenterTab::LocalTerminal(LocalTerminalId(2)))
);
});
gpui_kit::VisualTestContext::from_window(handle.into(), cx).deactivate_window();
print_into(cx, &workspace, 1, "\x1b]9;部署完成\x07");
let shown = system_notifications(cx, 1).await;
assert_eq!(shown[0].tag.as_ref(), "terminal:local:1");
assert_eq!(shown[0].title.as_ref(), "本地终端 1");
assert_eq!(shown[0].body.as_ref(), "部署完成");
cx.simulate_system_notification_response(gpui_kit::SystemNotificationResponse {
tag: shown[0].tag.clone(),
action_id: None,
});
cx.run_until_parked();
cx.update(|cx| {
assert_eq!(
workspace.read(cx).active_tab(),
Some(shellrs::app::CenterTab::LocalTerminal(LocalTerminalId(1)))
);
});
}