T37: programs ask for notifications through OSC 9, 777 and 99, read as Ghostty and kitty read them, OSC 99's chunks gathered by id with base64, options, close, query and alive, and the workspace hears each; OSC 9;4's progress stands as the root record until a program reports through OSC 7501, and again after a full reset
CI / platforms (windows-latest) (push) Successful in 1m39s
CI / platforms (ubuntu-24.04) (push) Successful in 2m31s
CI / macos (push) Successful in 29m38s

This commit is contained in:
ZacharyZhang-NY committed 2026-10-17 01:23:09 -04:00
1 parent 30e65e3435
commit ef33a0d34e
12 files changed
+680 -15

No files matched your search

+1
View File
@@ -10,6 +10,7 @@ mod links;
pub mod mac;
mod marks;
pub mod mouse;
mod notify;
mod paint;
mod pointer;
mod prompts;
+318
View File
@@ -0,0 +1,318 @@
use base64::Engine;
use super::status::BASE64;
/// When a notification may show: OSC 99's `o`.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Occasion {
#[default]
Always,
Unfocused,
Invisible,
}
/// A notification a program asks for: OSC 9, 777 or 99.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Notice {
/// OSC 99's identifier, which updates, closes and answers name; none for OSC 9, 777 and OSC 99 without one.
pub id: Option<String>,
pub title: String,
pub body: String,
pub occasion: Occasion,
/// A press shows the window and session (`a=focus`, the default), and tells the program (`a=report`).
pub focus: bool,
pub report: bool,
/// The program hears of its close: `c=1`.
pub close: bool,
/// Milliseconds it lasts: `w` over 0.
pub expire: Option<u32>,
}
/// Where a program's work stands, OSC 9;4.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Progress {
Remove,
Set(Option<u8>),
Error(Option<u8>),
Indeterminate,
Pause(Option<u8>),
}
/// What a program asks of notifications and progress.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Ask {
Show(Notice),
/// Closes the notification this id names: OSC 99's `p=close`.
Close(String),
/// Which notifications are alive; the answer names this id: `p=alive`.
Alive(String),
/// What the terminal does with OSC 99; the answer names this id: `p=?`.
Query(String),
Progress(Progress),
}
/// The ask in an OSC 9 body past `9;`: ConEmu's commands, 9;4 among them, else iTerm2's notification, as Ghostty reads them.
pub fn nine(body: &[u8]) -> Option<Ask> {
let semi = |at: usize| body.get(at) == Some(&b';');
let conemu = match body {
[] => false,
[b'1', b';', ..] | [b'1', b'0'] => true,
[b'1', b'0', b';', b'0'..=b'3', ..] | [b'1', b'1', b';', ..] | [b'1', b'2', ..] => true,
[b'4', b';', state @ b'0'..=b'4', ..] => {
return Some(Ask::Progress(progress(*state, &body[3..])));
}
[b'5', ..] => true,
[b'2' | b'3' | b'6' | b'7' | b'8' | b'9', ..] => semi(1),
_ => false,
};
if conemu {
log::debug!(
"pty: OSC 9;{} is ConEmu's, no notification",
String::from_utf8_lossy(body)
);
return None;
}
let text = safe(body, "OSC 9")?;
(!text.is_empty()).then(|| Ask::Show(plain(text, String::new())))
}
/// OSC 9;4's state and the progress after it, clamped to 100.
fn progress(state: u8, rest: &[u8]) -> Progress {
let value = rest
.strip_prefix(b";")
.and_then(|digits| std::str::from_utf8(digits).ok())
.filter(|digits| !digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit()))
.and_then(|digits| digits.parse::<u64>().ok())
.map(|value| value.min(100) as u8);
match state {
b'0' => Progress::Remove,
// Set alone means 0, as in Ghostty; a value that does not read means none.
b'1' if rest.is_empty() => Progress::Set(Some(0)),
b'1' => Progress::Set(value),
b'2' => Progress::Error(value),
b'3' => Progress::Indeterminate,
_ => Progress::Pause(value),
}
}
/// The notification in an OSC 777 body past `777;`: rxvt's `notify;title;body`.
pub fn rxvt(body: &[u8]) -> Option<Ask> {
let Some(rest) = body.strip_prefix(b"notify;") else {
log::warn!(
"pty: OSC 777;{} is no rxvt notify",
String::from_utf8_lossy(body)
);
return None;
};
let Some(at) = rest.iter().position(|&byte| byte == b';') else {
log::warn!("pty: OSC 777 notify without a title");
return None;
};
let (title, body) = (
safe(&rest[..at], "OSC 777")?,
safe(&rest[at + 1..], "OSC 777")?,
);
(!title.is_empty() || !body.is_empty()).then(|| Ask::Show(plain(title, body)))
}
/// A notification with no options, the body standing in for a missing title.
fn plain(title: String, body: String) -> Notice {
Notice {
id: None,
title,
body,
occasion: Occasion::Always,
focus: true,
report: false,
close: false,
expire: None,
}
}
/// Text with no control character, else none, logged with `what` sent it.
fn safe(bytes: &[u8], what: &str) -> Option<String> {
let text = String::from_utf8(bytes.to_vec())
.inspect_err(|_| log::warn!("pty: {what} text is not UTF-8"))
.ok()?;
if text.chars().any(char::is_control) {
log::warn!("pty: {what} text holds a control character");
return None;
}
Some(text)
}
/// The answer to OSC 99's query, naming `id`: what this terminal reads of notifications.
pub fn answer(id: &str) -> String {
format!("\x1b]99;i={id}:p=?;p=title,body,?,alive:o=always\x1b\\")
}
/// The answer to OSC 99's `p=alive`, naming `id`: none, as no notification shows yet.
pub fn alive(id: &str) -> String {
format!("\x1b]99;i={id}:p=alive;\x1b\\")
}
/// An OSC 99 payload's most bytes: plain, and base64.
const PLAIN: usize = 2048;
const ENCODED: usize = 4096;
/// The most text one notification gathers from its chunks, and how many gather at once.
const GATHERED: usize = 16384;
const PENDING: usize = 32;
/// OSC 99 notifications gathered from their chunks, by id.
#[derive(Default)]
pub(crate) struct Kitty {
pending: Vec<(String, Notice)>,
}
impl Kitty {
/// The ask an OSC 99 body past `99;` completes, logged with why when there is none.
pub fn read(&mut self, body: &[u8]) -> Option<Ask> {
self.parse(body)
.inspect_err(|why| log::warn!("pty: OSC 99 dropped: {why}"))
.ok()
.flatten()
}
fn parse(&mut self, body: &[u8]) -> Result<Option<Ask>, String> {
let at = body
.iter()
.position(|&byte| byte == b';')
.ok_or("no payload")?;
let (metadata, payload) = (&body[..at], &body[at + 1..]);
let mut options: Vec<(u8, &[u8])> = Vec::new();
for pair in metadata
.split(|&byte| byte == b':')
.filter(|pair| !pair.is_empty())
{
match pair {
[key, b'=', value @ ..] if key.is_ascii_alphabetic() => options.push((*key, value)),
_ => log::debug!("pty: OSC 99 passes {} by", String::from_utf8_lossy(pair)),
}
}
let get = |key: u8| {
options
.iter()
.rev()
.find(|(name, _)| *name == key)
.map(|(_, value)| *value)
};
let id = match get(b'i') {
Some(id)
if id
.iter()
.all(|&byte| byte.is_ascii_alphanumeric() || b"_-+.".contains(&byte)) =>
{
Some(String::from_utf8(id.to_vec()).expect("ASCII"))
}
Some(id) => {
return Err(format!(
"id {} holds a byte ids may not",
String::from_utf8_lossy(id)
));
}
None => None,
};
let encoded = get(b'e') == Some(b"1");
let limit = if encoded { ENCODED } else { PLAIN };
if payload.len() > limit {
return Err(format!("a payload over {limit} bytes"));
}
let text = if encoded {
let bytes = BASE64
.decode(payload)
.map_err(|error| format!("payload is no base64: {error}"))?;
String::from_utf8(bytes).map_err(|_| "payload is not UTF-8")?
} else {
String::from_utf8(payload.to_vec()).map_err(|_| "payload is not UTF-8")?
};
if text.chars().any(char::is_control) {
return Err("payload holds a control character".into());
}
// An answer names the asker's id, or 0 without one, as kitty's spec says.
let named = || id.clone().unwrap_or_else(|| "0".into());
match get(b'p').unwrap_or(b"title") {
b"?" => return Ok(Some(Ask::Query(named()))),
b"alive" => return Ok(Some(Ask::Alive(named()))),
b"close" => return Ok(id.map(Ask::Close)),
b"title" | b"body" => {}
other => {
log::debug!(
"pty: OSC 99 payload {} passed by",
String::from_utf8_lossy(other)
);
return Ok(None);
}
}
let key = id.clone().unwrap_or_default();
let at = match self.pending.iter().position(|(name, _)| *name == key) {
Some(at) => at,
None if self.pending.len() == PENDING => {
return Err(format!("{PENDING} notifications gathering already"));
}
None => {
self.pending.push((
key.clone(),
Notice {
id: id.clone(),
..plain(String::new(), String::new())
},
));
self.pending.len() - 1
}
};
let notice = &mut self.pending[at].1;
if get(b'p') == Some(b"body") {
notice.body.push_str(&text);
} else {
notice.title.push_str(&text);
}
if notice.title.len() + notice.body.len() > GATHERED {
self.pending.remove(at);
return Err(format!("a notification over {GATHERED} bytes"));
}
if let Some(occasion) = get(b'o') {
notice.occasion = match occasion {
b"unfocused" => Occasion::Unfocused,
b"invisible" => Occasion::Invisible,
_ => Occasion::Always,
};
}
if let Some(actions) = get(b'a') {
for action in actions.split(|&byte| byte == b',') {
let (on, name) = match action.strip_prefix(b"-") {
Some(name) => (false, name),
None => (true, action),
};
match name {
b"focus" => notice.focus = on,
b"report" => notice.report = on,
_ => {}
}
}
}
if let Some(close) = get(b'c') {
notice.close = close == b"1";
}
if let Some(expire) = get(b'w') {
notice.expire = std::str::from_utf8(expire)
.ok()
.and_then(|ms| ms.parse::<u32>().ok())
.filter(|&ms| ms > 0);
}
if get(b'd') == Some(b"0") {
return Ok(None);
}
let (_, mut notice) = self.pending.remove(at);
if notice.title.is_empty() && notice.body.is_empty() {
return Ok(None);
}
if notice.title.is_empty() {
notice.title = std::mem::take(&mut notice.body);
}
Ok(Some(Ask::Show(notice)))
}
}
#[cfg(test)]
mod tests;
+198
View File
@@ -0,0 +1,198 @@
use super::*;
fn shown(ask: Option<Ask>) -> Notice {
match ask {
Some(Ask::Show(notice)) => notice,
other => panic!("no notification: {other:?}"),
}
}
#[test]
fn osc_9_is_iterm2_s_notification_unless_it_is_conemu_s_command() {
assert_eq!(shown(nine(b"Hello world")).title, "Hello world");
for command in [
&b"1;100"[..],
b"10",
b"10;2",
b"11;note",
b"12",
b"2;box",
b"3;",
b"3;title",
b"5",
b"5x",
b"6;macro",
b"7;cmd",
b"8;VAR",
b"9;/path",
] {
assert_eq!(nine(command), None, "{}", String::from_utf8_lossy(command));
}
for text in [
"1", "1a", "10x", "10;5", "13", "2", "2a", "4", "4;", "4;9", "x;y",
] {
assert_eq!(shown(nine(text.as_bytes())).title, text);
}
assert_eq!(nine(b""), None);
assert_eq!(nine(b"a\xffb"), None, "not UTF-8");
}
#[test]
fn osc_9_4_reads_progress_as_ghostty_does() {
let progress = |body: &[u8]| match nine(body) {
Some(Ask::Progress(progress)) => progress,
other => panic!("no progress: {other:?}"),
};
assert_eq!(progress(b"4;0"), Progress::Remove);
assert_eq!(progress(b"4;1"), Progress::Set(Some(0)));
assert_eq!(progress(b"4;1;40"), Progress::Set(Some(40)));
assert_eq!(progress(b"4;1;900"), Progress::Set(Some(100)));
assert_eq!(progress(b"4;1;x"), Progress::Set(None));
assert_eq!(
progress(b"4;1;99999999999999999999999"),
Progress::Set(None)
);
assert_eq!(progress(b"4;1;1_0"), Progress::Set(None));
assert_eq!(progress(b"4;2"), Progress::Error(None));
assert_eq!(progress(b"4;2;50"), Progress::Error(Some(50)));
assert_eq!(progress(b"4;3;7"), Progress::Indeterminate);
assert_eq!(progress(b"4;4;10"), Progress::Pause(Some(10)));
}
#[test]
fn osc_777_is_rxvt_s_notify() {
let notice = shown(rxvt(b"notify;Title;Body;more"));
assert_eq!(
(notice.title.as_str(), notice.body.as_str()),
("Title", "Body;more")
);
assert_eq!(rxvt(b"other;x;y"), None);
assert_eq!(rxvt(b"notify;Title"), None, "no body");
assert_eq!(rxvt(b"notify;;"), None, "nothing to say");
}
#[test]
fn osc_99_gathers_chunks_by_id_and_reads_its_options() {
let mut kitty = Kitty::default();
assert_eq!(kitty.read(b"i=a:d=0;Hel"), None);
assert_eq!(kitty.read(b"i=b:d=0:p=body;other"), None);
assert_eq!(kitty.read(b"i=a:d=0:p=body;Wor"), None);
let notice = shown(kitty.read(b"i=a:p=body:o=unfocused:a=report,-focus:c=1:w=5000;ld"));
assert_eq!(
(
notice.id.as_deref(),
notice.title.as_str(),
notice.body.as_str()
),
(Some("a"), "Hel", "World")
);
assert_eq!(
(
notice.occasion,
notice.focus,
notice.report,
notice.close,
notice.expire
),
(Occasion::Unfocused, false, true, true, Some(5000))
);
let other = shown(kitty.read(b"i=b:p=body;!"));
assert_eq!(
(other.title.as_str(), other.body.as_str()),
("other!", ""),
"a lone body is the title"
);
}
#[test]
fn osc_99_decodes_base64_and_takes_plain_text() {
let mut kitty = Kitty::default();
let notice = shown(kitty.read(b"e=1:p=body;SGVsbG8gd29ybGQ="));
assert_eq!((notice.title.as_str(), notice.id), ("Hello world", None));
let plain = shown(kitty.read(b";Hello world"));
assert_eq!(
(plain.title.as_str(), plain.focus, plain.report),
("Hello world", true, false)
);
assert_eq!(
kitty.read(b"bad:i=c;x").map(|_| ()),
Some(()),
"a malformed pair passes by"
);
}
#[test]
fn osc_99_asks_to_close_and_what_is_alive_and_supported() {
let mut kitty = Kitty::default();
assert_eq!(kitty.read(b"i=x:p=close;"), Some(Ask::Close("x".into())));
assert_eq!(kitty.read(b"p=close;"), None, "no id, no close");
assert_eq!(kitty.read(b"i=q:p=?;"), Some(Ask::Query("q".into())));
assert_eq!(kitty.read(b"p=?;"), Some(Ask::Query("0".into())));
assert_eq!(kitty.read(b"i=y:p=alive;"), Some(Ask::Alive("y".into())));
assert_eq!(
kitty.read(b"p=icon;x"),
None,
"an unknown payload passes by"
);
}
#[test]
fn osc_99_drops_what_breaks_its_rules() {
let mut kitty = Kitty::default();
let long = |length: usize, metadata: &str| {
let mut body = format!("{metadata};").into_bytes();
body.extend(std::iter::repeat_n(b'a', length));
body
};
for body in [
b"no payload".to_vec(),
b"i=a b;x".to_vec(),
b"i=a\"b;x".to_vec(),
b"e=1;!!!".to_vec(),
b"e=1;/w==".to_vec(),
"x\u{85}y"
.as_bytes()
.iter()
.copied()
.fold(b";".to_vec(), |mut body, byte| {
body.push(byte);
body
}),
long(PLAIN + 1, ""),
long(ENCODED + 4, "e=1"),
] {
assert_eq!(
kitty.read(&body),
None,
"{}",
String::from_utf8_lossy(&body)
);
}
for at in 0..PENDING {
assert_eq!(kitty.read(format!("i=p{at}:d=0;x").as_bytes()), None);
}
assert_eq!(kitty.read(b"i=over:d=0;x"), None);
assert!(
kitty.pending.iter().all(|(id, _)| id != "over"),
"past the cap nothing gathers"
);
let mut big = Kitty::default();
for _ in 0..GATHERED / PLAIN {
assert_eq!(big.read(&long(PLAIN, "i=g:d=0")), None);
}
assert_eq!(
big.read(&long(PLAIN, "i=g")),
None,
"over the gathered bound"
);
assert!(big.pending.is_empty());
}
#[test]
fn the_answers_name_the_asker_s_id() {
assert_eq!(
answer("q"),
"\x1b]99;i=q:p=?;p=title,body,?,alive:o=always\x1b\\"
);
assert_eq!(alive("0"), "\x1b]99;i=0:p=alive;\x1b\\");
}
+3 -1
View File
@@ -11,7 +11,7 @@ use alacritty_terminal::{
};
use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender, unbounded};
use super::{marks::Mark, mouse::MouseMode, status::Status, tap::Tapped};
use super::{marks::Mark, mouse::MouseMode, notify::Ask, status::Status, tap::Tapped};
use crate::settings::{CursorStyle, Settings};
/// What a session hears from its pty.
@@ -23,6 +23,8 @@ pub enum PtyEvent {
Mark(Mark),
/// A program's status, OSC 7501.
Status(Status),
/// A notification or progress, OSC 9, 777 or 99.
Ask(Ask),
/// A full reset, `ESC c`.
Reset,
}
+33 -5
View File
@@ -5,13 +5,18 @@ use alacritty_terminal::{
use futures::StreamExt;
use super::*;
use crate::term::status::{Record, State};
use crate::term::{
notify::Ask,
status::{Record, State},
};
struct Ran {
folders: Vec<PathBuf>,
marks: Vec<Mark>,
/// What programs said of their status, a full reset as none.
statuses: Vec<Option<Status>>,
/// Notifications and progress programs asked for.
asks: Vec<Ask>,
text: String,
/// The first line's cells that carry a prompt's link.
prompt: Vec<usize>,
@@ -30,21 +35,22 @@ fn run(script: &str, size: (usize, usize)) -> Ran {
};
let (pty, mut events) =
spawn(&launch, size, (8, 16), config(&Settings::default())).expect("sh starts");
let (folders, marks, statuses, success) = futures::executor::block_on(async {
let (mut folders, mut marks, mut statuses, mut success) =
(Vec::new(), Vec::new(), Vec::new(), false);
let (folders, marks, statuses, asks, success) = futures::executor::block_on(async {
let (mut folders, mut marks, mut statuses, mut asks, mut success) =
(Vec::new(), Vec::new(), Vec::new(), Vec::new(), false);
while let Some(event) = events.next().await {
match event {
PtyEvent::Folder(folder) => folders.push(folder),
PtyEvent::Mark(mark) => marks.push(mark),
PtyEvent::Status(status) => statuses.push(Some(status)),
PtyEvent::Reset => statuses.push(None),
PtyEvent::Ask(ask) => asks.push(ask),
PtyEvent::Term(Event::ChildExit(status)) => success = status.success(),
PtyEvent::Term(Event::Exit) => break,
PtyEvent::Term(_) | PtyEvent::Mouse(_) => {}
}
}
(folders, marks, statuses, success)
(folders, marks, statuses, asks, success)
});
let term = pty.term.lock();
let row = &term.grid()[Line(0)];
@@ -58,6 +64,7 @@ fn run(script: &str, size: (usize, usize)) -> Ran {
folders,
marks,
statuses,
asks,
text: text.trim_end().to_string(),
prompt: prompt.collect(),
success,
@@ -119,6 +126,27 @@ fn osc_7501_and_a_full_reset_reach_the_session_in_order() {
assert_eq!(ran.text, "x");
}
#[test]
fn notifications_and_progress_reach_the_session_in_order() {
let ran = run(
"printf '\\033]9;Hello\\007\\033]777;notify;T;B\\007\\033]99;i=a:d=0;Hel\\033\\\\\\033]99;i=a;lo\\033\\\\\\033]9;4;1;40\\007x'",
(40, 4),
);
let titles: Vec<String> = ran
.asks
.iter()
.map(|ask| match ask {
Ask::Show(notice) => format!("{}/{}", notice.title, notice.body),
other => format!("{other:?}"),
})
.collect();
assert_eq!(
titles,
["Hello/", "T/B", "Hello/", "Progress(Set(Some(40)))"]
);
assert_eq!(ran.text, "x");
}
#[test]
fn a_long_escape_of_controls_keeps_the_pty_reading() {
let ran = run(
+44 -3
View File
@@ -4,6 +4,8 @@ use base64::{
engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig},
};
use super::notify::Progress;
/// What a program says it is doing, OSC 7501.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum State {
@@ -59,7 +61,7 @@ const LEVELS: usize = 8;
pub const RECORDS: usize = 256;
/// Standard base64, its padding optional.
const BASE64: GeneralPurpose = GeneralPurpose::new(
pub(super) const BASE64: GeneralPurpose = GeneralPurpose::new(
&STANDARD,
GeneralPurposeConfig::new().with_decode_padding_mode(DecodePaddingMode::Indifferent),
);
@@ -191,11 +193,49 @@ fn text(value: &str, key: &str, encoded: usize, decoded: usize) -> Result<String
pub struct Records {
list: Vec<(String, Record, u64)>,
clock: u64,
/// A program has reported through OSC 7501 since the last full reset, so OSC 9;4 maps no more.
reported: bool,
}
impl Records {
/// Replaces the record at `id` whole.
/// A program's report: replaces the record at `id` whole.
pub fn report(&mut self, id: String, record: Record) {
self.reported = true;
self.put(id, record);
}
/// OSC 9;4 as the root record, until a program reports through OSC 7501.
pub fn progress(&mut self, progress: Progress) {
if self.reported {
log::debug!("status: OSC 9;4 passed by, a program reports through OSC 7501");
return;
}
let root = |state, progress| Record {
state,
kind: None,
progress,
app: None,
title: None,
msg: None,
};
match progress {
Progress::Remove => self.list.retain(|(id, ..)| !id.is_empty()),
// Paused work is work still, its progress kept.
Progress::Set(value) | Progress::Pause(value) => {
self.put(String::new(), root(State::Working, value))
}
Progress::Indeterminate => self.put(String::new(), root(State::Working, None)),
Progress::Error(_) => self.put(String::new(), root(State::Error, None)),
}
}
/// A full reset: every record goes, and OSC 9;4 maps again.
pub fn reset(&mut self) {
self.list.clear();
self.reported = false;
}
fn put(&mut self, id: String, record: Record) {
self.clock += 1;
if let Some(entry) = self.list.iter_mut().find(|(at, ..)| *at == id) {
*entry = (id, record, self.clock);
@@ -211,8 +251,9 @@ impl Records {
self.list.push((id, record, self.clock));
}
/// Drops the record at `id` and every one under it; with none, all.
/// A program's clear: drops the record at `id` and every one under it; with none, all.
pub fn clear(&mut self, id: Option<&str>) {
self.reported = true;
match id {
None => self.list.clear(),
Some(id) => {
+34
View File
@@ -1,4 +1,5 @@
use super::*;
use crate::term::notify::Progress;
fn encoded(text: &[u8]) -> String {
BASE64.encode(text)
@@ -209,3 +210,36 @@ fn the_sequence_counts_from_osc_through_st() {
assert_eq!(read(b"state=done", 4097), None);
assert!(read(b"state=done", 4096).is_some());
}
#[test]
fn osc_9_4_maps_to_the_root_record_until_a_program_reports() {
let root = |records: &Records| {
records
.list
.iter()
.find(|(id, ..)| id.is_empty())
.map(|(_, record, _)| (record.state, record.progress))
};
let mut records = Records::default();
records.progress(Progress::Set(Some(40)));
assert_eq!(root(&records), Some((State::Working, Some(40))));
records.progress(Progress::Pause(Some(5)));
assert_eq!(root(&records), Some((State::Working, Some(5))));
records.progress(Progress::Indeterminate);
assert_eq!(root(&records), Some((State::Working, None)));
records.progress(Progress::Error(Some(9)));
assert_eq!(root(&records), Some((State::Error, None)));
records.progress(Progress::Remove);
assert_eq!(root(&records), None);
records.report("a".into(), record(State::Idle, None));
records.progress(Progress::Set(Some(1)));
assert_eq!(root(&records), None, "a program reports through OSC 7501");
records.reset();
assert!(records.list.is_empty());
records.progress(Progress::Set(Some(2)));
assert_eq!(
root(&records),
Some((State::Working, Some(2))),
"a full reset maps again"
);
}
+27 -4
View File
@@ -10,6 +10,7 @@ use polling::{Event, PollMode, Poller};
use super::{
marks,
mouse::MouseMode,
notify::{self, Kitty},
prompts::{self, Prompts},
pty::PtyEvent,
status,
@@ -24,6 +25,7 @@ pub(crate) struct Tapped<P> {
scanner: Osc,
mouse: MouseModes,
prompts: Prompts,
kitty: Kitty,
events: UnboundedSender<PtyEvent>,
pid: u32,
last: Option<PathBuf>,
@@ -36,6 +38,7 @@ impl<P> Tapped<P> {
scanner: Osc::default(),
mouse: MouseModes::default(),
prompts: Prompts::default(),
kitty: Kitty::default(),
events,
pid,
last: None,
@@ -55,9 +58,9 @@ impl<P: EventedReadWrite> io::Read for Tapped<P> {
}
let count = self.inner.reader().read(&mut buf[..take])?;
let read = &buf[..count];
let (events, pid, last) = (&self.events, self.pid, &mut self.last);
let (events, pid, last, kitty) = (&self.events, self.pid, &mut self.last, &mut self.kitty);
self.scanner
.feed(read, &mut |found| report(found, events, pid, last));
.feed(read, &mut |found| report(found, events, pid, last, kitty));
if let Some(mode) = self.mouse.feed(read) {
log::info!("pty {pid}: mouse {mode:?}");
if events.unbounded_send(PtyEvent::Mouse(mode)).is_err() {
@@ -70,8 +73,14 @@ impl<P: EventedReadWrite> io::Read for Tapped<P> {
}
}
/// Sends the session what the scanner found: a full reset, a mark, a program's status or a folder.
fn report(found: Found, events: &UnboundedSender<PtyEvent>, pid: u32, last: &mut Option<PathBuf>) {
/// Sends the session what the scanner found: a full reset, a mark, a program's status or ask, or a folder.
fn report(
found: Found,
events: &UnboundedSender<PtyEvent>,
pid: u32,
last: &mut Option<PathBuf>,
kitty: &mut Kitty,
) {
let event = match found {
Found::Reset => {
log::debug!("pty {pid}: a full reset");
@@ -87,6 +96,9 @@ fn report(found: Found, events: &UnboundedSender<PtyEvent>, pid: u32, last: &mut
{
log::debug!("pty {pid}: {status:?}");
PtyEvent::Status(status)
} else if let Some(ask) = ask(body, kitty) {
log::debug!("pty {pid}: {ask:?}");
PtyEvent::Ask(ask)
} else if let Some(folder) = body.strip_prefix(b"7;").and_then(folder) {
if last.as_ref() == Some(&folder) {
log::debug!("pty {pid}: folder {} again", folder.display());
@@ -105,6 +117,17 @@ fn report(found: Found, events: &UnboundedSender<PtyEvent>, pid: u32, last: &mut
}
}
/// A notification or progress an OSC 99, 777 or 9 body asks for.
fn ask(body: &[u8], kitty: &mut Kitty) -> Option<notify::Ask> {
if let Some(rest) = body.strip_prefix(b"99;") {
kitty.read(rest)
} else if let Some(rest) = body.strip_prefix(b"777;") {
notify::rxvt(rest)
} else {
body.strip_prefix(b"9;").and_then(notify::nine)
}
}
impl<P: EventedReadWrite> EventedReadWrite for Tapped<P> {
type Reader = Self;
type Writer = P::Writer;
+15 -1
View File
@@ -24,6 +24,7 @@ use super::{
input::{self, Find, NextPrompt, PreviousPrompt},
marks::Mark,
mouse::MouseMode,
notify::{self, Ask},
paint::{self, Look},
pointer::Pointer,
pty::{Events, Pty, PtyEvent, config, fit},
@@ -52,6 +53,8 @@ pub enum TerminalEvent {
Bell,
/// A command the shell ran ended, with the status it gave.
Finished(Option<i32>),
/// A notification to show or close, OSC 9, 777 or 99.
Notice(Ask),
Exited(ExitStatus),
}
@@ -230,7 +233,8 @@ impl TerminalView {
PtyEvent::Mark(Mark::Prompt) => self.records.prompt(),
PtyEvent::Mark(_) => {}
PtyEvent::Status(status) => self.status(status),
PtyEvent::Reset => self.records.clear(None),
PtyEvent::Ask(ask) => self.ask(ask, cx),
PtyEvent::Reset => self.records.reset(),
}
}
// ponytail: a full search per output batch while find is open; search only new lines if it lags.
@@ -249,6 +253,16 @@ impl TerminalView {
}
}
/// Maps progress, answers queries, and hands notifications on.
fn ask(&mut self, ask: Ask, cx: &mut Context<Self>) {
match ask {
Ask::Progress(progress) => self.records.progress(progress),
Ask::Query(id) => self.pty.write(notify::answer(&id).into_bytes()),
Ask::Alive(id) => self.pty.write(notify::alive(&id).into_bytes()),
ask @ (Ask::Show(_) | Ask::Close(_)) => cx.emit(TerminalEvent::Notice(ask)),
}
}
fn term_event(&mut self, event: Event, cx: &mut Context<Self>) {
match event {
Event::Wakeup => self.shown = true,
+4
View File
@@ -46,6 +46,10 @@ impl Workspace {
}
session.status = *status;
}
TerminalEvent::Notice(ask) => {
let pid = session.terminal.read(cx).pid();
log::info!("app: session {pid} asks {ask:?}");
}
TerminalEvent::Exited(status) => {
let pid = session.terminal.read(cx).pid();
log::info!("app: session {pid}'s program ended, {status}");