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 | |
| 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.
35 files changed, 364 insertions, 11 deletions
diff --git a/src/main.rs b/src/main.rs index 15ed027a..6d633d20 100644 --- a/src/main.rs +++ b/src/main.rs @@ -50,6 +50,7 @@ fn main() { block_until_exit_failure, block_until_exit, near_current_pane, + purpose, no_focus, borderless, tab_id, @@ -70,6 +71,7 @@ fn main() { }; let command_cli_action = CliAction::NewPane { + purpose, command, plugin: None, direction, @@ -124,6 +126,8 @@ fn main() { let blocking = false; let unblock_condition = None; let command_cli_action = CliAction::NewPane { + // `zellij plugin` panes are zellij UI, and plugin panes carry no purpose + purpose: None, command: vec![], plugin: Some(url), direction: None, diff --git a/zellij-server/src/panes/grid.rs b/zellij-server/src/panes/grid.rs index 688a454a..1b935557 100644 --- a/zellij-server/src/panes/grid.rs +++ b/zellij-server/src/panes/grid.rs @@ -1039,10 +1039,18 @@ impl Grid { self.pane_purpose = zellij_utils::input::layout::sanitize_pane_purpose(raw); true } - /// Set the purpose from outside the pane (layout application, resurrection). - /// This wins over anything the pane itself declares, now or later. + /// Set the purpose from outside the pane (layout, CLI `--purpose`, + /// resurrection). This wins over anything the pane itself declares, now or + /// later. + /// + /// Sanitised here rather than at each caller: this is the one choke point + /// every out-of-band source passes through, and a purpose reaching a + /// consumer's shell or JSON pipeline unsanitised is the whole risk. A layout + /// value has already been sanitised at parse time, which is idempotent. pub fn set_authoritative_purpose(&mut self, purpose: Option<String>) { - self.pane_purpose = purpose; + self.pane_purpose = purpose + .as_deref() + .and_then(zellij_utils::input::layout::sanitize_pane_purpose); self.pane_purpose_is_authoritative = true; } pub fn new( diff --git a/zellij-server/src/plugins/zellij_exports.rs b/zellij-server/src/plugins/zellij_exports.rs index 318b2acc..34b711de 100644 --- a/zellij-server/src/plugins/zellij_exports.rs +++ b/zellij-server/src/plugins/zellij_exports.rs @@ -1622,6 +1622,7 @@ fn open_file_near_plugin( ClientTabIndexOrPaneId::PaneId(PaneId::Plugin(env.plugin_id)), Some(NotificationEnd::new(completion_tx)), false, // set_blocking + None, // purpose ); let _ = env.senders.send_to_pty(pty_instr); @@ -1665,6 +1666,7 @@ fn open_file_floating_near_plugin( ClientTabIndexOrPaneId::PaneId(PaneId::Plugin(env.plugin_id)), Some(NotificationEnd::new(completion_tx)), false, // set_blocking + None, // purpose ); let _ = env.senders.send_to_pty(pty_instr); @@ -1735,6 +1737,7 @@ fn open_terminal(env: &PluginEnv, cwd: PathBuf) { _ => None, }; let action = Action::NewTiledPane { + purpose: None, direction: None, command: run_command_action, pane_name: None, @@ -1780,6 +1783,7 @@ fn open_terminal_near_plugin(env: &PluginEnv, cwd: PathBuf) { ClientTabIndexOrPaneId::PaneId(PaneId::Plugin(env.plugin_id)), Some(NotificationEnd::new(completion_tx)), false, // set_blocking + None, // purpose )); // Wait for completion @@ -1813,6 +1817,7 @@ fn open_terminal_floating( _ => None, }; let action = Action::NewFloatingPane { + purpose: None, command: run_command_action, pane_name: None, coordinates: floating_pane_coordinates, @@ -1859,6 +1864,7 @@ fn open_terminal_floating_near_plugin( ClientTabIndexOrPaneId::PaneId(PaneId::Plugin(env.plugin_id)), Some(NotificationEnd::new(completion_tx)), false, // set_blocking + None, // purpose )); // Wait for completion @@ -2175,6 +2181,7 @@ fn open_command_pane( use_terminal_title, }; let action = Action::NewTiledPane { + purpose: None, direction, command: Some(run_command_action), pane_name: name, @@ -2240,6 +2247,7 @@ fn open_command_pane_near_plugin( ClientTabIndexOrPaneId::PaneId(PaneId::Plugin(env.plugin_id)), Some(NotificationEnd::new(completion_tx)), false, // set_blocking + None, // purpose )); // Wait for completion @@ -2285,6 +2293,7 @@ fn open_command_pane_floating( use_terminal_title, }; let action = Action::NewFloatingPane { + purpose: None, command: Some(run_command_action), pane_name: name, coordinates: floating_pane_coordinates, @@ -2347,6 +2356,7 @@ fn open_command_pane_floating_near_plugin( ClientTabIndexOrPaneId::PaneId(PaneId::Plugin(env.plugin_id)), Some(NotificationEnd::new(completion_tx)), false, // set_blocking + None, // purpose )); // Wait for completion @@ -2460,6 +2470,7 @@ fn open_command_pane_background( ClientTabIndexOrPaneId::ClientId(env.client_id), Some(NotificationEnd::new(completion_tx)), false, // set_blocking + None, // purpose )); // Wait for completion @@ -2880,6 +2891,7 @@ fn new_tiled_pane_in_tab(env: &PluginEnv, tab_position: usize) { _ => None, }; let action = Action::NewTiledPane { + purpose: None, direction: None, command: run_command_action, pane_name: None, diff --git a/zellij-server/src/pty.rs b/zellij-server/src/pty.rs index 68a2371b..5d6b6a05 100644 --- a/zellij-server/src/pty.rs +++ b/zellij-server/src/pty.rs @@ -56,6 +56,7 @@ pub enum PtyInstruction { ClientTabIndexOrPaneId, Option<NotificationEnd>, // completion signal bool, // set_blocking + Option<String>, // agent-facing purpose (see workspace hints) ), // bool (if Some) is // should_float, String is an optional pane name OpenInPlaceEditor( @@ -224,6 +225,7 @@ pub(crate) fn pty_thread_main(mut pty: Pty, layout: Box<Layout>) -> Result<()> { client_or_tab_index, completion_tx, set_blocking, + purpose, ) => { let err_context = || format!("failed to spawn terminal for {:?}", client_or_tab_index); @@ -316,6 +318,7 @@ pub(crate) fn pty_thread_main(mut pty: Pty, layout: Box<Layout>) -> Result<()> { client_or_tab_index, completion_tx, set_blocking, + purpose.clone(), )) .with_context(err_context)?; }, @@ -335,6 +338,7 @@ pub(crate) fn pty_thread_main(mut pty: Pty, layout: Box<Layout>) -> Result<()> { client_or_tab_index, completion_tx, set_blocking, + purpose.clone(), )) .with_context(err_context)?; if let Some(run_command) = run_command { diff --git a/zellij-server/src/route.rs b/zellij-server/src/route.rs index 8783af72..abc8f98d 100644 --- a/zellij-server/src/route.rs +++ b/zellij-server/src/route.rs @@ -656,10 +656,12 @@ pub(crate) fn route_action( ClientTabIndexOrPaneId::ClientId(client_id), Some(NotificationEnd::new(completion_tx)), false, // set_blocking + None, // purpose )) .with_context(err_context)?; }, Action::NewBlockingPane { + purpose, placement, pane_name, command, @@ -709,6 +711,7 @@ pub(crate) fn route_action( client_tab_index_or_paneid, notification_end, set_pane_blocking, + purpose, )) .with_context(err_context)?; wait_forever = true; @@ -755,6 +758,7 @@ pub(crate) fn route_action( client_tab_index_or_paneid, Some(NotificationEnd::new(completion_tx)), false, // set_blocking + None, // purpose ) }; senders.send_to_pty(pty_instr).with_context(err_context)?; @@ -775,6 +779,7 @@ pub(crate) fn route_action( .with_context(err_context)?; }, Action::NewFloatingPane { + purpose, command: run_command, pane_name: name, coordinates: floating_pane_coordinates, @@ -796,6 +801,7 @@ pub(crate) fn route_action( client_tab_index_or_paneid, Some(NotificationEnd::new(completion_tx)), false, // set_blocking + purpose, )) .with_context(err_context)?; }, @@ -840,6 +846,7 @@ pub(crate) fn route_action( .with_context(err_context)?; }, Action::NewStackedPane { + purpose, command: run_command, pane_name: name, near_current_pane, @@ -893,10 +900,12 @@ pub(crate) fn route_action( client_tab_index_or_paneid, Some(NotificationEnd::new(completion_tx)), false, // set_blocking + purpose, )) .with_context(err_context)?; }, Action::NewTiledPane { + purpose, direction, command: run_command, pane_name: name, @@ -922,6 +931,7 @@ pub(crate) fn route_action( client_tab_index_or_paneid, Some(NotificationEnd::new(completion_tx)), false, // set_blocking + purpose, )) .with_context(err_context)?; }, @@ -979,6 +989,7 @@ pub(crate) fn route_action( client_tab_index_or_paneid, Some(NotificationEnd::new(completion_tx)), false, // set_blocking + None, // purpose )) .with_context(err_context)?; }, diff --git a/zellij-server/src/screen.rs b/zellij-server/src/screen.rs index 35522656..1b63451a 100644 --- a/zellij-server/src/screen.rs +++ b/zellij-server/src/screen.rs @@ -343,6 +343,7 @@ pub enum ScreenInstruction { ClientTabIndexOrPaneId, Option<NotificationEnd>, // completion signal bool, // set_blocking + Option<String>, // agent-facing purpose (see workspace hints) ), OpenInPlaceEditor(PaneId, ClientTabIndexOrPaneId), TogglePaneEmbedOrFloating(ClientId, Option<NotificationEnd>), @@ -4918,6 +4919,7 @@ impl Screen { let selectable_tiled_panes_count = tab.get_selectable_tiled_panes_count(); let selectable_floating_panes_count = tab.get_selectable_floating_panes_count(); let tab_info_for_screen = TabInfo { + purpose: tab.purpose.clone(), position: tab.position, name: tab.name.clone(), active: self.active_tab_ids.values().any(|i| i == &tab.id), @@ -4975,6 +4977,7 @@ impl Screen { let selectable_tiled_panes_count = tab.get_selectable_tiled_panes_count(); let selectable_floating_panes_count = tab.get_selectable_floating_panes_count(); let tab_info_for_plugins = TabInfo { + purpose: None, position: tab.position, name: tab .single_pane_tab_name() @@ -7017,6 +7020,7 @@ impl Screen { tab.name.clone(), tab_is_focused, hide_floating_panes, + tab.purpose.clone(), tiled_panes, floating_panes, ); @@ -7123,6 +7127,7 @@ impl Screen { let selectable_floating_panes_count = tab.get_selectable_floating_panes_count(); TabInfo { + purpose: tab.purpose.clone(), position: tab.position, name: tab.name.clone(), active: self.active_tab_ids.values().any(|i| i == &tab.id), @@ -7788,6 +7793,7 @@ pub(crate) fn screen_thread_main( client_or_tab_index, mut completion_tx, set_blocking, + purpose, ) => { completion_tx.as_mut().map(|c| c.set_affected_pane_id(pid)); @@ -7939,6 +7945,19 @@ pub(crate) fn screen_thread_main( screen.log_and_report_session_state()?; screen.render(None)?; + // Applied once here rather than in each of the branches + // above, all of which end in `tab.new_pane`. Authoritative like a + // layout's purpose: whoever can run the CLI can already do + // anything, unlike the in-band OSC path which any program in the + // pane can drive. + if purpose.is_some() { + for tab in screen.tabs.values_mut() { + if tab.get_pane_with_id_mut(pid).is_some() { + tab.set_pane_purpose(pid, purpose.clone()); + break; + } + } + } }, ScreenInstruction::OpenInPlaceEditor(pid, client_tab_index_or_pane_id) => { match client_tab_index_or_pane_id { diff --git a/zellij-server/src/session_layout_metadata.rs b/zellij-server/src/session_layout_metadata.rs index 9ecdff98..9e42221e 100644 --- a/zellij-server/src/session_layout_metadata.rs +++ b/zellij-server/src/session_layout_metadata.rs @@ -208,10 +208,12 @@ impl SessionLayoutMetadata { name: String, is_focused: bool, hide_floating_panes: bool, + purpose: Option<String>, tiled_panes: Vec<PaneLayoutMetadata>, floating_panes: Vec<PaneLayoutMetadata>, ) { self.tabs.push(TabLayoutMetadata { + purpose, name: Some(name), is_focused, hide_floating_panes, @@ -498,6 +500,7 @@ impl Into<TabLayoutManifest> for TabLayoutMetadata { floating_panes: self.floating_panes.into_iter().map(|t| t.into()).collect(), is_focused: self.is_focused, hide_floating_panes: self.hide_floating_panes, + purpose: self.purpose.clone(), } } } @@ -549,6 +552,7 @@ pub struct TabLayoutMetadata { floating_panes: Vec<PaneLayoutMetadata>, is_focused: bool, hide_floating_panes: bool, + purpose: Option<String>, } #[derive(Debug, Clone)] @@ -744,7 +748,7 @@ mod tests { fn session_with_editor(editor: &str, panes: Vec<PaneLayoutMetadata>) -> SessionLayoutMetadata { let mut meta = SessionLayoutMetadata::default(); meta.default_editor = Some(PathBuf::from(editor)); - meta.add_tab("tab1".to_string(), true, false, panes, vec![]); + meta.add_tab("tab1".to_string(), true, false, None, panes, vec![]); meta } diff --git a/zellij-server/src/tab/mod.rs b/zellij-server/src/tab/mod.rs index 93dc50ea..9ed4ce24 100644 --- a/zellij-server/src/tab/mod.rs +++ b/zellij-server/src/tab/mod.rs @@ -298,6 +298,9 @@ pub(crate) struct Tab { pub id: usize, pub position: usize, pub name: String, + /// Opaque agent-facing hint for what this tab is for; see + /// `sl0p/workspace-hints-proposal.md`. + pub purpose: Option<String>, pub prev_name: String, default_name: String, pub size: Size, @@ -1172,6 +1175,8 @@ impl Tab { Tab { id, position, + // set when a layout is applied; a tab created ad hoc has none + purpose: None, tiled_panes, floating_panes, suppressed_panes: HashMap::new(), @@ -2206,6 +2211,14 @@ impl Tab { client_id: ClientId, blocking_terminal: Option<(u32, NotificationEnd)>, ) -> Result<()> { + // The tab's purpose rides its root layout node (the `tab` node itself), + // which is why this is read here and not from any pane. + if layout.purpose.is_some() { + self.purpose = layout + .purpose + .as_deref() + .and_then(zellij_utils::input::layout::sanitize_pane_purpose); + } self.swap_layouts .set_base_layout((layout.clone(), floating_panes_layout.clone())); match LayoutApplier::new( @@ -2928,6 +2941,7 @@ impl Tab { client_id_or_tab_index, completion_tx, false, // set_blocking + None, // purpose ); self.senders .send_to_pty(instruction) @@ -8018,6 +8032,13 @@ impl Tab { pane.update_rounded_corners(rounded_corners); } } + /// Set a pane's purpose from outside the pane (CLI spawn, layout). Outranks + /// anything the program inside the pane declares via OSC. + pub fn set_pane_purpose(&mut self, pane_id: PaneId, purpose: Option<String>) { + if let Some(pane) = self.get_pane_with_id_mut(pane_id) { + pane.set_purpose(purpose); + } + } /// Opaque agent-facing hint for what a pane is for, if it has one. pub fn pane_purpose(&self, pane_id: PaneId) -> Option<String> { self.get_pane_with_id(pane_id).and_then(|p| p.purpose()) diff --git a/zellij-server/src/unit/screen_tests.rs b/zellij-server/src/unit/screen_tests.rs index 90e21f3e..6e872558 100644 --- a/zellij-server/src/unit/screen_tests.rs +++ b/zellij-server/src/unit/screen_tests.rs @@ -3319,6 +3319,7 @@ pub fn send_cli_new_pane_action_with_default_parameters() { pty_receiver ); let cli_new_pane_action = CliAction::NewPane { + purpose: None, direction: None, command: vec![], plugin: None, @@ -3375,6 +3376,7 @@ pub fn send_cli_new_pane_action_with_split_direction() { pty_receiver ); let cli_new_pane_action = CliAction::NewPane { + purpose: None, direction: Some(Direction::Right), command: vec![], plugin: None, @@ -3431,6 +3433,7 @@ pub fn send_cli_new_pane_action_with_command_and_cwd() { pty_receiver ); let cli_new_pane_action = CliAction::NewPane { + purpose: None, direction: Some(Direction::Right), command: vec!["htop".into()], plugin: None, @@ -3498,6 +3501,7 @@ pub fn send_cli_new_pane_action_with_floating_pane_and_coordinates() { pty_receiver ); let cli_new_pane_action = CliAction::NewPane { + purpose: None, direction: Some(Direction::Right), command: vec!["htop".into()], plugin: None, @@ -5326,6 +5330,7 @@ pub fn send_cli_new_pane_in_place_with_close_replaced_pane() { pty_receiver ); let cli_action = CliAction::NewPane { + purpose: None, direction: None, command: vec!["bash".into()], plugin: None, @@ -8066,6 +8071,7 @@ pub fn send_cli_new_pane_action_with_tab_id() { pty_receiver ); let cli_new_pane_action = CliAction::NewPane { + purpose: None, direction: Some(Direction::Right), command: vec![], plugin: None, @@ -8129,6 +8135,7 @@ pub fn send_cli_new_floating_pane_action_with_tab_id() { pty_receiver ); let cli_new_pane_action = CliAction::NewPane { + purpose: None, direction: None, command: vec![], plugin: None, @@ -8241,6 +8248,7 @@ pub fn send_cli_new_pane_action_with_tab_id_and_direction() { pty_receiver ); let cli_new_pane_action = CliAction::NewPane { + purpose: None, direction: Some(Direction::Right), command: vec![], plugin: None, @@ -8303,6 +8311,7 @@ pub fn send_cli_new_pane_action_with_tab_id_and_stacked() { pty_receiver ); let cli_new_pane_action = CliAction::NewPane { + purpose: None, direction: None, command: vec!["ls".into()], plugin: None, @@ -8491,6 +8500,7 @@ pub fn pty_bytes_and_hold_pane_buffered_before_new_pane() { ClientTabIndexOrPaneId::ClientId(client_id), None, // completion_tx false, // set_blocking + None, // purpose )); std::thread::sleep(std::time::Duration::from_millis(100)); diff --git a/zellij-server/src/unit/snapshots/zellij_server__screen__screen_tests__send_cli_edit_action_with_default_parameters.snap b/zellij-server/src/unit/snapshots/zellij_server__screen__screen_tests__send_cli_edit_action_with_default_parameters.snap index 8996ded8..658ff140 100644 --- a/zellij-server/src/unit/snapshots/zellij_server__screen__screen_tests__send_cli_edit_action_with_default_parameters.snap +++ b/zellij-server/src/unit/snapshots/zellij_server__screen__screen_tests__send_cli_edit_action_with_default_parameters.snap @@ -1,5 +1,6 @@ --- source: zellij-server/src/./unit/screen_tests.rs +assertion_line: 3581 expression: "format!(\"{:?}\", *received_pty_instructions.lock().unwrap())" --- -[UpdateActivePane(Some(Terminal(0)), 1), UpdateActivePane(Some(Terminal(0)), 1), SpawnTerminal(Some(OpenFile(OpenFilePayload { path: "/file/to/edit", line_number: None, cwd: Some("."), originating_plugin: None })), Some("Editing: /file/to/edit"), Tiled { direction: None, borderless: None }, false, ClientId(10), Some(NotificationEnd { channel: None, exit_status: None, unblock_condition: None, affected_pane_id: None, affected_tab_id: None, error_message: None, stdout_message: None }), false), Exit] +[UpdateActivePane(Some(Terminal(0)), 1), UpdateActivePane(Some(Terminal(0)), 1), SpawnTerminal(Some(OpenFile(OpenFilePayload { path: "/file/to/edit", line_number: None, cwd: Some("."), originating_plugin: None })), Some("Editing: /file/to/edit"), Tiled { direction: None, borderless: None }, false, ClientId(10), Some(NotificationEnd { channel: None, exit_status: None, unblock_condition: None, affected_pane_id: None, affected_tab_id: None, error_message: None, stdout_message: None }), false, None), Exit] diff --git a/zellij-server/src/unit/snapshots/zellij_server__screen__screen_tests__send_cli_edit_action_with_line_number.snap b/zellij-server/src/unit/snapshots/zellij_server__screen__screen_tests__send_cli_edit_action_with_line_number.snap index 2866060b..ee35a678 100644 --- a/zellij-server/src/unit/snapshots/zellij_server__screen__screen_tests__send_cli_edit_action_with_line_number.snap +++ b/zellij-server/src/unit/snapshots/zellij_server__screen__screen_tests__send_cli_edit_action_with_line_number.snap @@ -1,5 +1,6 @@ --- source: zellij-server/src/./unit/screen_tests.rs +assertion_line: 3625 expression: "format!(\"{:?}\", *received_pty_instructions.lock().unwrap())" --- -[UpdateActivePane(Some(Terminal(0)), 1), UpdateActivePane(Some(Terminal(0)), 1), SpawnTerminal(Some(OpenFile(OpenFilePayload { path: "/file/to/edit", line_number: Some(100), cwd: Some("."), originating_plugin: None })), Some("Editing: /file/to/edit"), Tiled { direction: None, borderless: None }, false, ClientId(10), Some(NotificationEnd { channel: None, exit_status: None, unblock_condition: None, affected_pane_id: None, affected_tab_id: None, error_message: None, stdout_message: None }), false), Exit] +[UpdateActivePane(Some(Terminal(0)), 1), UpdateActivePane(Some(Terminal(0)), 1), SpawnTerminal(Some(OpenFile(OpenFilePayload { path: "/file/to/edit", line_number: Some(100), cwd: Some("."), originating_plugin: None })), Some("Editing: /file/to/edit"), Tiled { direction: None, borderless: None }, false, ClientId(10), Some(NotificationEnd { channel: None, exit_status: None, unblock_condition: None, affected_pane_id: None, affected_tab_id: None, error_message: None, stdout_message: None }), false, None), Exit] diff --git a/zellij-server/src/unit/snapshots/zellij_server__screen__screen_tests__send_cli_edit_action_with_split_direction.snap b/zellij-server/src/unit/snapshots/zellij_server__screen__screen_tests__send_cli_edit_action_with_split_direction.snap index 036f62bf..48614da5 100644 --- a/zellij-server/src/unit/snapshots/zellij_server__screen__screen_tests__send_cli_edit_action_with_split_direction.snap +++ b/zellij-server/src/unit/snapshots/zellij_server__screen__screen_tests__send_cli_edit_action_with_split_direction.snap @@ -1,5 +1,6 @@ --- source: zellij-server/src/./unit/screen_tests.rs +assertion_line: 3669 expression: "format!(\"{:?}\", *received_pty_instructions.lock().unwrap())" --- -[UpdateActivePane(Some(Terminal(0)), 1), UpdateActivePane(Some(Terminal(0)), 1), SpawnTerminal(Some(OpenFile(OpenFilePayload { path: "/file/to/edit", line_number: None, cwd: Some("."), originating_plugin: None })), Some("Editing: /file/to/edit"), Tiled { direction: Some(Down), borderless: None }, false, ClientId(10), Some(NotificationEnd { channel: None, exit_status: None, unblock_condition: None, affected_pane_id: None, affected_tab_id: None, error_message: None, stdout_message: None }), false), Exit] +[UpdateActivePane(Some(Terminal(0)), 1), UpdateActivePane(Some(Terminal(0)), 1), SpawnTerminal(Some(OpenFile(OpenFilePayload { path: "/file/to/edit", line_number: None, cwd: Some("."), originating_plugin: None })), Some("Editing: /file/to/edit"), Tiled { direction: Some(Down), borderless: None }, false, ClientId(10), Some(NotificationEnd { channel: None, exit_status: None, unblock_condition: None, affected_pane_id: None, affected_tab_id: None, error_message: None, stdout_message: None }), false, None), Exit] diff --git a/zellij-server/src/unit/snapshots/zellij_server__screen__screen_tests__send_cli_new_pane_action_with_command_and_cwd.snap b/zellij-server/src/unit/snapshots/zellij_server__screen__screen_tests__send_cli_new_pane_action_with_command_and_cwd.snap index 43dd40d1..e9db2e72 100644 --- a/zellij-server/src/unit/snapshots/zellij_server__screen__screen_tests__send_cli_new_pane_action_with_command_and_cwd.snap +++ b/zellij-server/src/unit/snapshots/zellij_server__screen__screen_tests__send_cli_new_pane_action_with_command_and_cwd.snap @@ -1,5 +1,6 @@ --- source: zellij-server/src/./unit/screen_tests.rs +assertion_line: 3480 expression: "format!(\"{:?}\", new_pane_instruction)" --- -Some(SpawnTerminal(Some(RunCommand(RunCommand { command: "htop", args: [], cwd: Some("/some/folder"), hold_on_close: true, hold_on_start: false, originating_plugin: None, use_terminal_title: false })), None, Tiled { direction: Some(Right), borderless: Some(false) }, false, ClientId(10), Some(NotificationEnd { channel: None, exit_status: None, unblock_condition: None, affected_pane_id: None, affected_tab_id: None, error_message: None, stdout_message: None }), false)) +Some(SpawnTerminal(Some(RunCommand(RunCommand { command: "htop", args: [], cwd: Some("/some/folder"), hold_on_close: true, hold_on_start: false, originating_plugin: None, use_terminal_title: false })), None, Tiled { direction: Some(Right), borderless: Some(false) }, false, ClientId(10), Some(NotificationEnd { channel: None, exit_status: None, unblock_condition: None, affected_pane_id: None, affected_tab_id: None, error_message: None, stdout_message: None }), false, None)) diff --git a/zellij-server/src/unit/snapshots/zellij_server__screen__screen_tests__send_cli_new_pane_action_with_default_parameters.snap b/zellij-server/src/unit/snapshots/zellij_server__screen__screen_tests__send_cli_new_pane_action_with_default_parameters.snap index 433f4d93..7e3b4f51 100644 --- a/zellij-server/src/unit/snapshots/zellij_server__screen__screen_tests__send_cli_new_pane_action_with_default_parameters.snap +++ b/zellij-server/src/unit/snapshots/zellij_server__screen__screen_tests__send_cli_new_pane_action_with_default_parameters.snap @@ -1,5 +1,6 @@ --- source: zellij-server/src/./unit/screen_tests.rs +assertion_line: 3355 expression: "format!(\"{:?}\", *received_pty_instructions.lock().unwrap())" --- -[UpdateActivePane(Some(Terminal(0)), 1), UpdateActivePane(Some(Terminal(0)), 1), SpawnTerminal(None, None, Tiled { direction: None, borderless: Some(false) }, false, ClientId(10), Some(NotificationEnd { channel: None, exit_status: None, unblock_condition: None, affected_pane_id: None, affected_tab_id: None, error_message: None, stdout_message: None }), false), Exit] +[UpdateActivePane(Some(Terminal(0)), 1), UpdateActivePane(Some(Terminal(0)), 1), SpawnTerminal(None, None, Tiled { direction: None, borderless: Some(false) }, false, ClientId(10), Some(NotificationEnd { channel: None, exit_status: None, unblock_condition: None, affected_pane_id: None, affected_tab_id: None, error_message: None, stdout_message: None }), false, None), Exit] diff --git a/zellij-server/src/unit/snapshots/zellij_server__screen__screen_tests__send_cli_new_pane_action_with_floating_pane_and_coordinates.snap b/zellij-server/src/unit/snapshots/zellij_server__screen__screen_tests__send_cli_new_pane_action_with_floating_pane_and_coordinates.snap index f33730b2..d2151356 100644 --- a/zellij-server/src/unit/snapshots/zellij_server__screen__screen_tests__send_cli_new_pane_action_with_floating_pane_and_coordinates.snap +++ b/zellij-server/src/unit/snapshots/zellij_server__screen__screen_tests__send_cli_new_pane_action_with_floating_pane_and_coordinates.snap @@ -1,5 +1,6 @@ --- source: zellij-server/src/./unit/screen_tests.rs +assertion_line: 3537 expression: "format!(\"{:?}\", *received_pty_instructions.lock().unwrap())" --- -[UpdateActivePane(Some(Terminal(0)), 1), UpdateActivePane(Some(Terminal(0)), 1), SpawnTerminal(Some(RunCommand(RunCommand { command: "htop", args: [], cwd: Some("/some/folder"), hold_on_close: true, hold_on_start: false, originating_plugin: None, use_terminal_title: false })), None, Floating(Some(FloatingPaneCoordinates { x: Some(Fixed(10)), y: None, width: Some(Percent(20)), height: None, pinned: None, borderless: Some(false) })), false, ClientId(10), Some(NotificationEnd { channel: None, exit_status: None, unblock_condition: None, affected_pane_id: None, affected_tab_id: None, error_message: None, stdout_message: None }), false), Exit] +[UpdateActivePane(Some(Terminal(0)), 1), UpdateActivePane(Some(Terminal(0)), 1), SpawnTerminal(Some(RunCommand(RunCommand { command: "htop", args: [], cwd: Some("/some/folder"), hold_on_close: true, hold_on_start: false, originating_plugin: None, use_terminal_title: false })), None, Floating(Some(FloatingPaneCoordinates { x: Some(Fixed(10)), y: None, width: Some(Percent(20)), height: None, pinned: None, borderless: Some(false) })), false, ClientId(10), Some(NotificationEnd { channel: None, exit_status: None, unblock_condition: None, affected_pane_id: None, affected_tab_id: None, error_message: None, stdout_message: None }), false, None), Exit] diff --git a/zellij-server/src/unit/snapshots/zellij_server__screen__screen_tests__send_cli_new_pane_action_with_split_direction.snap b/zellij-server/src/unit/snapshots/zellij_server__screen__screen_tests__send_cli_new_pane_action_with_split_direction.snap index 1aebde3e..629e6c76 100644 --- a/zellij-server/src/unit/snapshots/zellij_server__screen__screen_tests__send_cli_new_pane_action_with_split_direction.snap +++ b/zellij-server/src/unit/snapshots/zellij_server__screen__screen_tests__send_cli_new_pane_action_with_split_direction.snap @@ -1,5 +1,6 @@ --- source: zellij-server/src/./unit/screen_tests.rs +assertion_line: 3412 expression: "format!(\"{:?}\", *received_pty_instructions.lock().unwrap())" --- -[UpdateActivePane(Some(Terminal(0)), 1), UpdateActivePane(Some(Terminal(0)), 1), SpawnTerminal(None, None, Tiled { direction: Some(Right), borderless: Some(false) }, false, ClientId(10), Some(NotificationEnd { channel: None, exit_status: None, unblock_condition: None, affected_pane_id: None, affected_tab_id: None, error_message: None, stdout_message: None }), false), Exit] +[UpdateActivePane(Some(Terminal(0)), 1), UpdateActivePane(Some(Terminal(0)), 1), SpawnTerminal(None, None, Tiled { direction: Some(Right), borderless: Some(false) }, false, ClientId(10), Some(NotificationEnd { channel: None, exit_status: None, unblock_condition: None, affected_pane_id: None, affected_tab_id: None, error_message: None, stdout_message: None }), false, None), Exit] diff --git a/zellij-utils/assets/prost/api.action.rs b/zellij-utils/assets/prost/api.action.rs index cfe201cf..8a7f1ad9 100644 --- a/zellij-utils/assets/prost/api.action.rs +++ b/zellij-utils/assets/prost/api.action.rs @@ -530,6 +530,8 @@ pub struct NewFloatingPanePayload { pub command: ::core::option::Option<RunCommandAction>, #[prost(bool, tag="2")] pub near_current_pane: bool, + #[prost(string, optional, tag="3")] + pub purpose: ::core::option::Option<::prost::alloc::string::String>, } #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] @@ -542,6 +544,8 @@ pub struct NewTiledPanePayload { pub near_current_pane: bool, #[prost(bool, optional, tag="4")] pub borderless: ::core::option::Option<bool>, + #[prost(string, optional, tag="5")] + pub purpose: ::core::option::Option<::prost::alloc::string::String>, } #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] @@ -592,6 +596,8 @@ pub struct NewBlockingPanePayload { pub unblock_condition: ::core::option::Option<i32>, #[prost(bool, tag="5")] pub near_current_pane: bool, + #[prost(string, optional, tag="6")] + pub purpose: ::core::option::Option<::prost::alloc::string::String>, } #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] diff --git a/zellij-utils/assets/prost_ipc/client_server_contract.rs b/zellij-utils/assets/prost_ipc/client_server_contract.rs index 18fdee65..0db61be9 100644 --- a/zellij-utils/assets/prost_ipc/client_server_contract.rs +++ b/zellij-utils/assets/prost_ipc/client_server_contract.rs @@ -893,6 +893,8 @@ pub struct NewFloatingPaneAction { pub tab_id: ::core::option::Option<u32>, #[prost(bool, tag="9")] pub no_focus: bool, + #[prost(string, optional, tag="10")] + pub purpose: ::core::option::Option<::prost::alloc::string::String>, } #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] @@ -911,6 +913,8 @@ pub struct NewTiledPaneAction { pub tab_id: ::core::option::Option<u32>, #[prost(bool, tag="10")] pub no_focus: bool, + #[prost(string, optional, tag="11")] + pub purpose: ::core::option::Option<::prost::alloc::string::String>, } #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] @@ -943,6 +947,8 @@ pub struct NewStackedPaneAction { pub tab_id: ::core::option::Option<u32>, #[prost(bool, tag="5")] pub no_focus: bool, + #[prost(string, optional, tag="6")] + pub purpose: ::core::option::Option<::prost::alloc::string::String>, } #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] @@ -961,6 +967,8 @@ pub struct NewBlockingPaneAction { pub tab_id: ::core::option::Option<u32>, #[prost(bool, tag="7")] pub no_focus: bool, + #[prost(string, optional, tag="8")] + pub purpose: ::core::option::Option<::prost::alloc::string::String>, } #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] 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, |
