diff options
| author | user <user@clank> | 2026-08-12 14:39:46 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-12 14:39:46 +0200 |
| commit | ee50f2661a141040f7f8579efe1283a0ea5b644c (patch) | |
| tree | 9888e41079285aeee08941a71979d84084c9966c /zellij-server | |
| parent | sl0p: separate the hover resize hint from the hover highlight (diff) | |
| download | sl0ppij-ee50f2661a141040f7f8579efe1283a0ea5b644c.tar.gz sl0ppij-ee50f2661a141040f7f8579efe1283a0ea5b644c.tar.xz sl0ppij-ee50f2661a141040f7f8579efe1283a0ea5b644c.zip | |
sl0p: per-pane status bar via OSC 5577 (experimental)
First slice of the status/action frame: one row of program-supplied text in a
pane's bottom frame row.
printf '\033]5577;1;status;building 3/7\033\\'
printf '\033]5577;1;clear\033\\'
Rendered in the EXISTING frame row rather than in reserved rows. Reserving would
shrink the pane's content area and resize its PTY whenever the bar appeared or
disappeared, firing SIGWINCH at the program that asked for it -- the same churn
that made the stack-list picker use a fixed cap. The frame row is already a text
surface with a precedence chain, so this cost no geometry work at all.
State lives on Grid like the window title, so it survives re-renders, resizes
and detach/reattach with no draining. Session resurrection is NOT covered yet.
Versioned from the start, and unknown versions or verbs are ignored, so the
planned button verbs cannot be half-interpreted by an older build. Text is the
remaining params rejoined with ';', which means a single text field needs no
escaping -- the trick OSC 0/2 already uses for titles.
Treated as untrusted, because it is: an in-band sequence is emitted just as
readily by `cat` of a hostile file as by a cooperating program. Control
characters are stripped -- a raw ESC could otherwise repaint anything on screen,
a newline would corrupt the frame row -- and the length is capped, since the
string is retained for the life of the pane.
Diffstat (limited to 'zellij-server')
| -rw-r--r-- | zellij-server/src/panes/grid.rs | 63 | ||||
| -rw-r--r-- | zellij-server/src/panes/terminal_pane.rs | 3 | ||||
| -rw-r--r-- | zellij-server/src/panes/unit/grid_tests.rs | 111 | ||||
| -rw-r--r-- | zellij-server/src/tab/mod.rs | 5 | ||||
| -rw-r--r-- | zellij-server/src/ui/pane_boundaries_frame.rs | 63 | ||||
| -rw-r--r-- | zellij-server/src/ui/pane_contents_and_ui.rs | 2 |
6 files changed, 246 insertions, 1 deletions
diff --git a/zellij-server/src/panes/grid.rs b/zellij-server/src/panes/grid.rs index 62ef0e10..da937456 100644 --- a/zellij-server/src/panes/grid.rs +++ b/zellij-server/src/panes/grid.rs @@ -408,6 +408,28 @@ fn subtract_isize_from_usize(u: usize, i: isize) -> usize { } } +/// Longest status bar text we keep. A pane can only ever show one row of it, and +/// the string is held for the life of the pane, so there is no reason to retain +/// megabytes because a program looped. +const MAX_STATUS_BAR_TEXT: usize = 512; + +/// Make program-supplied status text safe to draw. +/// +/// The payload arrives in-band from whatever is running in the pane -- which +/// includes `cat` of a file someone else wrote -- so it is untrusted. Control +/// characters are dropped rather than escaped: a stray ESC would let the payload +/// break out of the frame row and repaint anything it liked, and a newline or +/// carriage return would corrupt the row. Truncation is by characters, not bytes, +/// so it cannot split a multi-byte one. +pub fn sanitize_status_bar_text(raw: &str) -> String { + raw.chars() + .filter(|c| !c.is_control()) + .take(MAX_STATUS_BAR_TEXT) + .collect::<String>() + .trim() + .to_owned() +} + pub fn parse_osc7_path(raw: &[u8]) -> Option<std::path::PathBuf> { let s = std::str::from_utf8(raw).ok()?; let rest = s.strip_prefix("file://")?; @@ -701,6 +723,10 @@ pub struct Grid { pub pending_messages_to_pty: Vec<Vec<u8>>, pub selection: Selection, pub title: Option<String>, + /// Text the program in this pane asked to show in its bottom frame row, via + /// OSC 5577. Persistent like `title` rather than drained, so it survives + /// re-renders, resizes and detach/reattach for as long as the pane lives. + pub status_bar: Option<String>, pub is_scrolled: bool, pub link_handler: Rc<RefCell<LinkHandler>>, pub ring_bell: bool, @@ -1061,6 +1087,7 @@ impl Grid { selection: Default::default(), title_stack: vec![], title: None, + status_bar: None, changed_colors: None, is_scrolled: false, link_handler, @@ -4099,6 +4126,42 @@ impl Perform for Grid { } }, + // sl0p fork: per-pane status bar, rendered in the pane's bottom + // frame row. + // + // OSC 5577 ; 1 ; status ; <text> set the text + // OSC 5577 ; 1 ; clear remove it + // + // Versioned from the start, and anything with an unknown version or + // verb is ignored, so later additions (buttons) cannot be + // misinterpreted by an older build. + // + // The text is the remaining params rejoined with ';', which means a + // single text field needs no escaping at all -- exactly how OSC 0/2 + // handles titles above. Structured payloads will need a real + // escaping rule; this deliberately does not pretend to have one. + b"5577" => { + let version = params.get(1).copied(); + if version != Some(b"1".as_slice()) { + return; + } + match params.get(2).copied() { + Some(b"status") => { + let text = params[3..] + .iter() + .flat_map(|p| str::from_utf8(p)) + .collect::<Vec<&str>>() + .join(";"); + let text = sanitize_status_bar_text(&text); + self.status_bar = if text.is_empty() { None } else { Some(text) }; + }, + Some(b"clear") => { + self.status_bar = None; + }, + _ => {}, + } + }, + // Set color index. b"4" => { for chunk in params[1..].chunks(2) { diff --git a/zellij-server/src/panes/terminal_pane.rs b/zellij-server/src/panes/terminal_pane.rs index 8085e6be..7ce5f263 100644 --- a/zellij-server/src/panes/terminal_pane.rs +++ b/zellij-server/src/panes/terminal_pane.rs @@ -1238,6 +1238,9 @@ impl Pane for TerminalPane { } self.pane_title.clone() } + fn status_bar_text(&self) -> Option<String> { + self.grid.status_bar.clone() + } fn stack_list_entry_label(&self) -> String { // `hide_default_pane_names` does not reach current_title() any more, so // this gets the "Pane #N" placeholder rather than an empty string. Kept diff --git a/zellij-server/src/panes/unit/grid_tests.rs b/zellij-server/src/panes/unit/grid_tests.rs index a58b55cb..ad85d142 100644 --- a/zellij-server/src/panes/unit/grid_tests.rs +++ b/zellij-server/src/panes/unit/grid_tests.rs @@ -8303,3 +8303,114 @@ fn kitty_placement_rejoins_its_text_after_editing_while_scrolled_back() { "the image must rejoin its marker line once the viewport is restored" ); } + +// --- sl0p: OSC 5577, the per-pane status bar ---------------------------- + +#[cfg(test)] +fn grid_for_status_bar_tests() -> Grid { + Grid::new( + 20, + 80, + Rc::new(RefCell::new(Palette::default())), + Rc::new(RefCell::new(HashMap::new())), + Rc::new(RefCell::new(LinkHandler::new())), + Rc::new(RefCell::new(None)), + Rc::new(RefCell::new(SixelImageStore::default())), + Rc::new(RefCell::new(KittyImageStore::default())), + Style::default(), + false, + true, + true, + true, + false, + ) +} + +#[cfg(test)] +fn feed(grid: &mut Grid, bytes: &str) { + let mut vte_parser = vte::Parser::new(); + vte_parser.advance(grid, bytes.as_bytes()); +} + +#[test] +fn sl0p_osc_5577_sets_and_clears_the_status_bar() { + let mut grid = grid_for_status_bar_tests(); + assert_eq!(grid.status_bar, None, "nothing shows until asked"); + + feed(&mut grid, "\u{1b}]5577;1;status;building 3/7\u{1b}\\"); + assert_eq!(grid.status_bar.as_deref(), Some("building 3/7")); + + // a later set replaces + feed(&mut grid, "\u{1b}]5577;1;status;done\u{1b}\\"); + assert_eq!(grid.status_bar.as_deref(), Some("done")); + + feed(&mut grid, "\u{1b}]5577;1;clear\u{1b}\\"); + assert_eq!(grid.status_bar, None, "clear should remove it"); + + // an empty text is a clear too, rather than an empty bar + feed(&mut grid, "\u{1b}]5577;1;status;x\u{1b}\\"); + feed(&mut grid, "\u{1b}]5577;1;status;\u{1b}\\"); + assert_eq!(grid.status_bar, None); +} + +#[test] +fn sl0p_osc_5577_keeps_semicolons_in_the_text() { + // The text is the remaining params rejoined, so a single text field needs no + // escaping -- the same trick OSC 0/2 uses for titles. + let mut grid = grid_for_status_bar_tests(); + feed(&mut grid, "\u{1b}]5577;1;status;a;b;c\u{1b}\\"); + assert_eq!(grid.status_bar.as_deref(), Some("a;b;c")); +} + +#[test] +fn sl0p_osc_5577_ignores_unknown_versions_and_verbs() { + // Forward compatibility: an older build must ignore payloads it does not + // understand rather than half-interpret them. + let mut grid = grid_for_status_bar_tests(); + feed(&mut grid, "\u{1b}]5577;2;status;from the future\u{1b}\\"); + assert_eq!(grid.status_bar, None, "unknown version must be ignored"); + + feed(&mut grid, "\u{1b}]5577;1;button;0;Deploy\u{1b}\\"); + assert_eq!(grid.status_bar, None, "unknown verb must be ignored"); + + feed(&mut grid, "\u{1b}]5577\u{1b}\\"); + assert_eq!(grid.status_bar, None, "a truncated payload must not panic"); +} + +#[test] +fn sl0p_osc_5577_text_cannot_inject_escapes_or_break_the_row() { + // The payload is in-band and untrusted -- `cat` of a hostile file emits it + // just as well as a cooperating program. A raw ESC would let it repaint + // anything it liked; a newline would corrupt the frame row. + let mut grid = grid_for_status_bar_tests(); + feed( + &mut grid, + "\u{1b}]5577;1;status;ok\u{07}\u{1b}[31mred\u{1b}\\", + ); + let text = grid.status_bar.clone().unwrap_or_default(); + assert!( + !text.contains('\u{1b}') && !text.contains('\u{07}'), + "control characters must be stripped, got {:?}", + text + ); + + let mut grid = grid_for_status_bar_tests(); + feed(&mut grid, "\u{1b}]5577;1;status;line1\nline2\r\u{1b}\\"); + let text = grid.status_bar.clone().unwrap_or_default(); + assert!( + !text.contains('\n') && !text.contains('\r'), + "newlines must be stripped, got {:?}", + text + ); +} + +#[test] +fn sl0p_osc_5577_text_is_length_capped() { + // Held for the life of the pane, so a looping program must not be able to + // make us retain an unbounded string. + let mut grid = grid_for_status_bar_tests(); + let long = "x".repeat(5000); + feed(&mut grid, &format!("\u{1b}]5577;1;status;{}\u{1b}\\", long)); + let len = grid.status_bar.as_deref().map(|t| t.chars().count()).unwrap_or(0); + assert!(len > 0 && len <= 512, "expected a capped length, got {}", len); +} diff --git a/zellij-server/src/tab/mod.rs b/zellij-server/src/tab/mod.rs index f5c30853..88528229 100644 --- a/zellij-server/src/tab/mod.rs +++ b/zellij-server/src/tab/mod.rs @@ -885,6 +885,11 @@ pub trait Pane { fn start_loading_indication(&mut self, _loading_indication: LoadingIndication) {} // only relevant for plugins fn progress_animation_offset(&mut self) {} // only relevant for plugins fn current_title(&self) -> String; + /// Text the program asked to show in this pane's bottom frame row, if any. + /// See OSC 5577 in grid.rs. + fn status_bar_text(&self) -> Option<String> { + None + } fn stack_list_entry_label(&self) -> String { self.current_title() } diff --git a/zellij-server/src/ui/pane_boundaries_frame.rs b/zellij-server/src/ui/pane_boundaries_frame.rs index ba6258f6..d2f1d887 100644 --- a/zellij-server/src/ui/pane_boundaries_frame.rs +++ b/zellij-server/src/ui/pane_boundaries_frame.rs @@ -178,6 +178,8 @@ pub struct FrameParams { pub mouse_is_hovering_over_pane: bool, pub pane_is_selectable: bool, pub show_help_text: bool, + /// Program-supplied text for the bottom frame row (OSC 5577). + pub status_bar: Option<String>, pub highlight_tooltip: Option<String>, pub omit_title: bool, pub frame_geom_override: Option<PaneGeom>, @@ -211,6 +213,7 @@ pub struct PaneFrame { mouse_is_hovering_over_pane: bool, is_selectable: bool, show_help_text: bool, + status_bar: Option<String>, highlight_tooltip: Option<String>, omit_title: bool, stack_list_entry: Option<StackListEntry>, @@ -249,6 +252,7 @@ impl PaneFrame { mouse_is_hovering_over_pane: frame_params.mouse_is_hovering_over_pane, is_selectable: frame_params.pane_is_selectable, show_help_text: frame_params.show_help_text, + status_bar: frame_params.status_bar, highlight_tooltip: frame_params.highlight_tooltip, omit_title: frame_params.omit_title, stack_list_entry: frame_params.stack_list_entry, @@ -1287,6 +1291,49 @@ impl PaneFrame { ret.append(&mut right_boundary); Ok(ret) } + /// Render the program-supplied status text into the pane's bottom frame row. + /// + /// Deliberately reuses the frame row rather than reserving a row of its own: + /// reserving would shrink the pane's content area and so resize its PTY every + /// time the bar appeared or disappeared, firing SIGWINCH at the program that + /// asked for it. The frame row costs nothing and is already a text surface. + fn render_status_bar_undertitle(&self, text: &str) -> Result<Vec<TerminalCharacter>> { + let err_context = || "failed to render status bar undertitle"; + let max_undertitle_length = self.geom.cols.saturating_sub(2); + let mut left_boundary = + foreground_color(self.get_corner(boundary_type::BOTTOM_LEFT), self.color); + let mut right_boundary = + foreground_color(self.get_corner(boundary_type::BOTTOM_RIGHT), self.color); + + // one leading space so the text does not touch the corner glyph + let label = format!(" {} ", text); + let label = if label.width() > max_undertitle_length { + let budget = max_undertitle_length.saturating_sub(2); + let mut truncated = String::from(" "); + for character in text.chars() { + if truncated.width() + character.width().unwrap_or(0) > budget { + break; + } + truncated.push(character); + } + truncated.push('…'); + truncated + } else { + label + }; + let label_len = label.width(); + let mut label_characters = foreground_color(&label, self.color); + let mut ret = vec![]; + ret.append(&mut left_boundary); + ret.append(&mut label_characters); + for _ in label_len..max_undertitle_length { + ret.push(foreground_color(boundary_type::HORIZONTAL, self.color) + .pop() + .with_context(err_context)?); + } + ret.append(&mut right_boundary); + Ok(ret) + } fn render_help_text_undertitle(&self) -> Result<Vec<TerminalCharacter>> { let max_undertitle_length = self.geom.cols.saturating_sub(2); @@ -1502,7 +1549,20 @@ impl PaneFrame { character_chunks.push(CharacterChunk::new(title, x, y)); } else if row == self.geom.rows - 1 { // bottom row - if self.highlight_tooltip.is_some() && self.is_main_client { + // + // The program's own status text wins over every hint we might + // put here: those are things the user can discover elsewhere, + // this was explicitly requested by what is running in the pane. + if let Some(status_bar) = self.status_bar.as_deref() { + let x = self.geom.x; + let y = self.geom.y + row; + character_chunks.push(CharacterChunk::new( + self.render_status_bar_undertitle(status_bar) + .with_context(err_context)?, + x, + y, + )); + } else if self.highlight_tooltip.is_some() && self.is_main_client { let x = self.geom.x; let y = self.geom.y + row; character_chunks.push(CharacterChunk::new( @@ -1657,6 +1717,7 @@ mod tests { mouse_is_hovering_over_pane: false, pane_is_selectable: true, show_help_text: true, + status_bar: None, highlight_tooltip: None, omit_title: false, frame_geom_override: None, diff --git a/zellij-server/src/ui/pane_contents_and_ui.rs b/zellij-server/src/ui/pane_contents_and_ui.rs index 8fd1d34c..d9f416e9 100644 --- a/zellij-server/src/ui/pane_contents_and_ui.rs +++ b/zellij-server/src/ui/pane_contents_and_ui.rs @@ -394,6 +394,7 @@ impl<'a> PaneContentsAndUi<'a> { .contains(&client_id), pane_is_selectable, show_help_text: self.show_help_text, + status_bar: self.pane.status_bar_text(), highlight_tooltip: highlight_tooltip.clone(), omit_title: self.omit_title, frame_geom_override: self.frame_geom_override, @@ -422,6 +423,7 @@ impl<'a> PaneContentsAndUi<'a> { .contains(&client_id), pane_is_selectable, show_help_text: self.show_help_text, + status_bar: self.pane.status_bar_text(), highlight_tooltip, omit_title: self.omit_title, frame_geom_override: self.frame_geom_override, |
