diff options
| author | user <user@clank> | 2026-08-12 18:44:24 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-12 18:44:24 +0200 |
| commit | 2c67579398835d51e625085ce3b22c22bc8f0cba (patch) | |
| tree | 2b22d137d988f0bbf658ec4d37336f813933bfa1 /zellij-utils/src | |
| parent | sl0p: document workspace hints for consumers (diff) | |
| download | sl0ppij-2c67579398835d51e625085ce3b22c22bc8f0cba.tar.gz sl0ppij-2c67579398835d51e625085ce3b22c22bc8f0cba.tar.xz sl0ppij-2c67579398835d51e625085ce3b22c22bc8f0cba.zip | |
sl0p: --purpose flag and tab-level purpose
Completes the two pieces deferred from the first workspace-hints pass.
`zellij run --purpose X` / `zellij action new-pane --purpose X` tag a pane at
spawn time, which is what an agent wants for panes it creates itself; the OSC
wrapper still works for programs that declare their own role. Not supported with
--in-place, which replaces another pane rather than being a pane of its own: clap
rejects the combination rather than accepting a flag that would do nothing.
`tab name="work" purpose="agent-work"` gives a whole tab a role, reported by
`list-tabs --json`. It rides the tab's ROOT layout node, reusing the pane field
because a tab's root is the tab container and never itself a pane -- asserted by a
test for the tightest case (a tab holding exactly one pane), since a leak there
would silently mislabel a pane.
The interesting part is why the flag first shipped broken. There are TWO protobuf
boundaries -- the plugin API and the client->server IPC contract -- and a CLI flag
must cross the second one. Defaulting the field in the conversion (what "no
protoc" usually forces) compiles cleanly and then silently drops the value in
transit, so --purpose parsed, printed a pane id, and did nothing. Fixed by adding
the field to both contracts by hand, in the .proto AND the committed generated
prost, kept in lockstep so a future regeneration reproduces it: protoc is only
needed to regenerate, not to build. Both boundaries now have a round-trip test,
and the IPC one was confirmed to fail when the wire read is removed.
Sanitising moved to Grid::set_authoritative_purpose, the single choke point every
out-of-band source passes through, after the CLI path was observed delivering
`bad purpose; rm -rf /` intact into list-panes --json.
Diffstat (limited to 'zellij-utils/src')
17 files changed, 239 insertions, 0 deletions
diff --git a/zellij-utils/src/cli.rs b/zellij-utils/src/cli.rs index 38167dd4..c7fd0f35 100644 --- a/zellij-utils/src/cli.rs +++ b/zellij-utils/src/cli.rs @@ -508,6 +508,12 @@ pub enum Sessions { /// if set, will open the pane near the current one rather than following the user's focus #[clap(long)] near_current_pane: bool, + /// Opaque agent-facing hint for what this pane is for, reported by + /// `list-panes --json` (eg. dev-server). Zellij never interprets it. + /// Not supported for in-place panes, which temporarily replace another + /// pane rather than being a pane of their own. + #[clap(long, value_parser, conflicts_with("in_place"))] + purpose: Option<String>, #[clap( long, help = "if set, will open the pane without changing the focus of any client, placing it relative to the pane the command was issued from" @@ -985,6 +991,12 @@ pub enum CliAction { /// if set, will open the pane near the current one rather than following the user's focus #[clap(long)] near_current_pane: bool, + /// Opaque agent-facing hint for what this pane is for, reported by + /// `list-panes --json` (eg. dev-server). Zellij never interprets it. + /// Not supported for in-place panes, which temporarily replace another + /// pane rather than being a pane of their own. + #[clap(long, value_parser, conflicts_with("in_place"))] + purpose: Option<String>, #[clap( long, help = "if set, will open the pane without changing the focus of any client, placing it relative to the pane the command was issued from" diff --git a/zellij-utils/src/client_server_contract/common_types.proto b/zellij-utils/src/client_server_contract/common_types.proto index 8f24e92a..6364990a 100644 --- a/zellij-utils/src/client_server_contract/common_types.proto +++ b/zellij-utils/src/client_server_contract/common_types.proto @@ -488,6 +488,7 @@ message NewFloatingPaneAction { bool near_current_pane = 7; optional uint32 tab_id = 8; bool no_focus = 9; + optional string purpose = 10; } message NewTiledPaneAction { @@ -498,6 +499,7 @@ message NewTiledPaneAction { optional bool borderless = 8; optional uint32 tab_id = 9; bool no_focus = 10; + optional string purpose = 11; } message NewInPlacePaneAction { @@ -516,6 +518,7 @@ message NewStackedPaneAction { bool near_current_pane = 3; optional uint32 tab_id = 4; bool no_focus = 5; + optional string purpose = 6; } message NewBlockingPaneAction { @@ -526,6 +529,7 @@ message NewBlockingPaneAction { bool near_current_pane = 5; optional uint32 tab_id = 6; bool no_focus = 7; + optional string purpose = 8; } message PaneNameInputAction { diff --git a/zellij-utils/src/data.rs b/zellij-utils/src/data.rs index 14444b2a..a18c540f 100644 --- a/zellij-utils/src/data.rs +++ b/zellij-utils/src/data.rs @@ -2340,6 +2340,11 @@ impl SessionInfo { /// Contains all the information for a currently opened tab. #[derive(Debug, Default, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)] pub struct TabInfo { + /// Opaque agent-facing hint for what this TAB is for, from the layout's + /// `tab ... purpose=`. Zellij never interprets it. Serde-only: the plugin + /// protobuf mirror has no field for it, so it defaults there. + #[serde(skip_serializing_if = "Option::is_none")] + pub purpose: Option<String>, /// The Tab's 0 indexed position pub position: usize, /// The name of the tab as it appears in the UI (if there's enough room for it) diff --git a/zellij-utils/src/input/actions.rs b/zellij-utils/src/input/actions.rs index 27dde42e..2fa5be5f 100644 --- a/zellij-utils/src/input/actions.rs +++ b/zellij-utils/src/input/actions.rs @@ -238,6 +238,8 @@ pub enum Action { near_current_pane: bool, no_focus: bool, tab_id: Option<usize>, + /// Opaque agent-facing hint; zellij never interprets it. + purpose: Option<String>, }, /// Open the file in a new pane using the default editor /// Returns: Created pane ID (format: terminal_<id>) @@ -262,6 +264,8 @@ pub enum Action { near_current_pane: bool, no_focus: bool, tab_id: Option<usize>, + /// Opaque agent-facing hint; zellij never interprets it. + purpose: Option<String>, }, /// Open a new tiled (embedded, non-floating) pane /// Returns: Created pane ID (format: terminal_<id> or plugin_<id>) @@ -273,6 +277,8 @@ pub enum Action { no_focus: bool, borderless: Option<bool>, tab_id: Option<usize>, + /// Opaque agent-facing hint; zellij never interprets it. + purpose: Option<String>, }, /// Open a new pane in place of the focused one, suppressing it instead /// Returns: Created pane ID (format: terminal_<id> or plugin_<id>) @@ -292,6 +298,8 @@ pub enum Action { near_current_pane: bool, no_focus: bool, tab_id: Option<usize>, + /// Opaque agent-facing hint; zellij never interprets it. + purpose: Option<String>, }, /// Embed focused pane in tab if floating or float focused pane if embedded TogglePaneEmbedOrFloating, @@ -1079,6 +1087,7 @@ impl Action { no_focus, borderless, tab_id, + purpose, } => { let pane_id_to_replace = match pane_id { Some(pane_id_str) => match PaneId::from_str(&pane_id_str) { @@ -1157,6 +1166,7 @@ impl Action { }; Ok(vec![Action::NewBlockingPane { + purpose: purpose.clone(), placement, pane_name: name, command, @@ -1241,6 +1251,7 @@ impl Action { }; if floating { Ok(vec![Action::NewFloatingPane { + purpose: purpose.clone(), command: Some(run_command_action), pane_name: name, coordinates: FloatingPaneCoordinates::new( @@ -1262,6 +1273,7 @@ impl Action { }]) } else if stacked { Ok(vec![Action::NewStackedPane { + purpose: purpose.clone(), command: Some(run_command_action), pane_name: name, near_current_pane, @@ -1270,6 +1282,7 @@ impl Action { }]) } else { Ok(vec![Action::NewTiledPane { + purpose: purpose.clone(), direction, command: Some(run_command_action), pane_name: name, @@ -1282,6 +1295,7 @@ impl Action { } else { if floating { Ok(vec![Action::NewFloatingPane { + purpose: purpose.clone(), command: None, pane_name: name, coordinates: FloatingPaneCoordinates::new( @@ -1303,6 +1317,7 @@ impl Action { }]) } else if stacked { Ok(vec![Action::NewStackedPane { + purpose: purpose.clone(), command: None, pane_name: name, near_current_pane, @@ -1311,6 +1326,7 @@ impl Action { }]) } else { Ok(vec![Action::NewTiledPane { + purpose: purpose.clone(), direction, command: None, pane_name: name, @@ -3669,6 +3685,7 @@ mod tests { #[test] fn test_new_pane_tiled_with_tab_id() { let cli_action = CliAction::NewPane { + purpose: None, direction: Some(Direction::Right), command: vec![], plugin: None, @@ -3713,6 +3730,7 @@ mod tests { #[test] fn test_new_pane_tiled_without_tab_id() { let cli_action = CliAction::NewPane { + purpose: None, direction: None, command: vec![], plugin: None, @@ -3757,6 +3775,7 @@ mod tests { #[test] fn test_new_in_place_pane_with_pane_id_to_replace() { let cli_action = CliAction::NewPane { + purpose: None, direction: None, command: vec![], plugin: None, @@ -3803,6 +3822,7 @@ mod tests { #[test] fn test_new_in_place_pane_with_malformed_pane_id() { let cli_action = CliAction::NewPane { + purpose: None, direction: None, command: vec![], plugin: None, @@ -3840,6 +3860,7 @@ mod tests { #[test] fn test_new_pane_floating_with_tab_id() { let cli_action = CliAction::NewPane { + purpose: None, direction: None, command: vec![], plugin: None, @@ -3884,6 +3905,7 @@ mod tests { #[test] fn test_new_pane_stacked_with_tab_id() { let cli_action = CliAction::NewPane { + purpose: None, direction: None, command: vec!["ls".into()], plugin: None, @@ -3928,6 +3950,7 @@ mod tests { #[test] fn test_new_pane_blocking_with_tab_id() { let cli_action = CliAction::NewPane { + purpose: None, direction: None, command: vec!["ls".into()], plugin: None, @@ -4036,6 +4059,7 @@ mod tests { #[test] fn test_new_pane_plugin_tiled_with_tab_id() { let cli_action = CliAction::NewPane { + purpose: None, direction: None, command: vec![], plugin: Some("zellij:strider".into()), @@ -4080,6 +4104,7 @@ mod tests { #[test] fn test_new_pane_plugin_floating_with_tab_id() { let cli_action = CliAction::NewPane { + purpose: None, direction: None, command: vec![], plugin: Some("zellij:strider".into()), @@ -4121,3 +4146,96 @@ mod tests { } } } + +#[cfg(test)] +mod sl0p_purpose_wire_tests { + use super::*; + + /// A CLI flag has to CROSS the client->server protobuf boundary, unlike a + /// field that merely exists on both sides. Defaulting it in the conversion + /// (which is what "no protoc" usually forces) silently drops it, and the flag + /// then parses and does nothing at all -- which is exactly what happened. + #[test] + fn sl0p_purpose_survives_the_protobuf_round_trip() { + use crate::plugin_api::action::ProtobufAction; + use std::convert::TryInto; + + let action = Action::NewTiledPane { + direction: None, + command: Some(RunCommandAction { + command: std::path::PathBuf::from("sleep"), + ..Default::default() + }), + pane_name: None, + near_current_pane: false, + no_focus: false, + borderless: None, + tab_id: None, + purpose: Some("dev-server".to_string()), + }; + + let proto: ProtobufAction = action.clone().try_into().expect("to protobuf"); + let back: Action = proto.try_into().expect("from protobuf"); + + match back { + Action::NewTiledPane { purpose, .. } => assert_eq!( + purpose.as_deref(), + Some("dev-server"), + "purpose must survive the wire, or --purpose is a no-op" + ), + other => panic!("expected NewTiledPane, got {:?}", other), + } + } +} + +#[cfg(test)] +mod sl0p_purpose_ipc_wire_tests { + use super::*; + + /// There are TWO protobuf boundaries: the plugin API and the client->server + /// IPC contract. `--purpose` parsed fine and did nothing for a whole round of + /// testing because only the first was wired, and a field with no wire + /// representation is silently dropped in transit rather than failing to + /// compile. This is the boundary a CLI flag actually crosses. + #[test] + fn sl0p_purpose_survives_the_client_server_ipc_round_trip() { + use crate::client_server_contract::client_server_contract::Action as ProtoIpcAction; + use std::convert::TryInto; + + for action in [ + Action::NewTiledPane { + direction: None, + command: None, + pane_name: None, + near_current_pane: false, + no_focus: false, + borderless: None, + tab_id: None, + purpose: Some("dev-server".to_string()), + }, + Action::NewFloatingPane { + command: None, + pane_name: None, + coordinates: None, + near_current_pane: false, + no_focus: false, + tab_id: None, + purpose: Some("dev-server".to_string()), + }, + ] { + let proto: ProtoIpcAction = action.clone().into(); + let back: Action = proto.try_into().expect("from ipc protobuf"); + let purpose = match back { + Action::NewTiledPane { purpose, .. } | Action::NewFloatingPane { purpose, .. } => { + purpose + }, + other => panic!("unexpected action back from the wire: {:?}", other), + }; + assert_eq!( + purpose.as_deref(), + Some("dev-server"), + "purpose must cross the IPC boundary, or --purpose is a silent no-op" + ); + } + } +} diff --git a/zellij-utils/src/input/unit/layout_test.rs b/zellij-utils/src/input/unit/layout_test.rs index 4a6eb854..7154d4ce 100644 --- a/zellij-utils/src/input/unit/layout_test.rs +++ b/zellij-utils/src/input/unit/layout_test.rs @@ -3016,3 +3016,30 @@ fn sl0p_unknown_pane_properties_are_still_rejected() { "an unknown pane property should be a parse error" ); } + +#[test] +fn sl0p_tab_root_layout_is_never_itself_a_pane() { + // Tab-level `purpose` is stored on the tab's root TiledPaneLayout, reusing + // the pane field. That is only safe if the root is never applied AS a pane -- + // otherwise a tab purpose would leak onto a pane. Checked for the tightest + // case: a tab holding exactly one pane. + let kdl = r#" + layout { + tab name="solo" purpose="agent-work" { + pane name="only" + } + } + "#; + let layout = Layout::from_kdl(kdl, Some("w.kdl".into()), None, None).expect("parses"); + let (_, tiled, _) = layout.tabs().into_iter().next().expect("one tab"); + assert_eq!( + tiled.purpose.as_deref(), + Some("agent-work"), + "the tab's purpose lands on the tab's root layout" + ); + assert_eq!(tiled.children.len(), 1, "the pane is a CHILD of the root"); + assert_eq!( + tiled.children[0].purpose, None, + "the tab purpose must not leak onto the pane inside it" + ); +} diff --git a/zellij-utils/src/ipc/protobuf_conversion.rs b/zellij-utils/src/ipc/protobuf_conversion.rs index f1deb3fe..3acd196e 100644 --- a/zellij-utils/src/ipc/protobuf_conversion.rs +++ b/zellij-utils/src/ipc/protobuf_conversion.rs @@ -1332,8 +1332,10 @@ impl From<crate::input::actions::Action> near_current_pane, no_focus, tab_id, + purpose, .. } => ActionType::NewFloatingPane(NewFloatingPaneAction { + purpose, command: command.map(|c| c.into()), pane_name, coordinates: coordinates.map(|c| c.into()), @@ -1349,8 +1351,10 @@ impl From<crate::input::actions::Action> no_focus, borderless, tab_id, + purpose, .. } => ActionType::NewTiledPane(NewTiledPaneAction { + purpose, direction: direction.map(|d| direction_to_proto_i32(d)), command: command.map(|c| c.into()), pane_name, @@ -1383,8 +1387,10 @@ impl From<crate::input::actions::Action> near_current_pane, no_focus, tab_id, + purpose, .. } => ActionType::NewStackedPane(NewStackedPaneAction { + purpose, command: command.map(|c| c.into()), pane_name, near_current_pane, @@ -1399,8 +1405,10 @@ impl From<crate::input::actions::Action> near_current_pane, no_focus, tab_id, + purpose, .. } => ActionType::NewBlockingPane(NewBlockingPaneAction { + purpose, placement: Some(placement.into()), pane_name, command: command.map(|c| c.into()), @@ -2221,6 +2229,7 @@ impl TryFrom<crate::client_server_contract::client_server_contract::Action> }), ActionType::NewFloatingPane(new_floating_action) => { Ok(crate::input::actions::Action::NewFloatingPane { + purpose: new_floating_action.purpose.clone(), command: new_floating_action .command .map(|c| c.try_into()) @@ -2237,6 +2246,7 @@ impl TryFrom<crate::client_server_contract::client_server_contract::Action> }, ActionType::NewTiledPane(new_tiled_action) => { Ok(crate::input::actions::Action::NewTiledPane { + purpose: new_tiled_action.purpose.clone(), direction: new_tiled_action .direction .map(|d| proto_i32_to_direction(d)) @@ -2267,6 +2277,7 @@ impl TryFrom<crate::client_server_contract::client_server_contract::Action> }, ActionType::NewStackedPane(new_stacked_action) => { Ok(crate::input::actions::Action::NewStackedPane { + purpose: new_stacked_action.purpose.clone(), command: new_stacked_action .command .map(|c| c.try_into()) @@ -2279,6 +2290,7 @@ impl TryFrom<crate::client_server_contract::client_server_contract::Action> }, ActionType::NewBlockingPane(new_blocking_action) => { Ok(crate::input::actions::Action::NewBlockingPane { + purpose: new_blocking_action.purpose.clone(), placement: new_blocking_action .placement .ok_or_else(|| anyhow!("NewBlockingPane missing placement"))? diff --git a/zellij-utils/src/ipc/tests/roundtrip_tests.rs b/zellij-utils/src/ipc/tests/roundtrip_tests.rs index 5210e788..df1d7209 100644 --- a/zellij-utils/src/ipc/tests/roundtrip_tests.rs +++ b/zellij-utils/src/ipc/tests/roundtrip_tests.rs @@ -1356,6 +1356,7 @@ fn test_client_messages() { }); test_client_roundtrip!(ClientToServerMsg::Action { action: Action::NewFloatingPane { + purpose: None, command: None, pane_name: None, coordinates: None, @@ -1369,6 +1370,7 @@ fn test_client_messages() { }); test_client_roundtrip!(ClientToServerMsg::Action { action: Action::NewFloatingPane { + purpose: None, command: Some(RunCommandAction { command: PathBuf::from("/path/to/command"), args: vec![], @@ -1398,6 +1400,7 @@ fn test_client_messages() { }); test_client_roundtrip!(ClientToServerMsg::Action { action: Action::NewFloatingPane { + purpose: None, command: Some(RunCommandAction { command: PathBuf::from("/path/to/command"), args: vec!["arg1".to_owned(), "arg2".to_owned()], @@ -1431,6 +1434,7 @@ fn test_client_messages() { }); test_client_roundtrip!(ClientToServerMsg::Action { action: Action::NewTiledPane { + purpose: None, command: None, direction: None, pane_name: None, @@ -1445,6 +1449,7 @@ fn test_client_messages() { }); test_client_roundtrip!(ClientToServerMsg::Action { action: Action::NewTiledPane { + purpose: None, command: Some(RunCommandAction { command: PathBuf::from("/path/to/command"), args: vec!["arg1".to_owned(), "arg2".to_owned()], @@ -1513,6 +1518,7 @@ fn test_client_messages() { }); test_client_roundtrip!(ClientToServerMsg::Action { action: Action::NewStackedPane { + purpose: None, command: Some(RunCommandAction { command: PathBuf::from("/path/to/command"), args: vec!["arg1".to_owned(), "arg2".to_owned()], @@ -1538,6 +1544,7 @@ fn test_client_messages() { }); test_client_roundtrip!(ClientToServerMsg::Action { action: Action::NewStackedPane { + purpose: None, command: None, pane_name: None, near_current_pane: false, @@ -2659,6 +2666,7 @@ fn test_client_messages() { }); test_client_roundtrip!(ClientToServerMsg::Action { action: Action::NewTiledPane { + purpose: None, direction: Some(Direction::Right), command: None, pane_name: None, @@ -2673,6 +2681,7 @@ fn test_client_messages() { }); test_client_roundtrip!(ClientToServerMsg::Action { action: Action::NewFloatingPane { + purpose: None, command: None, pane_name: None, coordinates: None, @@ -2686,6 +2695,7 @@ fn test_client_messages() { }); test_client_roundtrip!(ClientToServerMsg::Action { action: Action::NewStackedPane { + purpose: None, command: None, pane_name: None, near_current_pane: false, @@ -2698,6 +2708,7 @@ fn test_client_messages() { }); test_client_roundtrip!(ClientToServerMsg::Action { action: Action::NewBlockingPane { + purpose: None, placement: NewPanePlacement::Tiled { direction: None, borderless: None diff --git a/zellij-utils/src/kdl/kdl_layout_parser.rs b/zellij-utils/src/kdl/kdl_layout_parser.rs index 9e823849..41b4a430 100644 --- a/zellij-utils/src/kdl/kdl_layout_parser.rs +++ b/zellij-utils/src/kdl/kdl_layout_parser.rs @@ -145,6 +145,7 @@ impl<'a> KdlLayoutParser<'a> { || property_name == "min_panes" || property_name == "exact_panes" || property_name == "hide_floating_panes" + || property_name == "purpose" } pub fn is_a_reserved_plugin_property(property_name: &str) -> bool { property_name == "location" @@ -1261,10 +1262,16 @@ impl<'a> KdlLayoutParser<'a> { }, None => vec![], }; + // Tab-level purpose rides the tab's ROOT layout, which is the tab + // container and never itself a pane (asserted by + // sl0p_tab_root_layout_is_never_itself_a_pane). + let tab_purpose = kdl_get_string_property_or_child_value!(kdl_node, "purpose") + .and_then(|s| crate::input::layout::sanitize_pane_purpose(s)); let mut pane_layout = TiledPaneLayout { children_split_direction, children, hide_floating_panes, + purpose: tab_purpose, ..Default::default() }; if let Some(cwd_prefix) = &self.cwd_prefix(tab_cwd.as_ref())? { diff --git a/zellij-utils/src/kdl/mod.rs b/zellij-utils/src/kdl/mod.rs index 358fa3e2..98743d67 100644 --- a/zellij-utils/src/kdl/mod.rs +++ b/zellij-utils/src/kdl/mod.rs @@ -576,6 +576,7 @@ impl Action { }); } else if string == "stacked" { return Ok(Action::NewStackedPane { + purpose: None, command: None, pane_name: None, near_current_pane: false, @@ -2067,6 +2068,7 @@ impl TryFrom<(&KdlNode, &Options)> for Action { .and_then(|c_m| kdl_child_bool_value_for_entry(c_m, "borderless")); if floating { Ok(Action::NewFloatingPane { + purpose: None, command: Some(run_command_action), pane_name: name, coordinates: FloatingPaneCoordinates::new( @@ -2088,6 +2090,7 @@ impl TryFrom<(&KdlNode, &Options)> for Action { }) } else if stacked { Ok(Action::NewStackedPane { + purpose: None, command: Some(run_command_action), pane_name: name, near_current_pane: false, @@ -2096,6 +2099,7 @@ impl TryFrom<(&KdlNode, &Options)> for Action { }) } else { Ok(Action::NewTiledPane { + purpose: None, direction, command: Some(run_command_action), pane_name: name, @@ -6521,6 +6525,7 @@ impl TabInfo { optional_int_node!("selectable_floating_panes_count", usize).unwrap_or(0); let tab_id = optional_int_node!("tab_id", usize).unwrap_or(0); Ok(TabInfo { + purpose: None, position, name, active, @@ -6960,6 +6965,7 @@ fn serialize_and_deserialize_session_info_with_data() { name: "my session name".to_owned(), tabs: vec![ TabInfo { + purpose: None, position: 0, name: "tab 1".to_owned(), active: true, @@ -6981,6 +6987,7 @@ fn serialize_and_deserialize_session_info_with_data() { has_bell_notification: false, }, TabInfo { + purpose: None, position: 1, name: "tab 2".to_owned(), active: true, diff --git a/zellij-utils/src/plugin_api/action.proto b/zellij-utils/src/plugin_api/action.proto index 1d896cef..4c6da631 100644 --- a/zellij-utils/src/plugin_api/action.proto +++ b/zellij-utils/src/plugin_api/action.proto @@ -328,6 +328,7 @@ message GoToTabNamePayload { message NewFloatingPanePayload { optional RunCommandAction command = 1; bool near_current_pane = 2; + optional string purpose = 3; } message NewTiledPanePayload { @@ -335,6 +336,7 @@ message NewTiledPanePayload { optional resize.ResizeDirection direction = 2; bool near_current_pane = 3; optional bool borderless = 4; + optional string purpose = 5; } message MovePanePayload { @@ -365,6 +367,7 @@ message NewBlockingPanePayload { optional RunCommandAction command = 3; optional UnblockCondition unblock_condition = 4; bool near_current_pane = 5; + optional string purpose = 6; } message NewInPlacePanePayload { diff --git a/zellij-utils/src/plugin_api/action.rs b/zellij-utils/src/plugin_api/action.rs index e3cd29f8..c7bb144a 100644 --- a/zellij-utils/src/plugin_api/action.rs +++ b/zellij-utils/src/plugin_api/action.rs @@ -370,10 +370,12 @@ impl TryFrom<ProtobufAction> for Action { Some(ProtobufActionName::NewFloatingPane) => match protobuf_action.optional_payload { Some(OptionalPayload::NewFloatingPanePayload(payload)) => { let near_current_pane = payload.near_current_pane; + let purpose = payload.purpose.clone(); if let Some(payload) = payload.command { let pane_name = payload.pane_name.clone(); let run_command_action: RunCommandAction = payload.try_into()?; Ok(Action::NewFloatingPane { + purpose: purpose.clone(), command: Some(run_command_action), pane_name, coordinates: None, @@ -383,6 +385,7 @@ impl TryFrom<ProtobufAction> for Action { }) } else { Ok(Action::NewFloatingPane { + purpose: purpose.clone(), command: None, pane_name: None, coordinates: None, @@ -401,11 +404,13 @@ impl TryFrom<ProtobufAction> for Action { .and_then(|d| ProtobufResizeDirection::from_i32(d)) .and_then(|d| d.try_into().ok()); let near_current_pane = payload.near_current_pane; + let purpose = payload.purpose.clone(); let borderless = payload.borderless; if let Some(payload) = payload.command { let pane_name = payload.pane_name.clone(); let run_command_action: RunCommandAction = payload.try_into()?; Ok(Action::NewTiledPane { + purpose: purpose.clone(), direction, command: Some(run_command_action), pane_name, @@ -416,6 +421,7 @@ impl TryFrom<ProtobufAction> for Action { }) } else { Ok(Action::NewTiledPane { + purpose: purpose.clone(), direction, command: None, pane_name: None, @@ -1036,6 +1042,7 @@ impl TryFrom<ProtobufAction> for Action { Some(ProtobufActionName::NewStackedPane) => match protobuf_action.optional_payload { Some(_) => Err("NewStackedPane should not have a payload"), None => Ok(Action::NewStackedPane { + purpose: None, command: None, pane_name: None, near_current_pane: false, @@ -1056,7 +1063,9 @@ impl TryFrom<ProtobufAction> for Action { .and_then(|uc| ProtobufUnblockCondition::from_i32(uc)) .and_then(|uc| uc.try_into().ok()); let near_current_pane = payload.near_current_pane; + let purpose = payload.purpose.clone(); Ok(Action::NewBlockingPane { + purpose: purpose.clone(), placement, pane_name, command, @@ -1418,6 +1427,7 @@ impl TryFrom<Action> for ProtobufAction { pane_name, coordinates: _coordinates, near_current_pane, + purpose, .. } => { let command = run_command_action.and_then(|r| { @@ -1430,6 +1440,7 @@ impl TryFrom<Action> for ProtobufAction { name: ProtobufActionName::NewFloatingPane as i32, optional_payload: Some(OptionalPayload::NewFloatingPanePayload( NewFloatingPanePayload { + purpose: purpose.clone(), command, near_current_pane, }, @@ -1441,6 +1452,7 @@ impl TryFrom<Action> for ProtobufAction { command: run_command_action, pane_name, near_current_pane, + purpose, borderless, .. } => { @@ -1459,6 +1471,7 @@ impl TryFrom<Action> for ProtobufAction { name: ProtobufActionName::NewTiledPane as i32, optional_payload: Some(OptionalPayload::NewTiledPanePayload( NewTiledPanePayload { + purpose: purpose.clone(), direction, command, near_current_pane, @@ -1932,6 +1945,7 @@ impl TryFrom<Action> for ProtobufAction { command, unblock_condition, near_current_pane, + purpose, .. } => { let placement: ProtobufNewPanePlacement = placement.try_into()?; @@ -1949,6 +1963,7 @@ impl TryFrom<Action> for ProtobufAction { name: ProtobufActionName::NewBlockingPane as i32, optional_payload: Some(OptionalPayload::NewBlockingPanePayload( NewBlockingPanePayload { + purpose: purpose.clone(), placement: Some(placement), pane_name, command, diff --git a/zellij-utils/src/plugin_api/event.rs b/zellij-utils/src/plugin_api/event.rs index 2e905f22..6be6c6d0 100644 --- a/zellij-utils/src/plugin_api/event.rs +++ b/zellij-utils/src/plugin_api/event.rs @@ -1888,6 +1888,7 @@ impl TryFrom<ProtobufTabInfo> for TabInfo { type Error = &'static str; fn try_from(protobuf_tab_info: ProtobufTabInfo) -> Result<Self, &'static str> { Ok(TabInfo { + purpose: None, // no protobuf field; plugins do not see tab purposes position: protobuf_tab_info.position as usize, name: protobuf_tab_info.name, active: protobuf_tab_info.active, @@ -2557,6 +2558,7 @@ fn serialize_tab_update_event_with_non_default_values() { use prost::Message; let tab_update_event = Event::TabUpdate(vec![ TabInfo { + purpose: None, position: 0, name: "First tab".to_owned(), active: true, @@ -2578,6 +2580,7 @@ fn serialize_tab_update_event_with_non_default_values() { is_flashing_bell: false, }, TabInfo { + purpose: None, position: 1, name: "Secondtab".to_owned(), active: false, @@ -2903,6 +2906,7 @@ fn serialize_session_update_event_with_non_default_values() { use prost::Message; let tab_infos = vec![ TabInfo { + purpose: None, position: 0, name: "First tab".to_owned(), active: true, @@ -2924,6 +2928,7 @@ fn serialize_session_update_event_with_non_default_values() { is_flashing_bell: false, }, TabInfo { + purpose: None, position: 1, name: "Secondtab".to_owned(), active: false, diff --git a/zellij-utils/src/session_serialization.rs b/zellij-utils/src/session_serialization.rs index 2b3f651c..75cd4ece 100644 --- a/zellij-utils/src/session_serialization.rs +++ b/zellij-utils/src/session_serialization.rs @@ -25,6 +25,8 @@ pub struct TabLayoutManifest { pub floating_panes: Vec<PaneLayoutManifest>, pub is_focused: bool, pub hide_floating_panes: bool, + /// Opaque agent-facing hint for what this TAB is for. + pub purpose: Option<String>, } #[derive(Default, Debug, Clone)] @@ -87,6 +89,7 @@ pub fn serialize_session_layout( } fn serialize_tab( + tab_purpose: Option<String>, tab_name: String, is_focused: bool, hide_floating_panes: bool, @@ -115,6 +118,11 @@ fn serialize_tab( .entries_mut() .push(KdlEntry::new_prop("focus", KdlValue::Bool(true))); } + if let Some(purpose) = tab_purpose { + serialized_tab + .entries_mut() + .push(KdlEntry::new_prop("purpose", purpose)); + } if hide_floating_panes { serialized_tab.entries_mut().push(KdlEntry::new_prop( "hide_floating_panes", @@ -652,6 +660,7 @@ fn serialize_multiple_tabs( let floating_panes = tab_layout_manifest.floating_panes; let hide_floating_panes = tab_layout_manifest.hide_floating_panes; let serialized = serialize_tab( + tab_layout_manifest.purpose.clone(), tab_name.clone(), tab_layout_manifest.is_focused, hide_floating_panes, 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 bdcb9108..16ffd584 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 @@ -1365,6 +1365,7 @@ Config { near_current_pane: false, no_focus: false, tab_id: None, + purpose: None, }, SwitchToMode { input_mode: Normal, 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 39eebd82..bfc875dd 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 @@ -1365,6 +1365,7 @@ Config { near_current_pane: false, no_focus: false, tab_id: None, + purpose: None, }, SwitchToMode { input_mode: Normal, 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 12909809..63b29457 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 @@ -1365,6 +1365,7 @@ Config { near_current_pane: false, no_focus: false, tab_id: None, + purpose: None, }, SwitchToMode { input_mode: Normal, 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 545b5b95..222971bb 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 @@ -1365,6 +1365,7 @@ Config { near_current_pane: false, no_focus: false, tab_id: None, + purpose: None, }, SwitchToMode { input_mode: Normal, |
