diff options
| author | user <user@clank> | 2026-08-12 16:15:19 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-12 16:15:19 +0200 |
| commit | c3764fca00c1d91c06612a33162b2354e9d42be0 (patch) | |
| tree | 38e7f519c7a7c725a638b84f5741659a172a3a28 | |
| parent | sl0p: highlight the status bar button under the pointer (diff) | |
| download | sl0ppij-c3764fca00c1d91c06612a33162b2354e9d42be0.tar.gz sl0ppij-c3764fca00c1d91c06612a33162b2354e9d42be0.tar.xz sl0ppij-c3764fca00c1d91c06612a33162b2354e9d42be0.zip | |
sl0p: add an indexed style for status bar buttons
`status_bar_button_style "indexed"` renders `1:Approve 2:Reject` instead of
`[Approve] [Reject]`, so the bar doubles as a key legend for a harness that binds
alt+1..9 to the same actions.
A config option rather than a protocol field: whether the numbers mean anything
depends on what the USER has bound, not on what the program knows. It needs no
wire change and so no version bump, and the click report still carries the id --
never the index -- so a program is unaffected by which style is in force.
The style is threaded into layout_status_bar, the one function the renderer draws
from and the hit-test resolves against. That is what makes this safe: changing a
button's rendered width necessarily changes its clickable span too, verified by a
test asserting span width equals the drawn width and that every drawn column
resolves back, for both styles.
Also fixes the web client's Style construction to read the option from config
rather than defaulting it -- my first pass claimed that code had no pane_frames
config available, which was wrong; it uses it two lines further down.
20 files changed, 168 insertions, 7 deletions
diff --git a/.pi-answers.md b/.pi-answers.md new file mode 100644 index 00000000..85dd1604 --- /dev/null +++ b/.pi-answers.md @@ -0,0 +1,14 @@ +# pi answer buttons + +<!-- Clickable buttons in this pane's zellij frame row (OSC 5577), also alt+1..9. + Format: - `label` — text sent to the agent + This file is authoritative: it was seeded with answer-picker.ts defaults, + so deleting a line removes that button. `/answers reload` re-reads it. --> + +- `continue` — continue +- `yes` — yes, go ahead +- `no` — no — don't do that +- `explain` — explain what you just did, and why +- `tests` — run the tests and fix anything that fails +- `commit` — commit this with a clear message +- `push` — push to git @@ -331,6 +331,10 @@ printf '\033]5577;1;clear\033\\' # remove bo ╰ awaiting review ──────────────────────────[Approve]─[Reject]╯ ``` +Buttons render as `[Approve]` by default, or `1:Approve` with +`ui { pane_frames { status_bar_button_style "indexed" } }` — useful when something +else binds those numbers to the same actions. + Full protocol reference, worked shell/Python consumers and the security model: **[`sl0p/pane-status-bar.md`](sl0p/pane-status-bar.md)**. diff --git a/sl0p/pane-status-bar.md b/sl0p/pane-status-bar.md index 8256e4d8..325ff936 100644 --- a/sl0p/pane-status-bar.md +++ b/sl0p/pane-status-bar.md @@ -184,6 +184,27 @@ not matched early. --- +## Button decoration + +How buttons are drawn is a **user** choice, not a program one, since only the user +knows whether the numbers mean anything on their setup: + +```kdl +ui { pane_frames { status_bar_button_style "indexed" } } // default: "boxed" +``` + +| style | renders | +|---|---| +| `boxed` | `[Approve] [Reject]` | +| `indexed` | `1:Approve 2:Reject` — 1-based position, then the label | + +`indexed` pairs with a harness that binds those numbers to the same actions +(`alt+1`…`alt+9`, say), so the bar doubles as a key legend. The index is the +button's position in the list you declared, so keep the order stable if you are +binding keys to it. + +Either way the click report is unchanged: it carries the **id**, never the index. + ## Rendering behaviour - **Requires pane frames.** The bar lives in the frame's bottom row, so with diff --git a/zellij-client/src/web_client/server_listener.rs b/zellij-client/src/web_client/server_listener.rs index d96433d9..75de66ca 100644 --- a/zellij-client/src/web_client/server_listener.rs +++ b/zellij-client/src/web_client/server_listener.rs @@ -80,6 +80,10 @@ pub fn zellij_server_listener( let client_attributes = zellij_utils::ipc::ClientAttributes { size: full_screen_ws, style: Style { + status_bar_button_style: config + .ui + .pane_frames + .status_bar_button_style, colors: palette, rounded_corners: config.ui.pane_frames.rounded_corners, hide_session_name: config.ui.pane_frames.hide_session_name, diff --git a/zellij-server/src/lib.rs b/zellij-server/src/lib.rs index 92d4a2c2..b59dd8a7 100644 --- a/zellij-server/src/lib.rs +++ b/zellij-server/src/lib.rs @@ -1021,6 +1021,7 @@ pub fn start_server_impl( pane_padding: config.ui.pane_frames.resolved_padding(), inactive_pane_grayscale: config.ui.pane_frames.inactive_grayscale, hide_default_pane_names: config.ui.pane_frames.hide_default_pane_names, + status_bar_button_style: config.ui.pane_frames.status_bar_button_style, }, }; @@ -1210,6 +1211,7 @@ pub fn start_server_impl( pane_padding: config.ui.pane_frames.resolved_padding(), inactive_pane_grayscale: config.ui.pane_frames.inactive_grayscale, hide_default_pane_names: config.ui.pane_frames.hide_default_pane_names, + status_bar_button_style: config.ui.pane_frames.status_bar_button_style, }, }; diff --git a/zellij-server/src/panes/terminal_pane.rs b/zellij-server/src/panes/terminal_pane.rs index 10a5c2a6..14b894b6 100644 --- a/zellij-server/src/panes/terminal_pane.rs +++ b/zellij-server/src/panes/terminal_pane.rs @@ -1313,6 +1313,7 @@ impl Pane for TerminalPane { inner_cols, self.grid.status_bar.as_deref(), &self.grid.status_bar_buttons, + self.style.status_bar_button_style, ) .button_at(column - inner_start) .map(|id| id.to_owned()) diff --git a/zellij-server/src/ui/pane_boundaries_frame.rs b/zellij-server/src/ui/pane_boundaries_frame.rs index cb4aa167..7076d346 100644 --- a/zellij-server/src/ui/pane_boundaries_frame.rs +++ b/zellij-server/src/ui/pane_boundaries_frame.rs @@ -1322,6 +1322,7 @@ impl PaneFrame { inner_cols, self.status_bar.as_deref(), &self.status_bar_buttons, + self.style.status_bar_button_style, ); let mut left_boundary = foreground_color(self.get_corner(boundary_type::BOTTOM_LEFT), self.color); diff --git a/zellij-utils/src/data.rs b/zellij-utils/src/data.rs index 2caedf8f..fa18a36e 100644 --- a/zellij-utils/src/data.rs +++ b/zellij-utils/src/data.rs @@ -1449,6 +1449,10 @@ pub struct Style { /// the committed prost assets. Defaults to Left when coming back from a /// ProtobufStyle. pub pane_title_alignment: PaneTitleAlignment, + /// How status bar action buttons are decorated (OSC 5577). Not round-tripped + /// through the plugin protobuf API, for the same reason as + /// `pane_title_alignment` above. + pub status_bar_button_style: crate::input::theme::StatusBarButtonStyle, /// OUTER gap: cells each pane gives up on every side, leaving empty /// terminal between neighbouring panes. Two adjacent panes therefore show /// 2 * pane_gap cells between them, and there is a gap at the screen edge. diff --git a/zellij-utils/src/input/config.rs b/zellij-utils/src/input/config.rs index 880b3fa9..c3f33d2d 100644 --- a/zellij-utils/src/input/config.rs +++ b/zellij-utils/src/input/config.rs @@ -1558,6 +1558,7 @@ mod config_test { stack_list_max_rows: 0, stack_list_grid: false, stack_list_style: Default::default(), + status_bar_button_style: Default::default(), }, }; assert_eq!(config.ui, expected_ui_config, "Ui config defined in config"); diff --git a/zellij-utils/src/input/layout.rs b/zellij-utils/src/input/layout.rs index 6160bb21..79063381 100644 --- a/zellij-utils/src/input/layout.rs +++ b/zellij-utils/src/input/layout.rs @@ -216,12 +216,13 @@ pub fn layout_status_bar( inner_cols: usize, status: Option<&str>, buttons: &[StatusBarButton], + button_style: crate::input::theme::StatusBarButtonStyle, ) -> StatusBarLayout { let mut spans = vec![]; let mut buttons_width = 0; - for button in buttons { - // "[label]" plus a leading space to separate it from what precedes it - let rendered = format!("[{}]", button.label); + for (index, button) in buttons.iter().enumerate() { + // plus a leading space, to separate it from whatever precedes it + let rendered = button_style.render(index + 1, &button.label); let width = rendered.chars().count() + 1; if buttons_width + width > inner_cols { break; // no room for this one or any after it diff --git a/zellij-utils/src/input/theme.rs b/zellij-utils/src/input/theme.rs index 646fa0ed..4a33db5e 100644 --- a/zellij-utils/src/input/theme.rs +++ b/zellij-utils/src/input/theme.rs @@ -9,6 +9,43 @@ use std::{ use crate::data::Styling; +/// How a status bar action button is decorated (OSC 5577). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)] +pub enum StatusBarButtonStyle { + /// `[Approve]` -- bracketed, no index. + Boxed, + /// `1:Approve` -- the button's 1-based position, then its label. Useful when + /// something else binds those numbers to the same actions, so the bar shows + /// which key does what. + Indexed, +} + +impl Default for StatusBarButtonStyle { + fn default() -> Self { + StatusBarButtonStyle::Boxed + } +} + +impl StatusBarButtonStyle { + pub fn from_str(name: &str) -> Option<Self> { + match name.trim().to_lowercase().as_str() { + "boxed" => Some(StatusBarButtonStyle::Boxed), + "indexed" => Some(StatusBarButtonStyle::Indexed), + _ => None, + } + } + pub fn possible_values() -> &'static str { + "\"boxed\", \"indexed\"" + } + /// How this style renders a button. `index` is 1-based. + pub fn render(&self, index: usize, label: &str) -> String { + match self { + StatusBarButtonStyle::Boxed => format!("[{}]", label), + StatusBarButtonStyle::Indexed => format!("{}:{}", index, label), + } + } +} + /// Visual treatment of the stacked-pane picker's entries. #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] pub enum StackListStyle { @@ -97,6 +134,8 @@ pub struct FrameConfig { pub stack_list_grid: bool, /// Visual treatment of picker entries. pub stack_list_style: StackListStyle, + /// How status bar action buttons are decorated. + pub status_bar_button_style: StatusBarButtonStyle, } /// Hand-written rather than derived: `gap_aspect` must default to 2 to match @@ -119,6 +158,7 @@ impl Default for FrameConfig { stack_list_max_rows: 0, stack_list_grid: false, stack_list_style: StackListStyle::Boxed, + status_bar_button_style: StatusBarButtonStyle::Boxed, } } } @@ -152,6 +192,7 @@ impl FrameConfig { merged.stack_list_max_rows = other.stack_list_max_rows; merged.stack_list_grid = other.stack_list_grid; merged.stack_list_style = other.stack_list_style; + merged.status_bar_button_style = other.status_bar_button_style; merged } } diff --git a/zellij-utils/src/input/unit/layout_test.rs b/zellij-utils/src/input/unit/layout_test.rs index 41573e93..c48c17ed 100644 --- a/zellij-utils/src/input/unit/layout_test.rs +++ b/zellij-utils/src/input/unit/layout_test.rs @@ -2805,7 +2805,7 @@ fn sl0p_status_bar_button_spans_are_where_they_are_drawn() { }, ]; for inner_cols in 20..120usize { - let layout = layout_status_bar(inner_cols, Some("waiting for review"), &buttons); + let layout = layout_status_bar(inner_cols, Some("waiting for review"), &buttons, Default::default()); for span in &layout.buttons { assert!( span.start + span.width <= inner_cols, @@ -2856,7 +2856,7 @@ fn sl0p_status_bar_drops_buttons_that_cannot_fit_rather_than_overflowing() { label: String::from("LongishLabel"), }) .collect(); - let layout = layout_status_bar(20, Some("status"), &buttons); + let layout = layout_status_bar(20, Some("status"), &buttons, Default::default()); assert!( layout.buttons.len() < buttons.len(), "expected some buttons to be dropped at 20 cols, got {:?}", @@ -2876,7 +2876,7 @@ fn sl0p_status_bar_text_yields_space_to_buttons() { label: String::from("Go"), }]; let long = "x".repeat(200); - let layout = layout_status_bar(30, Some(&long), &buttons); + let layout = layout_status_bar(30, Some(&long), &buttons, Default::default()); assert_eq!(layout.buttons.len(), 1, "the button must survive"); assert!( layout.text.chars().count() < 30, @@ -2903,3 +2903,40 @@ fn sl0p_status_bar_wire_format_round_trips() { let parsed = parse_status_bar_buttons(wire.split(';')); assert_eq!(parsed, buttons, "wire form: {}", wire); } + +#[test] +fn sl0p_status_bar_button_style_changes_rendering_and_spans_together() { + // The style changes how wide a button is, and the hit-test resolves against + // the same layout -- so an indexed button must be clickable across exactly + // the columns it is drawn on, not the columns a boxed one would have used. + use crate::input::theme::StatusBarButtonStyle; + let buttons: Vec<StatusBarButton> = ["Approve", "Reject", "Defer"] + .iter() + .enumerate() + .map(|(i, label)| StatusBarButton { + id: format!("id{}", i), + label: label.to_string(), + }) + .collect(); + + let boxed = layout_status_bar(80, None, &buttons, StatusBarButtonStyle::Boxed); + let indexed = layout_status_bar(80, None, &buttons, StatusBarButtonStyle::Indexed); + + assert_eq!(boxed.buttons[0].rendered, "[Approve]"); + assert_eq!(indexed.buttons[0].rendered, "1:Approve"); + assert_eq!(indexed.buttons[2].rendered, "3:Defer", "1-based index"); + + for layout in [&boxed, &indexed] { + for span in &layout.buttons { + assert_eq!( + span.width, + span.rendered.chars().count(), + "span width must match what is drawn: {:?}", + span + ); + for col in span.start..span.start + span.width { + assert_eq!(layout.button_at(col), Some(span.id.as_str())); + } + } + } +} diff --git a/zellij-utils/src/kdl/mod.rs b/zellij-utils/src/kdl/mod.rs index 88ea7dbe..358fa3e2 100644 --- a/zellij-utils/src/kdl/mod.rs +++ b/zellij-utils/src/kdl/mod.rs @@ -15,7 +15,9 @@ use crate::input::layout::{ use crate::input::options::{Clipboard, OnForceClose, Options, PaneFrameStyle}; use crate::input::permission::{GrantedPermission, PermissionCache}; use crate::input::plugins::PluginAliases; -use crate::input::theme::{FrameConfig, StackListStyle, Theme, Themes, UiConfig}; +use crate::input::theme::{ + FrameConfig, StackListStyle, StatusBarButtonStyle, Theme, Themes, UiConfig, +}; use crate::input::web_client::WebClientConfig; use kdl_layout_parser::KdlLayoutParser; use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; @@ -5688,6 +5690,26 @@ impl UiConfig { }, None => StackListStyle::default(), }; + let status_bar_button_style = match kdl_children_property_first_arg_as_string!( + pane_frames, + "status_bar_button_style" + ) { + Some(raw) => match StatusBarButtonStyle::from_str(raw) { + Some(style) => style, + None => { + return Err(ConfigError::new_kdl_error( + format!( + "Unknown status_bar_button_style: \"{}\". Valid values are: {}", + raw, + StatusBarButtonStyle::possible_values() + ), + pane_frames.span().offset(), + pane_frames.span().len(), + )) + }, + }, + None => StatusBarButtonStyle::default(), + }; let frame_config = FrameConfig { rounded_corners, hide_session_name, @@ -5702,6 +5724,7 @@ impl UiConfig { stack_list_max_rows, stack_list_grid, stack_list_style, + status_bar_button_style, }; ui_config.pane_frames = frame_config; } diff --git a/zellij-utils/src/plugin_api/event.rs b/zellij-utils/src/plugin_api/event.rs index 0dafec64..2e905f22 100644 --- a/zellij-utils/src/plugin_api/event.rs +++ b/zellij-utils/src/plugin_api/event.rs @@ -2493,6 +2493,7 @@ fn serialize_mode_update_event_with_non_default_values() { rounded_corners: true, hide_session_name: false, pane_title_alignment: Default::default(), + status_bar_button_style: Default::default(), pane_gap_x: Default::default(), pane_gap_y: Default::default(), pane_padding: Default::default(), diff --git a/zellij-utils/src/plugin_api/style.rs b/zellij-utils/src/plugin_api/style.rs index cf554351..3fc1efe7 100644 --- a/zellij-utils/src/plugin_api/style.rs +++ b/zellij-utils/src/plugin_api/style.rs @@ -26,6 +26,7 @@ impl TryFrom<ProtobufStyle> for Style { // alignment, and adding a proto field would mean regenerating the // committed prost assets. pane_title_alignment: Default::default(), + status_bar_button_style: Default::default(), pane_gap_x: Default::default(), pane_gap_y: Default::default(), pane_padding: Default::default(), diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments.snap index 56798240..bdcb9108 100644 --- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments.snap +++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments.snap @@ -6253,6 +6253,7 @@ Config { stack_list_max_rows: 0, stack_list_grid: false, stack_list_style: Boxed, + status_bar_button_style: Boxed, }, }, env: {}, diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_env_vars_override_config_env_vars.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_env_vars_override_config_env_vars.snap index 1541d678..39eebd82 100644 --- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_env_vars_override_config_env_vars.snap +++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_env_vars_override_config_env_vars.snap @@ -6253,6 +6253,7 @@ Config { stack_list_max_rows: 0, stack_list_grid: false, stack_list_style: Boxed, + status_bar_button_style: Boxed, }, }, env: { diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_keybinds_override_config_keybinds.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_keybinds_override_config_keybinds.snap index 126a7274..bf08161c 100644 --- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_keybinds_override_config_keybinds.snap +++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_keybinds_override_config_keybinds.snap @@ -309,6 +309,7 @@ Config { stack_list_max_rows: 0, stack_list_grid: false, stack_list_style: Boxed, + status_bar_button_style: Boxed, }, }, env: {}, diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_themes_override_config_themes.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_themes_override_config_themes.snap index a83dd9f6..12909809 100644 --- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_themes_override_config_themes.snap +++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_themes_override_config_themes.snap @@ -8024,6 +8024,7 @@ Config { stack_list_max_rows: 0, stack_list_grid: false, stack_list_style: Boxed, + status_bar_button_style: Boxed, }, }, env: {}, diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_ui_config_overrides_config_ui_config.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_ui_config_overrides_config_ui_config.snap index 79f1c716..545b5b95 100644 --- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_ui_config_overrides_config_ui_config.snap +++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_ui_config_overrides_config_ui_config.snap @@ -6253,6 +6253,7 @@ Config { stack_list_max_rows: 0, stack_list_grid: false, stack_list_style: Boxed, + status_bar_button_style: Boxed, }, }, env: {}, |
