Command Sequences, Blocking CLI Commands, New Plugin APIs (#4546)

* fix deadlocks

* initial logging to plugin implementation

* only log actions, include keys as Action::Write

* send actions to plugins as a new Event::UserAction

* add Action::Write metadata to plugin API

* run_action plugin command and permission

* block-until-exit/success/failure in the cli

* fix tests

* add NewBlockingPane to the plugin API

* run actions in a different thread so as not to block plugin execution

* add custom context to run_action flow

* fixes for floating pane z_index sorting and unknown protobuf events not crashing plugins

* always render the frame of pinned panes

* macros module

* add macros to config

* KDL macro parsing

* thread macros to the plugin thread

* get/update/delete macros from plugins

* add macros updated event

* add ability to rename macros

* fix deadlock

* fix z_indices with pinned unselectable panes bug

* allow plugins to opt-in to show_cursor

* change show_cursor(bool) to show_cursor(Option<coordinates>)

* test

* upgrade strum to allow proper enum iteration and case insensitive fromstr implementation for Actions

* better cwd tracking for exited command panes

* add initial panes to NewTab Action (no-op for now)

* add initial panes to cli newtab action

* make initial panes work

* add close_on_exit and start_suspended to the cli

* properly convert newtab action (potentially breaking for plugins)

* allow first pane in tab to block

* make distinction between opening panes with the last active client and the current pane in the cli

* improve some apis

* fix fixed size closing pane bug

* fix plugin cursor coordinates

* fix issues with launch-or-focus-plugin having to do with in-place and suppressed panes

* fix: set affected pane in replace pane

* allow setting a pane in place when it is suppressed and being focused (tests failing)

* fix tests

* some fixes for unsuppression of panes

* some fixes for completion_tx in new tab action

* allow replace/suppressed rather than replace/close only

* fixes for inserting suppressed panes without conflicts

* pane focus history and explicit unsuppress plugin api

* fix: prefer explicitly provided pane id in placement in actions that provide it

* fix floating pane coordinate change and z_index

* fix plugin cache dir to persist between sessions

* fix some stack focusing issues when adding panes without a client id

* make sure pinned panes are always above non-pinned floating panes

* allow plugins to copy to clipboard

* initial text styling additions implementations - buggy

* fix dimming issues

* remove extras, stay with just dim and unbold

* unbold and dim as indices

* allow ui components to render success/error colors

* allow plugins to send sigint/sigkill to processes, as well as receive their pid

* ui component fixes and send originating plugin context with run_action

* originating plugin for new tab too

* make plugn show pane command also expand stacked panes

* add sequence plugin

* remove macros

* remove snapshots

* add shortcut for sequence plugin

* fix plugin tests

* plugin tests for new apis

* update snapshots for sequence shortcut

* cleanups and formatting

* Event::CwdChanged

* moar cleanups

* remove some warnings

* remove mroe warnings

* remove dead code

* remove more dead code

* fix minor bugs in sequence plugin

* update sequence help and format codebase

* fix conditional blocking cli commands

* fix initial-command new-tab cli flag

* add client_ids to CwdChanged Event

* last fixes

* add pr
This commit is contained in:
Aram Drevekenin
2025-12-11 14:25:42 +01:00
committed by GitHub
parent 7c8945fbf3
commit 693fa082de
201 changed files with 12403 additions and 890 deletions
+122 -18
View File
@@ -10,8 +10,8 @@ use zellij_utils::input::actions::Action;
pub use zellij_utils::plugin_api;
use zellij_utils::plugin_api::event::ProtobufPaneScrollbackResponse;
use zellij_utils::plugin_api::plugin_command::{
CreateTokenResponse, ListTokensResponse, ProtobufPluginCommand, RenameWebTokenResponse,
RevokeAllWebTokensResponse, RevokeTokenResponse,
CreateTokenResponse, ListTokensResponse, ProtobufGetPanePidResponse, ProtobufPluginCommand,
RenameWebTokenResponse, RevokeAllWebTokensResponse, RevokeTokenResponse,
};
use zellij_utils::plugin_api::plugin_ids::{ProtobufPluginIds, ProtobufZellijVersion};
@@ -48,6 +48,17 @@ pub fn set_selectable(selectable: bool) {
unsafe { host_run_plugin_command() };
}
/// Shows the cursor at specific coordinates or hides it
///
/// # Arguments
/// * `cursor_position` - None to hide cursor, Some((x, y)) to show at coordinates
pub fn show_cursor(cursor_position: Option<(usize, usize)>) {
let plugin_command = PluginCommand::ShowCursor(cursor_position);
let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
object_to_stdout(&protobuf_plugin_command.encode_to_vec());
unsafe { host_run_plugin_command() };
}
pub fn request_permission(permissions: &[PermissionType]) {
let plugin_command = PluginCommand::RequestPluginPermissions(permissions.into());
let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
@@ -395,8 +406,9 @@ pub fn show_self(should_float_if_hidden: bool) {
}
/// Show the pane (unsuppress it if it is suppressed) with the specified [PaneId], focus it and switch to its tab
pub fn show_pane_with_id(pane_id: PaneId, should_float_if_hidden: bool) {
let plugin_command = PluginCommand::ShowPaneWithId(pane_id, should_float_if_hidden);
pub fn show_pane_with_id(pane_id: PaneId, should_float_if_hidden: bool, should_focus_pane: bool) {
let plugin_command =
PluginCommand::ShowPaneWithId(pane_id, should_float_if_hidden, should_focus_pane);
let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
object_to_stdout(&protobuf_plugin_command.encode_to_vec());
unsafe { host_run_plugin_command() };
@@ -561,6 +573,17 @@ pub fn write_chars(chars: &str) {
unsafe { host_run_plugin_command() };
}
/// Copy arbitrary text to the user's clipboard
///
/// Respects the user's configured clipboard destination (system clipboard or primary selection).
/// Requires the WriteToClipboard permission.
pub fn copy_to_clipboard(text: impl Into<String>) {
let plugin_command = PluginCommand::CopyToClipboard(text.into());
let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
object_to_stdout(&protobuf_plugin_command.encode_to_vec());
unsafe { host_run_plugin_command() };
}
/// Focused the previously focused tab (regardless of the tab position)
pub fn toggle_tab() {
let plugin_command = PluginCommand::ToggleTab;
@@ -774,16 +797,32 @@ pub fn close_plugin_pane(plugin_pane_id: u32) {
}
/// Changes the focus to the terminal pane with the specified id, unsuppressing it if it was suppressed and switching to its tab and layer (eg. floating/tiled).
pub fn focus_terminal_pane(terminal_pane_id: u32, should_float_if_hidden: bool) {
let plugin_command = PluginCommand::FocusTerminalPane(terminal_pane_id, should_float_if_hidden);
pub fn focus_terminal_pane(
terminal_pane_id: u32,
should_float_if_hidden: bool,
should_be_in_place_if_hidden: bool,
) {
let plugin_command = PluginCommand::FocusTerminalPane(
terminal_pane_id,
should_float_if_hidden,
should_be_in_place_if_hidden,
);
let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
object_to_stdout(&protobuf_plugin_command.encode_to_vec());
unsafe { host_run_plugin_command() };
}
/// Changes the focus to the plugin pane with the specified id, unsuppressing it if it was suppressed and switching to its tab and layer (eg. floating/tiled).
pub fn focus_plugin_pane(plugin_pane_id: u32, should_float_if_hidden: bool) {
let plugin_command = PluginCommand::FocusPluginPane(plugin_pane_id, should_float_if_hidden);
pub fn focus_plugin_pane(
plugin_pane_id: u32,
should_float_if_hidden: bool,
should_be_in_place_if_hidden: bool,
) {
let plugin_command = PluginCommand::FocusPluginPane(
plugin_pane_id,
should_float_if_hidden,
should_be_in_place_if_hidden,
);
let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
object_to_stdout(&protobuf_plugin_command.encode_to_vec());
unsafe { host_run_plugin_command() };
@@ -1023,14 +1062,22 @@ pub fn resize_pane_with_id(resize_strategy: ResizeStrategy, pane_id: PaneId) {
}
/// Changes the focus to the pane with the specified id, unsuppressing it if it was suppressed and switching to its tab and layer (eg. floating/tiled).
pub fn focus_pane_with_id(pane_id: PaneId, should_float_if_hidden: bool) {
pub fn focus_pane_with_id(
pane_id: PaneId,
should_float_if_hidden: bool,
should_be_in_place_if_hidden: bool,
) {
let plugin_command = match pane_id {
PaneId::Terminal(terminal_pane_id) => {
PluginCommand::FocusTerminalPane(terminal_pane_id, should_float_if_hidden)
},
PaneId::Plugin(plugin_pane_id) => {
PluginCommand::FocusPluginPane(plugin_pane_id, should_float_if_hidden)
},
PaneId::Terminal(terminal_pane_id) => PluginCommand::FocusTerminalPane(
terminal_pane_id,
should_float_if_hidden,
should_be_in_place_if_hidden,
),
PaneId::Plugin(plugin_pane_id) => PluginCommand::FocusPluginPane(
plugin_pane_id,
should_float_if_hidden,
should_be_in_place_if_hidden,
),
};
let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
object_to_stdout(&protobuf_plugin_command.encode_to_vec());
@@ -1102,6 +1149,48 @@ pub fn write_chars_to_pane_id(chars: &str, pane_id: PaneId) {
unsafe { host_run_plugin_command() };
}
/// Send SIGINT to the process running inside a terminal pane identified by this PaneId
pub fn send_sigint_to_pane_id(pane_id: PaneId) {
let plugin_command = PluginCommand::SendSigintToPaneId(pane_id);
let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
object_to_stdout(&protobuf_plugin_command.encode_to_vec());
unsafe { host_run_plugin_command() };
}
/// Send SIGKILL to the process running inside a terminal pane identified by this PaneId
pub fn send_sigkill_to_pane_id(pane_id: PaneId) {
let plugin_command = PluginCommand::SendSigkillToPaneId(pane_id);
let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
object_to_stdout(&protobuf_plugin_command.encode_to_vec());
unsafe { host_run_plugin_command() };
}
/// Get the PID of the process running inside a terminal pane
pub fn get_pane_pid(pane_id: PaneId) -> Result<i32, String> {
let plugin_command = PluginCommand::GetPanePid { pane_id };
let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
object_to_stdout(&protobuf_plugin_command.encode_to_vec());
unsafe { host_run_plugin_command() };
// Read response from stdin
let response_bytes =
bytes_from_stdin().map_err(|e| format!("Failed to read response from stdin: {:?}", e))?;
// Decode protobuf response
let protobuf_response = ProtobufGetPanePidResponse::decode(response_bytes.as_slice())
.map_err(|e| format!("Failed to decode protobuf response: {}", e))?;
// Convert to Rust type
let response = GetPanePidResponse::try_from(protobuf_response)
.map_err(|e| format!("Failed to convert protobuf response: {}", e))?;
// Convert Result enum to actual Result type
match response {
GetPanePidResponse::Ok(pid) => Ok(pid),
GetPanePidResponse::Err(error_msg) => Err(error_msg),
}
}
/// Switch the position of the pane with this id with a different pane
pub fn move_pane_with_pane_id(pane_id: PaneId) {
let plugin_command = PluginCommand::MovePaneWithPaneId(pane_id);
@@ -1504,9 +1593,16 @@ pub fn clear_key_presses_intercepts() {
unsafe { host_run_plugin_command() };
}
pub fn replace_pane_with_existing_pane(pane_id_to_replace: PaneId, existing_pane_id: PaneId) {
let plugin_command =
PluginCommand::ReplacePaneWithExistingPane(pane_id_to_replace, existing_pane_id);
pub fn replace_pane_with_existing_pane(
pane_id_to_replace: PaneId,
existing_pane_id: PaneId,
suppress_replaced_pane: bool,
) {
let plugin_command = PluginCommand::ReplacePaneWithExistingPane(
pane_id_to_replace,
existing_pane_id,
suppress_replaced_pane,
);
let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
object_to_stdout(&protobuf_plugin_command.encode_to_vec());
unsafe { host_run_plugin_command() };
@@ -1580,6 +1676,14 @@ pub fn post_message_to_plugin(plugin_message: PluginMessage) {
unsafe { host_run_plugin_command() };
}
pub fn run_action(action: Action, context: BTreeMap<String, String>) {
// TODO: also accept reference
let plugin_command = PluginCommand::RunAction(action, context);
let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
object_to_stdout(&protobuf_plugin_command.encode_to_vec());
unsafe { host_run_plugin_command() };
}
#[link(wasm_import_module = "zellij")]
extern "C" {
fn host_run_plugin_command();
@@ -38,6 +38,58 @@ impl NestedListItem {
self.content = self.content.color_range(index_level, indices);
self
}
pub fn error_color_indices(mut self, indices: Vec<usize>) -> Self {
self.content = self.content.error_color_indices(indices);
self
}
pub fn error_color_range<R: RangeBounds<usize>>(mut self, indices: R) -> Self {
self.content = self.content.error_color_range(indices);
self
}
pub fn error_color_substring<S: AsRef<str>>(mut self, substr: S) -> Self {
self.content = self.content.error_color_substring(substr);
self
}
pub fn error_color_nth_substring<S: AsRef<str>>(
mut self,
substr: S,
occurrence_index: usize,
) -> Self {
self.content = self
.content
.error_color_nth_substring(substr, occurrence_index);
self
}
pub fn error_color_all(mut self) -> Self {
self.content = self.content.error_color_all();
self
}
pub fn success_color_indices(mut self, indices: Vec<usize>) -> Self {
self.content = self.content.success_color_indices(indices);
self
}
pub fn success_color_range<R: RangeBounds<usize>>(mut self, indices: R) -> Self {
self.content = self.content.success_color_range(indices);
self
}
pub fn success_color_substring<S: AsRef<str>>(mut self, substr: S) -> Self {
self.content = self.content.success_color_substring(substr);
self
}
pub fn success_color_nth_substring<S: AsRef<str>>(
mut self,
substr: S,
occurrence_index: usize,
) -> Self {
self.content = self
.content
.success_color_nth_substring(substr, occurrence_index);
self
}
pub fn success_color_all(mut self) -> Self {
self.content = self.content.success_color_all();
self
}
pub fn serialize(&self) -> String {
let mut serialized = String::new();
for _ in 0..self.indentation_level {
+218 -3
View File
@@ -29,6 +29,220 @@ impl Text {
self.opaque = true;
self
}
pub fn dim_indices(mut self, mut indices: Vec<usize>) -> Self {
const DIM_LEVEL: usize = 4;
self.pad_indices(DIM_LEVEL);
self.indices
.get_mut(DIM_LEVEL)
.map(|i| i.append(&mut indices));
self
}
pub fn dim_range<R: RangeBounds<usize>>(mut self, indices: R) -> Self {
const DIM_LEVEL: usize = 4;
self.pad_indices(DIM_LEVEL);
let start = match indices.start_bound() {
Bound::Unbounded => 0,
Bound::Included(s) => *s,
Bound::Excluded(s) => *s,
};
let end = match indices.end_bound() {
Bound::Unbounded => self.text.chars().count(),
Bound::Included(s) => *s + 1,
Bound::Excluded(s) => *s,
};
let indices = (start..end).into_iter();
self.indices
.get_mut(DIM_LEVEL)
.map(|i| i.append(&mut indices.into_iter().collect()));
self
}
pub fn dim_substring<S: AsRef<str>>(mut self, substr: S) -> Self {
let substr = substr.as_ref();
let mut start = 0;
while let Some(pos) = self.text[start..].find(substr) {
let abs_pos = start + pos;
self = self.dim_range(abs_pos..abs_pos + substr.chars().count());
start = abs_pos + substr.len();
}
self
}
pub fn dim_all(self) -> Self {
const DIM_LEVEL: usize = 4;
self.color_range(DIM_LEVEL, ..)
}
pub fn unbold_indices(mut self, mut indices: Vec<usize>) -> Self {
const UNBOLD_LEVEL: usize = 5;
self.pad_indices(UNBOLD_LEVEL);
self.indices
.get_mut(UNBOLD_LEVEL)
.map(|i| i.append(&mut indices));
self
}
pub fn unbold_range<R: RangeBounds<usize>>(mut self, indices: R) -> Self {
const UNBOLD_LEVEL: usize = 5;
self.pad_indices(UNBOLD_LEVEL);
let start = match indices.start_bound() {
Bound::Unbounded => 0,
Bound::Included(s) => *s,
Bound::Excluded(s) => *s,
};
let end = match indices.end_bound() {
Bound::Unbounded => self.text.chars().count(),
Bound::Included(s) => *s + 1,
Bound::Excluded(s) => *s,
};
let indices = (start..end).into_iter();
self.indices
.get_mut(UNBOLD_LEVEL)
.map(|i| i.append(&mut indices.into_iter().collect()));
self
}
pub fn unbold_substring<S: AsRef<str>>(mut self, substr: S) -> Self {
let substr = substr.as_ref();
let mut start = 0;
while let Some(pos) = self.text[start..].find(substr) {
let abs_pos = start + pos;
self = self.unbold_range(abs_pos..abs_pos + substr.chars().count());
start = abs_pos + substr.len();
}
self
}
pub fn unbold_all(self) -> Self {
const UNBOLD_LEVEL: usize = 5;
self.color_range(UNBOLD_LEVEL, ..)
}
pub fn error_color_indices(mut self, mut indices: Vec<usize>) -> Self {
const ERROR_COLOR_LEVEL: usize = 6;
self.pad_indices(ERROR_COLOR_LEVEL);
self.indices
.get_mut(ERROR_COLOR_LEVEL)
.map(|i| i.append(&mut indices));
self
}
pub fn error_color_range<R: RangeBounds<usize>>(mut self, indices: R) -> Self {
const ERROR_COLOR_LEVEL: usize = 6;
self.pad_indices(ERROR_COLOR_LEVEL);
let start = match indices.start_bound() {
Bound::Unbounded => 0,
Bound::Included(s) => *s,
Bound::Excluded(s) => *s,
};
let end = match indices.end_bound() {
Bound::Unbounded => self.text.chars().count(),
Bound::Included(s) => *s + 1,
Bound::Excluded(s) => *s,
};
let indices = (start..end).into_iter();
self.indices
.get_mut(ERROR_COLOR_LEVEL)
.map(|i| i.append(&mut indices.into_iter().collect()));
self
}
pub fn error_color_substring<S: AsRef<str>>(mut self, substr: S) -> Self {
let substr = substr.as_ref();
let mut start = 0;
while let Some(pos) = self.text[start..].find(substr) {
let abs_pos = start + pos;
self = self.error_color_range(abs_pos..abs_pos + substr.chars().count());
start = abs_pos + substr.len();
}
self
}
pub fn error_color_nth_substring<S: AsRef<str>>(
self,
substr: S,
occurrence_index: usize,
) -> Self {
const ERROR_COLOR_LEVEL: usize = 6;
let substr = substr.as_ref();
let mut start = 0;
let mut count = 0;
while let Some(pos) = self.text[start..].find(substr) {
if count == occurrence_index {
let abs_pos = start + pos;
return self.color_range(ERROR_COLOR_LEVEL, abs_pos..abs_pos + substr.len());
}
count += 1;
start = start + pos + substr.len();
}
self
}
pub fn error_color_all(self) -> Self {
const ERROR_COLOR_LEVEL: usize = 6;
self.color_range(ERROR_COLOR_LEVEL, ..)
}
pub fn success_color_indices(mut self, mut indices: Vec<usize>) -> Self {
const SUCCESS_COLOR_LEVEL: usize = 7;
self.pad_indices(SUCCESS_COLOR_LEVEL);
self.indices
.get_mut(SUCCESS_COLOR_LEVEL)
.map(|i| i.append(&mut indices));
self
}
pub fn success_color_range<R: RangeBounds<usize>>(mut self, indices: R) -> Self {
const SUCCESS_COLOR_LEVEL: usize = 7;
self.pad_indices(SUCCESS_COLOR_LEVEL);
let start = match indices.start_bound() {
Bound::Unbounded => 0,
Bound::Included(s) => *s,
Bound::Excluded(s) => *s,
};
let end = match indices.end_bound() {
Bound::Unbounded => self.text.chars().count(),
Bound::Included(s) => *s + 1,
Bound::Excluded(s) => *s,
};
let indices = (start..end).into_iter();
self.indices
.get_mut(SUCCESS_COLOR_LEVEL)
.map(|i| i.append(&mut indices.into_iter().collect()));
self
}
pub fn success_color_substring<S: AsRef<str>>(mut self, substr: S) -> Self {
let substr = substr.as_ref();
let mut start = 0;
while let Some(pos) = self.text[start..].find(substr) {
let abs_pos = start + pos;
self = self.success_color_range(abs_pos..abs_pos + substr.chars().count());
start = abs_pos + substr.len();
}
self
}
pub fn success_color_nth_substring<S: AsRef<str>>(
self,
substr: S,
occurrence_index: usize,
) -> Self {
const SUCCESS_COLOR_LEVEL: usize = 7;
let substr = substr.as_ref();
let mut start = 0;
let mut count = 0;
while let Some(pos) = self.text[start..].find(substr) {
if count == occurrence_index {
let abs_pos = start + pos;
return self.color_range(SUCCESS_COLOR_LEVEL, abs_pos..abs_pos + substr.len());
}
count += 1;
start = start + pos + substr.len();
}
self
}
pub fn success_color_all(self) -> Self {
const SUCCESS_COLOR_LEVEL: usize = 7;
self.color_range(SUCCESS_COLOR_LEVEL, ..)
}
pub fn color_indices(mut self, index_level: usize, mut indices: Vec<usize>) -> Self {
self.pad_indices(index_level);
self.indices
@@ -54,16 +268,17 @@ impl Text {
.map(|i| i.append(&mut indices.into_iter().collect()));
self
}
pub fn color_substring<S: AsRef<str>>(mut self, index_level: usize, substr: S) -> Self {
let substr = substr.as_ref();
let mut start = 0;
while let Some(pos) = self.text[start..].find(substr) {
let abs_pos = start + pos;
self = self.color_range(index_level, abs_pos..abs_pos + substr.chars().count());
let char_start = self.text[..abs_pos].chars().count();
let char_end = char_start + substr.chars().count();
self = self.color_range(index_level, char_start..char_end);
start = abs_pos + substr.len();
}
self
}