aboutsummaryrefslogtreecommitdiffstats
path: root/default-plugins
diff options
context:
space:
mode:
authorAram Drevekenin <aram@poor.dev>2025-12-11 14:25:42 +0100
committerGitHub <noreply@github.com>2025-12-11 14:25:42 +0100
commit693fa082def5ef0fe030cd7fdc3b1df051b35dc6 (patch)
treeb3f651462bbc1cd2fa5b2d79207343413ef6b83c /default-plugins
parentfix(zellij-server): unblock building on 32bit targets (#4484) (diff)
downloadsl0ppij-693fa082def5ef0fe030cd7fdc3b1df051b35dc6.tar.gz
sl0ppij-693fa082def5ef0fe030cd7fdc3b1df051b35dc6.tar.xz
sl0ppij-693fa082def5ef0fe030cd7fdc3b1df051b35dc6.zip
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
Diffstat (limited to 'default-plugins')
-rw-r--r--default-plugins/compact-bar/src/keybind_utils.rs7
-rw-r--r--default-plugins/configuration/src/presets.rs36
-rw-r--r--default-plugins/fixture-plugin-for-tests/src/main.rs42
-rw-r--r--default-plugins/plugin-manager/src/main.rs2
-rw-r--r--default-plugins/sequence/.cargo/config.toml2
-rw-r--r--default-plugins/sequence/.gitignore1
-rw-r--r--default-plugins/sequence/Cargo.toml16
l---------default-plugins/sequence/LICENSE.md1
-rw-r--r--default-plugins/sequence/src/lib.rs5
-rw-r--r--default-plugins/sequence/src/main.rs1131
-rw-r--r--default-plugins/sequence/src/path_formatting.rs71
-rw-r--r--default-plugins/sequence/src/state/chain_type.rs45
-rw-r--r--default-plugins/sequence/src/state/command_entry.rs86
-rw-r--r--default-plugins/sequence/src/state/command_parser.rs327
-rw-r--r--default-plugins/sequence/src/state/command_status.rs26
-rw-r--r--default-plugins/sequence/src/state/editing.rs80
-rw-r--r--default-plugins/sequence/src/state/execution.rs265
-rw-r--r--default-plugins/sequence/src/state/layout.rs73
-rw-r--r--default-plugins/sequence/src/state/mod.rs446
-rw-r--r--default-plugins/sequence/src/state/positioning.rs48
-rw-r--r--default-plugins/sequence/src/state/selection.rs214
-rw-r--r--default-plugins/sequence/src/ui/components.rs596
-rw-r--r--default-plugins/sequence/src/ui/fuzzy_complete.rs321
-rw-r--r--default-plugins/sequence/src/ui/layout_calculations.rs28
-rw-r--r--default-plugins/sequence/src/ui/mod.rs5
-rw-r--r--default-plugins/sequence/src/ui/text_input.rs997
-rw-r--r--default-plugins/sequence/src/ui/truncation.rs373
-rw-r--r--default-plugins/session-manager/src/main.rs4
-rw-r--r--default-plugins/status-bar/src/one_line_ui.rs26
-rw-r--r--default-plugins/status-bar/src/second_line.rs24
30 files changed, 5285 insertions, 13 deletions
diff --git a/default-plugins/compact-bar/src/keybind_utils.rs b/default-plugins/compact-bar/src/keybind_utils.rs
index aeb862d1..2f84497c 100644
--- a/default-plugins/compact-bar/src/keybind_utils.rs
+++ b/default-plugins/compact-bar/src/keybind_utils.rs
@@ -440,7 +440,8 @@ impl KeybindProcessor {
action,
Action::NewStackedPane {
command: None,
- pane_name: None
+ pane_name: None,
+ near_current_pane: false,
}
)
},
@@ -481,7 +482,9 @@ impl KeybindProcessor {
swap_floating_layouts: None,
tab_name: None,
should_change_focus_to_new_tab: true,
- cwd: None
+ cwd: None,
+ initial_panes: _,
+ first_pane_unblock_condition: _,
}
)
},
diff --git a/default-plugins/configuration/src/presets.rs b/default-plugins/configuration/src/presets.rs
index 0c972a1d..7e875073 100644
--- a/default-plugins/configuration/src/presets.rs
+++ b/default-plugins/configuration/src/presets.rs
@@ -155,6 +155,12 @@ keybinds clear-defaults=true {{
}};
SwitchToMode "Locked"
}}
+ bind "q" {{
+ LaunchPlugin "zellij:sequence" {{
+ floating true
+ }};
+ SwitchToMode "Locked"
+ }}
}}
shared_except "locked" "renametab" "renamepane" {{
bind "{primary_modifier} g" {{ SwitchToMode "Locked"; }}
@@ -371,6 +377,12 @@ keybinds clear-defaults=true {{
}};
SwitchToMode "Normal"
}}
+ bind "q" {{
+ LaunchPlugin "zellij:sequence" {{
+ floating true
+ }};
+ SwitchToMode "Normal"
+ }}
}}
tmux {{
bind "[" {{ SwitchToMode "Scroll"; }}
@@ -587,6 +599,12 @@ keybinds clear-defaults=true {{
}};
SwitchToMode "Normal"
}}
+ bind "q" {{
+ LaunchPlugin "zellij:sequence" {{
+ floating true
+ }};
+ SwitchToMode "Normal"
+ }}
}}
tmux {{
bind "[" {{ SwitchToMode "Scroll"; }}
@@ -784,6 +802,12 @@ keybinds clear-defaults=true {{
}};
SwitchToMode "Normal"
}}
+ bind "q" {{
+ LaunchPlugin "zellij:sequence" {{
+ floating true
+ }};
+ SwitchToMode "Normal"
+ }}
}}
tmux {{
bind "[" {{ SwitchToMode "Scroll"; }}
@@ -981,6 +1005,12 @@ keybinds clear-defaults=true {{
}};
SwitchToMode "Normal"
}}
+ bind "q" {{
+ LaunchPlugin "zellij:sequence" {{
+ floating true
+ }};
+ SwitchToMode "Normal"
+ }}
}}
tmux {{
bind "[" {{ SwitchToMode "Scroll"; }}
@@ -1173,6 +1203,12 @@ keybinds clear-defaults=true {{
}};
SwitchToMode "Normal"
}}
+ bind "q" {{
+ LaunchPlugin "zellij:sequence" {{
+ floating true
+ }};
+ SwitchToMode "Normal"
+ }}
}}
tmux {{
bind "[" {{ SwitchToMode "Scroll"; }}
diff --git a/default-plugins/fixture-plugin-for-tests/src/main.rs b/default-plugins/fixture-plugin-for-tests/src/main.rs
index 01489860..9f5b7e93 100644
--- a/default-plugins/fixture-plugin-for-tests/src/main.rs
+++ b/default-plugins/fixture-plugin-for-tests/src/main.rs
@@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[allow(unused_imports)]
use std::io::prelude::*;
+use zellij_tile::prelude::actions::Action;
use zellij_tile::prelude::*;
// This is a fixture plugin used only for tests in Zellij
@@ -59,6 +60,8 @@ impl ZellijPlugin for State {
PermissionType::ReadCliPipes,
PermissionType::MessageAndLaunchOtherPlugins,
PermissionType::Reconfigure,
+ PermissionType::WriteToClipboard,
+ PermissionType::RunActionsAsUser,
]);
self.configuration = configuration;
subscribe(&[
@@ -258,11 +261,13 @@ impl ZellijPlugin for State {
},
BareKey::Char('t') if key.has_modifiers(&[KeyModifier::Ctrl]) => {
let should_float_if_hidden = false;
- focus_terminal_pane(1, should_float_if_hidden);
+ let should_be_in_place_if_hidden = false;
+ focus_terminal_pane(1, should_float_if_hidden, should_be_in_place_if_hidden);
},
BareKey::Char('u') if key.has_modifiers(&[KeyModifier::Ctrl]) => {
let should_float_if_hidden = false;
- focus_plugin_pane(1, should_float_if_hidden);
+ let should_be_in_place_if_hidden = false;
+ focus_plugin_pane(1, should_float_if_hidden, should_be_in_place_if_hidden);
},
BareKey::Char('v') if key.has_modifiers(&[KeyModifier::Ctrl]) => {
rename_terminal_pane(1, "new terminal_pane_name");
@@ -354,7 +359,7 @@ impl ZellijPlugin for State {
hide_pane_with_id(PaneId::Terminal(1));
},
BareKey::Char('b') if key.has_modifiers(&[KeyModifier::Alt]) => {
- show_pane_with_id(PaneId::Terminal(1), true);
+ show_pane_with_id(PaneId::Terminal(1), true, true);
},
BareKey::Char('c') if key.has_modifiers(&[KeyModifier::Alt]) => {
open_command_pane_background(
@@ -549,6 +554,37 @@ impl ZellijPlugin for State {
BareKey::Char('z') if key.has_modifiers(&[KeyModifier::Alt]) => {
list_clients();
},
+ BareKey::Char('a') if key.has_modifiers(&[KeyModifier::Super]) => {
+ // Test show_cursor with coordinates
+ show_cursor(Some((5, 10)));
+ },
+ BareKey::Char('b') if key.has_modifiers(&[KeyModifier::Super]) => {
+ // Test hide_cursor
+ show_cursor(None);
+ },
+ BareKey::Char('c') if key.has_modifiers(&[KeyModifier::Super]) => {
+ // Test copy_to_clipboard
+ copy_to_clipboard("test clipboard text");
+ },
+ BareKey::Char('d') if key.has_modifiers(&[KeyModifier::Super]) => {
+ // Test run_action with MoveFocus
+ let mut context = BTreeMap::new();
+ context.insert("test_key".to_string(), "test_value".to_string());
+ run_action(
+ Action::MoveFocus {
+ direction: Direction::Left,
+ },
+ context,
+ );
+ },
+ BareKey::Char('e') if key.has_modifiers(&[KeyModifier::Super]) => {
+ // Test send_sigint_to_pane_id
+ send_sigint_to_pane_id(PaneId::Terminal(1));
+ },
+ BareKey::Char('f') if key.has_modifiers(&[KeyModifier::Super]) => {
+ // Test send_sigkill_to_pane_id
+ send_sigkill_to_pane_id(PaneId::Terminal(1));
+ },
_ => {},
},
Event::CustomMessage(message, payload) => {
diff --git a/default-plugins/plugin-manager/src/main.rs b/default-plugins/plugin-manager/src/main.rs
index 8e1c91a8..5cd243f5 100644
--- a/default-plugins/plugin-manager/src/main.rs
+++ b/default-plugins/plugin-manager/src/main.rs
@@ -910,7 +910,7 @@ impl State {
}
pub fn focus_selected(&self) {
if let Some(selected_plugin_id) = self.selected_plugin_id() {
- focus_pane_with_id(PaneId::Plugin(selected_plugin_id), true);
+ focus_pane_with_id(PaneId::Plugin(selected_plugin_id), true, false);
}
}
pub fn reload_selected(&self) {
diff --git a/default-plugins/sequence/.cargo/config.toml b/default-plugins/sequence/.cargo/config.toml
new file mode 100644
index 00000000..6b509f5b
--- /dev/null
+++ b/default-plugins/sequence/.cargo/config.toml
@@ -0,0 +1,2 @@
+[build]
+target = "wasm32-wasip1"
diff --git a/default-plugins/sequence/.gitignore b/default-plugins/sequence/.gitignore
new file mode 100644
index 00000000..ea8c4bf7
--- /dev/null
+++ b/default-plugins/sequence/.gitignore
@@ -0,0 +1 @@
+/target
diff --git a/default-plugins/sequence/Cargo.toml b/default-plugins/sequence/Cargo.toml
new file mode 100644
index 00000000..4f97ae6c
--- /dev/null
+++ b/default-plugins/sequence/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "sequence"
+version = "0.1.0"
+authors = ["Aram Drevekenin <aram@poor.dev>"]
+edition = "2018"
+
+[dependencies]
+zellij-tile = { path = "../../zellij-tile" }
+unicode-width = "0.1"
+fuzzy-matcher = "0.3"
+# TODO: get this from Zellij so that we don't have to match versions
+strum = "0.27"
+regex = "1"
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
+uuid = { version = "1.0", features = ["v4"] }
diff --git a/default-plugins/sequence/LICENSE.md b/default-plugins/sequence/LICENSE.md
new file mode 120000
index 00000000..f0608a63
--- /dev/null
+++ b/default-plugins/sequence/LICENSE.md
@@ -0,0 +1 @@
+../../LICENSE.md \ No newline at end of file
diff --git a/default-plugins/sequence/src/lib.rs b/default-plugins/sequence/src/lib.rs
new file mode 100644
index 00000000..1e124979
--- /dev/null
+++ b/default-plugins/sequence/src/lib.rs
@@ -0,0 +1,5 @@
+pub mod path_formatting;
+pub mod state;
+pub mod ui;
+
+pub use state::State;
diff --git a/default-plugins/sequence/src/main.rs b/default-plugins/sequence/src/main.rs
new file mode 100644
index 00000000..0ac286d9
--- /dev/null
+++ b/default-plugins/sequence/src/main.rs
@@ -0,0 +1,1131 @@
+mod path_formatting;
+mod state;
+mod ui;
+
+use crate::state::CommandStatus;
+use crate::ui::components;
+use crate::ui::fuzzy_complete;
+use crate::ui::layout_calculations::calculate_viewport;
+use crate::ui::text_input::InputAction;
+use crate::ui::truncation::truncate_middle;
+use state::State;
+use zellij_tile::prelude::actions::Action;
+use zellij_tile::prelude::*;
+
+use std::collections::BTreeMap;
+use std::path::PathBuf;
+
+use unicode_width::UnicodeWidthStr;
+
+register_plugin!(State);
+
+impl ZellijPlugin for State {
+ fn load(&mut self, _configuration: BTreeMap<String, String>) {
+ subscribe(&[
+ EventType::ModeUpdate,
+ EventType::SessionUpdate,
+ EventType::Key,
+ EventType::PermissionRequestResult,
+ EventType::HostFolderChanged,
+ EventType::RunCommandResult,
+ EventType::ActionComplete,
+ EventType::PaneUpdate,
+ EventType::Timer,
+ EventType::PastedText,
+ EventType::TabUpdate,
+ EventType::CommandPaneOpened,
+ ]);
+
+ // Store our own plugin ID and client ID
+ let plugin_ids = get_plugin_ids();
+ self.plugin_id = Some(plugin_ids.plugin_id);
+ self.client_id = Some(plugin_ids.client_id);
+ self.cwd = Some(plugin_ids.initial_cwd);
+ update_title(self);
+ }
+
+ fn update(&mut self, event: Event) -> bool {
+ handle_event(self, event)
+ }
+
+ fn render(&mut self, rows: usize, cols: usize) {
+ // Store dimensions for use in cursor calculations
+ self.own_rows = Some(rows);
+ self.own_columns = Some(cols);
+
+ let max_visible_rows = rows.saturating_sub(5);
+
+ let base_x = 1;
+ let base_y = 0;
+
+ let (offset, visible_count, hidden_above, hidden_below) = calculate_viewport(
+ self.execution.all_commands.len(),
+ max_visible_rows,
+ self.selection.current_selected_command_index,
+ self.execution.current_running_command_index,
+ );
+
+ let mut table = components::build_table_header(hidden_above > 0 || hidden_below > 0);
+
+ for index in offset..offset + visible_count {
+ table = components::add_command_row(
+ table,
+ self,
+ index,
+ offset,
+ visible_count,
+ hidden_above,
+ hidden_below,
+ );
+ }
+
+ print_table_with_coordinates(
+ table,
+ base_x,
+ base_y,
+ self.own_columns.map(|o| o.saturating_sub(base_x)),
+ None,
+ );
+
+ let help_y = base_y + visible_count + 2;
+ let (first_help, _, second_help) = components::render_help_lines(self, Some(cols));
+ print_text_with_coordinates(first_help, base_x, help_y, None, None);
+
+ if let Some((second_help_text, _)) = second_help {
+ print_text_with_coordinates(second_help_text, base_x, help_y + 1, None, None);
+ }
+ }
+}
+
+pub fn handle_event(state: &mut State, event: Event) -> bool {
+ use std::path::PathBuf;
+
+ match event {
+ Event::PermissionRequestResult(_) => {
+ change_host_folder(PathBuf::from("/"));
+ update_title(state);
+ true
+ },
+ Event::ModeUpdate(mode_info) => {
+ state.shell = mode_info.shell.clone();
+
+ false
+ },
+ Event::Key(key) => {
+ let mut should_render = handle_key_event(state, key);
+ let repositioned = state.reposition_plugin();
+ if repositioned {
+ // we only want to render once we have repositioned, we will do this in TabUpdate
+ should_render = false;
+ }
+ update_cursor(state);
+ should_render
+ },
+ Event::SessionUpdate(session_infos, _resurrectable_sessions) => {
+ if state.is_first_run {
+ // Find the current session
+ let current_session = session_infos.iter().find(|s| s.is_current_session);
+
+ if let Some(session) = current_session {
+ // Get the pane history for this client
+ if let Some(client_id) = state.client_id {
+ if let Some(pane_history) = session.pane_history.get(&client_id) {
+ let own_pane_id = state.plugin_id.map(|id| PaneId::Plugin(id));
+ state.primary_pane_id = select_primary_pane_from_history(
+ pane_history,
+ &session.panes,
+ own_pane_id,
+ );
+ state.original_pane_id = state.primary_pane_id; // pane id focused
+ // before plugin
+ // launched
+ state.is_first_run = false;
+ }
+ }
+ }
+ }
+ false
+ },
+ Event::Timer(_elapsed) => {
+ // Timer events are used for lock backoff
+
+ let should_render = update_spinner(state);
+
+ should_render
+ },
+ Event::PastedText(pasted_text) => {
+ // Split pasted text into lines
+ let mut should_render = true;
+ let lines: Vec<&str> = pasted_text
+ .lines()
+ .map(|line| line.trim())
+ .filter(|line| !line.is_empty())
+ .collect();
+
+ if lines.is_empty() {
+ return false;
+ }
+ state.pasted_lines(lines);
+ let repositioned = state.reposition_plugin();
+ if repositioned {
+ // we only want to render once we have repositioned, we will do this in TabUpdate
+ should_render = false;
+ }
+ update_cursor(state);
+
+ should_render
+ },
+ Event::TabUpdate(tab_infos) => {
+ if let Some(tab_info) = tab_infos.iter().find(|t| t.active) {
+ let new_cols = Some(tab_info.viewport_columns);
+ let new_rows = Some(tab_info.viewport_rows);
+
+ // Check if dimensions changed
+ let dimensions_changed = new_cols != state.total_viewport_columns
+ || new_rows != state.total_viewport_rows;
+
+ state.total_viewport_columns = new_cols;
+ state.total_viewport_rows = new_rows;
+
+ if dimensions_changed {
+ state.reposition_plugin();
+ }
+ update_cursor(state);
+ }
+
+ false
+ },
+ Event::PaneUpdate(pane_manifest) => handle_pane_update(state, pane_manifest),
+ Event::ActionComplete(_action, pane_id, context) => {
+ let should_render = handle_action_complete(state, pane_id, context);
+ update_title(state);
+ should_render
+ },
+ Event::CommandPaneOpened(terminal_pane_id, context) => {
+ // we get this event immediately as the pane opens, we use it to associate the
+ // pane's id with our state
+
+ if !is_running_sequence(&context, state.execution.sequence_id) {
+ // action from previous sequence or unrelated
+ return false;
+ }
+ if let Some(command_text) = context.get("command_text") {
+ state.update_pane_id_for_command(PaneId::Terminal(terminal_pane_id), command_text);
+ if !state.layout.spinner_timer_scheduled {
+ set_timeout(0.1);
+ state.layout.spinner_timer_scheduled = true;
+ }
+ update_title(state);
+ }
+
+ true
+ },
+ _ => false,
+ }
+}
+
+fn handle_key_event(state: &mut State, key: KeyWithModifier) -> bool {
+ // Ctrl+Enter - Insert command after current with AND chain type
+ if key.has_modifiers(&[KeyModifier::Ctrl]) && matches!(key.bare_key, BareKey::Enter) {
+ let mut is_cd = false;
+ if let Some(current_text) = state.editing_input_text() {
+ if let Some(path) = state::detect_cd_command(&current_text) {
+ if let Some(new_cwd) = path_formatting::resolve_path(state.cwd.as_ref(), &path) {
+ state.current_selected_command_mut().map(|c| {
+ c.set_cwd(Some(new_cwd));
+ c.set_text("".to_owned());
+ });
+ state.editing.editing_input.as_mut().map(|i| i.clear());
+ is_cd = true
+ }
+ }
+ };
+
+ state.add_empty_command_after_current_selected();
+ if is_cd {
+ state.start_editing_selected();
+ }
+ return true;
+ }
+
+ // Ctrl+X - Cycle chain type
+ if key.has_modifiers(&[KeyModifier::Ctrl]) && matches!(key.bare_key, BareKey::Char('x')) {
+ // return state.ui.command_sequence.cycle_current_chain_type();
+ state.cycle_chain_type();
+ return true;
+ }
+
+ // Ctrl+Space, copy to clipboard
+ if key.has_modifiers(&[KeyModifier::Ctrl]) && matches!(key.bare_key, BareKey::Char(' ')) {
+ state.copy_to_clipboard();
+ return false;
+ }
+
+ // Ctrl+w, close all panes and clear sequence
+ if key.has_modifiers(&[KeyModifier::Ctrl]) && matches!(key.bare_key, BareKey::Char('w')) {
+ if !state.execution.is_running && !state.all_commands_are_pending() {
+ close_panes_and_return_to_shell(state);
+ }
+ return true;
+ }
+
+ if matches!(key.bare_key, BareKey::Up)
+ && !key.has_modifiers(&[KeyModifier::Ctrl])
+ && !key.has_modifiers(&[KeyModifier::Alt])
+ {
+ state.move_selection_up();
+ return true;
+ }
+
+ if matches!(key.bare_key, BareKey::Down)
+ && !key.has_modifiers(&[KeyModifier::Ctrl])
+ && !key.has_modifiers(&[KeyModifier::Alt])
+ {
+ state.move_selection_down();
+ return true;
+ }
+
+ // Del - delete current command
+ if matches!(key.bare_key, BareKey::Delete)
+ && !key.has_modifiers(&[KeyModifier::Ctrl])
+ && !key.has_modifiers(&[KeyModifier::Alt])
+ {
+ state.remove_current_selected_command();
+ return true;
+ }
+
+ // Ctrl+C - Clear currently focused command, or remove it if already empty
+ if key.has_modifiers(&[KeyModifier::Ctrl]) && matches!(key.bare_key, BareKey::Char('c')) {
+ if state.execution.is_running {
+ interrupt_sequence(state);
+ return true;
+ } else if state.editing.editing_input.is_some() {
+ let is_empty = state.current_selected_command_is_empty();
+ let has_more_than_one_command = state.execution.all_commands.len() > 1;
+
+ if is_empty && has_more_than_one_command {
+ state.remove_current_selected_command();
+ } else if is_empty {
+ // last command, return to shell
+ close_panes_and_return_to_shell(state);
+ } else {
+ // If not empty, clear it
+ state.clear_current_selected_command();
+ }
+ return true;
+ }
+ }
+
+ if state.editing.editing_input.is_none()
+ && key.has_no_modifiers()
+ && matches!(key.bare_key, BareKey::Char('e'))
+ {
+ state.start_editing_selected();
+ return true;
+ }
+
+ if state.editing.editing_input.is_none()
+ && key.has_no_modifiers()
+ && matches!(key.bare_key, BareKey::Enter)
+ {
+ rerun_sequence(state);
+ return true;
+ }
+
+ // Handle input actions from TextInput
+ let is_backspace = matches!(key.bare_key, BareKey::Backspace);
+
+ if let Some(action) = state
+ .editing
+ .editing_input
+ .as_mut()
+ .map(|i| i.handle_key(key))
+ {
+ match action {
+ InputAction::Submit => {
+ return handle_submit(state);
+ },
+ InputAction::Cancel => {
+ state.cancel_editing_selected();
+ if state.all_commands_are_pending() {
+ // we always want to stay in editing mode in main screen
+ state.start_editing_selected();
+ }
+ true
+ },
+ InputAction::Complete => {
+ state
+ .editing_input_text()
+ .map(|current_text| {
+ if let Some(completed) = fuzzy_complete::fuzzy_complete(
+ &current_text,
+ &Default::default(), // TODO: get rid of this whoel thing?
+ state.cwd.as_ref(),
+ ) {
+ state.set_editing_input_text(completed);
+ true
+ } else {
+ false
+ }
+ })
+ .unwrap_or(false)
+ },
+ InputAction::Continue => {
+ if let Some(current_text) = state.editing_input_text() {
+ if let Some((cmd_text, chain_type)) =
+ state::detect_chain_operator_at_end(&current_text)
+ {
+ let mut should_add_empty_line = true;
+ if let Some(path) = state::detect_cd_command(&cmd_text) {
+ if let Some(new_cwd) =
+ path_formatting::resolve_path(state.cwd.as_ref(), &path)
+ {
+ let remaining_text =
+ state::get_remaining_after_first_segment(&current_text)
+ .unwrap_or_else(|| "".to_owned());
+ if remaining_text.len() > 0 {
+ should_add_empty_line = false;
+ }
+ state.current_selected_command_mut().map(|c| {
+ c.set_cwd(Some(new_cwd));
+ c.set_text(remaining_text);
+ });
+ }
+ } else {
+ state.current_selected_command_mut().map(|c| {
+ c.set_text(cmd_text);
+ c.set_chain_type(chain_type);
+ });
+ };
+ state.cancel_editing_selected();
+ if should_add_empty_line {
+ state.add_empty_command_after_current_selected();
+ }
+ state.start_editing_selected();
+ }
+ }
+
+ // Handle backspace on empty command
+ if is_backspace {
+ let is_empty = state.current_selected_command_is_empty()
+ || state
+ .editing_input_text()
+ .map(|i| i.is_empty())
+ .unwrap_or(false);
+ let has_more_than_one_command = state.execution.all_commands.len() > 1;
+
+ if is_empty && has_more_than_one_command {
+ let current_index =
+ state.selection.current_selected_command_index.unwrap_or(0);
+ if current_index > 0 {
+ state.remove_current_selected_command();
+ return true;
+ }
+ } else if is_empty {
+ // last command
+ close_panes_and_return_to_shell(state);
+ }
+ }
+
+ true
+ },
+ InputAction::NoAction => false,
+ }
+ } else {
+ false
+ }
+}
+
+/// Handle Enter key - submit and execute the command sequence
+fn handle_submit(state: &mut State) -> bool {
+ let cwd = state.cwd.clone();
+ if state.handle_editing_submit(&cwd) {
+ // handled the command internally (eg. cd) no need to run sequence
+ return true;
+ }
+
+ if state.execution.all_commands.len() == 1 && state.can_run_sequence() {
+ rerun_sequence(state);
+ } else if state.execution.all_commands.len() == 1 {
+ state.move_selection_down();
+ state.start_editing_selected();
+ }
+ true
+}
+
+pub fn handle_pane_update(state: &mut State, pane_manifest: PaneManifest) -> bool {
+ // Store the manifest for later use
+ state.pane_manifest = Some(pane_manifest.clone());
+ let mut needs_rerender = false;
+ if state.update_exited_command_statuses(&pane_manifest) {
+ needs_rerender = true;
+ }
+ if state.update_sequence_stopped_state() {
+ needs_rerender = true;
+ }
+ state.reposition_plugin();
+ update_primary_and_original_pane_ids(state, &pane_manifest);
+ needs_rerender
+}
+
+pub fn calculate_cursor_position(state: &mut State) -> Option<(usize, usize)> {
+ // Get pane dimensions from state (must be set by render)
+ let Some(cols) = state.own_columns else {
+ eprintln!("Warning: own_columns not set");
+ return None;
+ };
+ let Some(rows) = state.own_rows else {
+ eprintln!("Warning: own_rows not set");
+ return None;
+ };
+
+ // Only show cursor if in edit mode
+ let (text, cursor_pos) = if let Some(text_input) = &state.editing.editing_input {
+ (
+ text_input.get_text().to_string(),
+ text_input.cursor_position(),
+ )
+ } else {
+ return None;
+ };
+
+ let Some(edit_index) = state.selection.current_selected_command_index else {
+ return None;
+ };
+
+ // Calculate folder column width using the longest cwd across all commands
+ let longest_cwd_display = state.execution.longest_cwd_display(&state.cwd);
+ let folder_display = format!("{} >", longest_cwd_display);
+ let folder_col_width = folder_display.width().max(1);
+
+ let base_x = 1;
+ let base_y = 0;
+
+ adjust_scroll_offset(state, rows);
+
+ let max_visible_rows = state.own_rows.map(|r| r.saturating_sub(5)).unwrap_or(0);
+
+ let (offset, visible_count, hidden_above, hidden_below) = calculate_viewport(
+ state.execution.all_commands.len(),
+ max_visible_rows,
+ state.selection.current_selected_command_index,
+ state.execution.current_running_command_index,
+ );
+
+ let (max_chain_width, max_status_width) =
+ components::calculate_max_widths(&state.execution.all_commands, state.layout.spinner_frame);
+
+ let Some((_, available_cmd_width)) = components::calculate_row_layout_info(
+ edit_index,
+ offset,
+ visible_count,
+ hidden_above,
+ hidden_below,
+ cols,
+ folder_col_width,
+ max_chain_width,
+ max_status_width,
+ ) else {
+ return None;
+ };
+
+ // Get cursor position in truncated text
+ let (_, cursor_in_truncated) = truncate_middle(&text, available_cmd_width, Some(cursor_pos));
+ let Some(cursor_in_truncated) = cursor_in_truncated else {
+ return None;
+ };
+
+ // Calculate relative coordinates within the visible table
+ let relative_y = edit_index.saturating_sub(offset) + 1; // +1 for header row
+ let relative_x = folder_col_width + 1 + cursor_in_truncated;
+
+ let x = base_x + relative_x;
+ let y = base_y + relative_y;
+
+ Some((x, y))
+}
+
+pub fn handle_action_complete(
+ state: &mut State,
+ pane_id: Option<PaneId>,
+ context: BTreeMap<String, String>,
+) -> bool {
+ // Update primary_pane_id to the pane that just completed the action
+ // This ensures we always have a valid pane to target for InPlace launches
+ // But only if it's not a floating pane
+ if let (Some(pane_id), Some(manifest)) = (pane_id, &state.pane_manifest) {
+ if !is_pane_floating(pane_id, manifest) {
+ state.primary_pane_id = Some(pane_id);
+ } else {
+ eprintln!("Not setting primary_pane_id to floating pane {:?}", pane_id);
+ }
+ }
+
+ if !is_running_sequence(&context, state.execution.sequence_id) {
+ // action from previous sequence or unrelated
+ return false;
+ }
+
+ // If the sequence has been stopped (e.g., via Ctrl+C), don't continue
+ if !state.execution.is_running {
+ return true; // Re-render to show the stopped state
+ }
+
+ // Move to the next command
+ let next_index = state.execution.current_running_command_index + 1;
+
+ if next_index < state.execution.all_commands.len() {
+ // Execute the next command
+ // let (next_command, next_chain_type) = &state.execution.all_commands[next_index];
+ let Some(next_command) = state.execution.all_commands.get(next_index) else {
+ // invalid state
+ return true;
+ };
+ let next_chain_type = next_command.get_chain_type();
+ let next_command_cwd = next_command.get_cwd();
+ let next_command_text = next_command.get_text();
+
+ let shell = state
+ .shell
+ .clone()
+ .unwrap_or_else(|| PathBuf::from("/bin/bash"));
+
+ let command = zellij_tile::prelude::actions::RunCommandAction {
+ command: shell,
+ args: vec!["-ic".to_string(), next_command_text.trim().to_string()],
+ cwd: next_command_cwd,
+ hold_on_close: true,
+ ..Default::default()
+ };
+
+ // Determine placement based on layout mode
+ let placement = NewPanePlacement::Stacked(pane_id);
+
+ // Update status: mark current command as completed
+ // Use the pane_id from ActionComplete if the status is still Pending
+ let pane_id_to_mark = state
+ .execution
+ .all_commands
+ .get(state.execution.current_running_command_index)
+ .and_then(|c| match c.get_status() {
+ CommandStatus::Running(pid) => pid,
+ CommandStatus::Pending => pane_id, // Use the pane from ActionComplete
+ CommandStatus::Exited(_, pid) => pid, // Already marked, keep it
+ CommandStatus::Interrupted(pid) => pid, // Already marked, keep it
+ });
+
+ state
+ .execution
+ .all_commands
+ .get_mut(state.execution.current_running_command_index)
+ .map(|c| c.set_status(CommandStatus::Exited(None, pane_id_to_mark)));
+ state.execution.current_running_command_index = next_index;
+
+ // Determine unblock_condition based on whether this is the last command
+ let unblock_condition = if next_index < state.execution.all_commands.len() - 1 {
+ // Not the last command - use the chain type's unblock condition
+ next_chain_type.to_unblock_condition()
+ } else {
+ // Last command - use UnblockCondition::OnAnyExit
+ Some(UnblockCondition::OnAnyExit)
+ };
+
+ let action = Action::NewBlockingPane {
+ placement,
+ command: Some(command),
+ pane_name: Some(next_command_text.trim().to_string()),
+ unblock_condition,
+ near_current_pane: true,
+ };
+
+ // Pass the sequence ID in the context
+ let mut context = BTreeMap::new();
+ context.insert(
+ "sequence_id".to_string(),
+ state.execution.sequence_id.to_string(),
+ );
+ context.insert("command_text".to_string(), next_command_text.to_string());
+
+ // Put the sequence back with updated index
+ run_action(action, context);
+ } else {
+ // Sequence complete - mark the last command as Exited
+
+ let pane_id_to_mark = state
+ .execution
+ .all_commands
+ .get(state.execution.current_running_command_index)
+ .and_then(|c| match c.get_status() {
+ CommandStatus::Running(pid) => pid,
+ CommandStatus::Pending => pane_id, // Use the pane from ActionComplete
+ CommandStatus::Exited(_, pid) => pid, // Already marked, keep it
+ CommandStatus::Interrupted(pid) => pid, // Already marked, keep it
+ });
+
+ state
+ .execution
+ .all_commands
+ .get_mut(state.execution.current_running_command_index)
+ .map(|c| c.set_status(CommandStatus::Exited(None, pane_id_to_mark)));
+ }
+ true
+}
+
+/// Adjust scroll offset to keep selected or running command visible
+pub fn adjust_scroll_offset(sequence: &mut crate::state::State, rows: usize) {
+ let max_visible = rows.saturating_sub(6);
+ let total_commands = sequence.execution.all_commands.len();
+
+ if total_commands <= max_visible {
+ sequence.selection.scroll_offset = 0;
+ return;
+ }
+
+ let focus_index = sequence
+ .selection
+ .current_selected_command_index
+ .unwrap_or(sequence.execution.current_running_command_index);
+ let half_visible = max_visible / 2;
+
+ if focus_index < half_visible {
+ sequence.selection.scroll_offset = 0;
+ } else if focus_index >= total_commands.saturating_sub(half_visible) {
+ sequence.selection.scroll_offset = total_commands.saturating_sub(max_visible);
+ } else {
+ sequence.selection.scroll_offset = focus_index.saturating_sub(half_visible);
+ }
+
+ sequence.selection.scroll_offset = sequence
+ .selection
+ .scroll_offset
+ .min(total_commands.saturating_sub(max_visible));
+}
+
+pub fn is_pane_floating(pane_id: PaneId, pane_manifest: &PaneManifest) -> bool {
+ pane_manifest
+ .panes
+ .iter()
+ .flat_map(|(_, panes)| panes.iter())
+ .find(|pane_info| match pane_id {
+ PaneId::Terminal(id) => !pane_info.is_plugin && pane_info.id == id,
+ PaneId::Plugin(id) => pane_info.is_plugin && pane_info.id == id,
+ })
+ .map(|pane_info| pane_info.is_floating)
+ .unwrap_or(false)
+}
+
+fn is_running_sequence(context: &BTreeMap<String, String>, running_sequence_id: u64) -> bool {
+ if let Some(context_sequence_id) = context.get("sequence_id") {
+ if let Ok(context_id) = context_sequence_id.parse::<u64>() {
+ if context_id != running_sequence_id {
+ // This action is from a different sequence, ignore it
+ return false;
+ } else {
+ return true;
+ }
+ } else {
+ // Failed to parse sequence_id, ignore this action
+ return false;
+ }
+ } else {
+ return false;
+ }
+}
+
+fn update_primary_and_original_pane_ids(state: &mut State, pane_manifest: &PaneManifest) {
+ if let Some(primary_pane_id) = state.primary_pane_id {
+ let mut primary_pane_found = false;
+
+ // Check if the primary pane still exists
+ for (_tab_index, panes) in &pane_manifest.panes {
+ for pane_info in panes {
+ let pane_matches = match primary_pane_id {
+ PaneId::Terminal(id) => !pane_info.is_plugin && pane_info.id == id,
+ PaneId::Plugin(id) => pane_info.is_plugin && pane_info.id == id,
+ };
+
+ if pane_matches {
+ primary_pane_found = true;
+ break;
+ }
+ }
+ if primary_pane_found {
+ break;
+ }
+ }
+
+ // If primary pane was not found, select a new one
+ if !primary_pane_found {
+ state.primary_pane_id = None;
+ }
+ }
+ if let Some(original_pane_id) = state.original_pane_id {
+ let mut original_pane_found = false;
+
+ // Check if the original pane still exists
+ for (_tab_index, panes) in &pane_manifest.panes {
+ for pane_info in panes {
+ let pane_matches = match original_pane_id {
+ PaneId::Terminal(id) => !pane_info.is_plugin && pane_info.id == id,
+ PaneId::Plugin(id) => pane_info.is_plugin && pane_info.id == id,
+ };
+
+ if pane_matches {
+ original_pane_found = true;
+ break;
+ }
+ }
+ if original_pane_found {
+ break;
+ }
+ }
+
+ // If original pane was not found, select a new one
+ if !original_pane_found {
+ state.original_pane_id = None;
+ }
+ }
+}
+
+pub fn select_primary_pane_from_history(
+ pane_history: &[PaneId],
+ pane_manifest: &PaneManifest,
+ own_pane_id: Option<PaneId>,
+) -> Option<PaneId> {
+ pane_history
+ .iter()
+ .rev() // Most recent first
+ .find(|&id| {
+ // Skip self
+ if Some(*id) == own_pane_id {
+ return false;
+ }
+
+ // Skip floating panes
+ !is_pane_floating(*id, pane_manifest)
+ })
+ .copied()
+}
+
+fn close_panes_and_return_to_shell(state: &mut State) -> bool {
+ // Close all panes in the sequence before clearing it
+ // Handle the first pane: replace it with the primary_pane_id_before_sequence if available
+ if let Some(first_pane_id) = state.execution.all_commands.iter().find_map(|command| {
+ // we look for the first command in the sequence that has a pane that's
+ // actually open
+ match command.get_status() {
+ CommandStatus::Running(pane_id) => pane_id,
+ CommandStatus::Exited(_, pane_id) => pane_id,
+ CommandStatus::Interrupted(pane_id) => pane_id,
+ _ => None,
+ }
+ }) {
+ if let Some(original_pane_id) = state.original_pane_id {
+ replace_pane_with_existing_pane(
+ first_pane_id,
+ original_pane_id,
+ true, // suppress_replaced_pane (closes the first pane)
+ );
+ } else {
+ // Fallback: if we don't have a saved primary pane, just close it
+ close_pane_with_id(first_pane_id);
+ }
+ }
+
+ // Close all other panes (starting from index 1)
+ for (idx, command) in state.execution.all_commands.iter().skip(1).enumerate() {
+ let pane_id = match command.get_status() {
+ CommandStatus::Running(pane_id) => pane_id,
+ CommandStatus::Exited(_, pane_id) => pane_id,
+ CommandStatus::Interrupted(pane_id) => pane_id,
+ _ => None,
+ };
+
+ if let Some(pane_id) = pane_id {
+ close_pane_with_id(pane_id);
+ } else {
+ eprintln!(
+ "Warning: Cannot close pane at index {}: pane_id is None",
+ idx + 1
+ );
+ }
+ }
+
+ state.clear_all_commands();
+ // Transition back to shell screen
+ if let Some(plugin_id) = state.plugin_id {
+ change_floating_pane_coordinates(
+ plugin_id,
+ Some(25),
+ Some(25),
+ Some(50),
+ Some(50),
+ false, // pinned (this should unpin it)
+ );
+ }
+ update_title(state);
+ true
+}
+
+fn change_floating_pane_coordinates(
+ own_plugin_id: u32,
+ x: Option<usize>,
+ y: Option<usize>,
+ width: Option<usize>,
+ height: Option<usize>,
+ should_be_pinned: bool,
+) {
+ let coordinates = FloatingPaneCoordinates::new(
+ x.map(|x| format!("{}%", x)),
+ y.map(|y| format!("{}%", y)),
+ width.map(|width| format!("{}%", width)),
+ height.map(|height| format!("{}%", height)),
+ Some(should_be_pinned),
+ );
+ if let Some(coordinates) = coordinates {
+ // TODO: better
+ // show_self(true);
+ change_floating_panes_coordinates(vec![(PaneId::Plugin(own_plugin_id), coordinates)]);
+ }
+}
+
+fn update_spinner(state: &mut State) -> bool {
+ // Advance spinner frame for RUNNING animation
+ state.layout.spinner_frame = (state.layout.spinner_frame + 1) % 8;
+
+ // Reset timer scheduled flag since this timer just fired
+ state.layout.spinner_timer_scheduled = false;
+
+ // If we have a running command, schedule next timer event
+ let has_running = state
+ .execution
+ .all_commands
+ .iter()
+ .any(|command| matches!(command.get_status(), CommandStatus::Running(_)));
+ if has_running && !state.layout.spinner_timer_scheduled {
+ set_timeout(0.1);
+ state.layout.spinner_timer_scheduled = true;
+ }
+ has_running
+}
+
+fn rerun_sequence(state: &mut State) {
+ if !state.can_run_sequence() {
+ return;
+ }
+
+ state.execution.all_commands.retain(|c| !c.is_empty());
+
+ if state.execution.all_commands.is_empty() {
+ return;
+ }
+
+ let selected_index = state
+ .selection
+ .current_selected_command_index
+ .unwrap_or(0)
+ .min(state.execution.all_commands.len().saturating_sub(1));
+
+ let mut close_replaced_pane = true;
+
+ // Extract the selected pane's ID BEFORE resetting statuses (needed for in-place replacement)
+ let selected_pane_id_for_replacement = &state
+ .execution
+ .all_commands
+ .get(selected_index)
+ .and_then(|c| match c.get_status() {
+ CommandStatus::Running(pane_id) => pane_id,
+ CommandStatus::Exited(_, pane_id) => pane_id,
+ CommandStatus::Interrupted(pane_id) => pane_id,
+ _ => None,
+ })
+ .or_else(|| {
+ if state.all_commands_are_pending() {
+ close_replaced_pane = false; // we should never close the original pane_id
+ state.original_pane_id
+ } else {
+ None
+ }
+ });
+
+ // Close all panes AFTER the selected one (selected will be replaced in-place)
+ for i in (selected_index + 1)..state.execution.all_commands.len() {
+ // Extract pane_id from the command status
+ let pane_id = state
+ .execution
+ .all_commands
+ .get(i)
+ .and_then(|c| match c.get_status() {
+ CommandStatus::Running(pane_id) => pane_id,
+ CommandStatus::Exited(_, pane_id) => pane_id,
+ CommandStatus::Interrupted(pane_id) => pane_id,
+ _ => None,
+ });
+
+ // Close the pane if we have a pane_id
+ if let Some(pane_id) = pane_id {
+ close_pane_with_id(pane_id);
+ } else {
+ eprintln!(
+ "Warning: Cannot close pane for command at index {}: pane_id is None",
+ i
+ );
+ }
+
+ // Reset the command status to Pending
+ state
+ .execution
+ .all_commands
+ .get_mut(i)
+ .map(|c| c.set_status(CommandStatus::Pending));
+ }
+
+ // Reset the selected pane's status to Pending (it will be replaced in-place)
+ state
+ .execution
+ .all_commands
+ .get_mut(selected_index)
+ .map(|c| c.set_status(CommandStatus::Pending));
+
+ // Set the current command index to the selected command
+ state.execution.current_running_command_index = selected_index;
+
+ // Execute the command at the selected index
+ if let Some((command_text, chain_type, command_cwd)) = state
+ .execution
+ .all_commands
+ .get(selected_index)
+ .map(|c| (c.get_text(), c.get_chain_type(), c.get_cwd()))
+ {
+ let shell = state
+ .shell
+ .clone()
+ .unwrap_or_else(|| PathBuf::from("/bin/bash"));
+
+ let command = zellij_tile::prelude::actions::RunCommandAction {
+ command: shell,
+ args: vec!["-ic".to_string(), command_text.trim().to_string()],
+ cwd: command_cwd,
+ hold_on_close: true,
+ ..Default::default()
+ };
+
+ // Determine placement - always replace the selected pane in-place if we have its ID
+ let placement = if let Some(pane_id) = selected_pane_id_for_replacement {
+ NewPanePlacement::InPlace {
+ pane_id_to_replace: Some(*pane_id),
+ close_replaced_pane,
+ }
+ } else {
+ let pane_id_to_stack_under = state
+ .execution
+ .all_commands
+ .iter()
+ .find_map(|c| c.get_pane_id());
+ NewPanePlacement::Stacked(pane_id_to_stack_under)
+ };
+
+ // Determine unblock_condition based on whether this is the last command
+ let unblock_condition = if selected_index < state.execution.all_commands.len() - 1 {
+ // Not the last command - use the chain type's unblock condition
+ chain_type.to_unblock_condition()
+ } else {
+ // Last command - use UnblockCondition::OnAnyExit
+ Some(UnblockCondition::OnAnyExit)
+ };
+
+ let action = Action::NewBlockingPane {
+ placement,
+ command: Some(command),
+ pane_name: Some(command_text.trim().to_string()),
+ unblock_condition,
+ near_current_pane: true,
+ };
+
+ // Generate a new sequence ID for the restart
+ state.execution.sequence_id += 1;
+
+ // Reset the stopped flag so the sequence can run
+ state.execution.is_running = true;
+ state.layout.needs_reposition = true;
+ state.execution.primary_pane_id_before_sequence = state.primary_pane_id;
+
+ // Pass the sequence ID in the context
+ let mut context = BTreeMap::new();
+ context.insert(
+ "sequence_id".to_string(),
+ state.execution.sequence_id.to_string(),
+ );
+ context.insert("command_text".to_string(), command_text.to_string());
+ run_action(action, context);
+ }
+}
+
+fn interrupt_sequence(state: &mut State) {
+ if state.execution.is_running {
+ // Ctrl-C press when running - interrupt the running command
+ for command in state.execution.all_commands.iter_mut() {
+ if let CommandStatus::Running(pane_id) = command.get_status() {
+ if let Some(pane_id) = pane_id {
+ send_sigkill_to_pane_id(pane_id);
+ }
+ command.set_status(CommandStatus::Interrupted(pane_id));
+ }
+ }
+ state.execution.is_running = false;
+ } else {
+ eprintln!("Cannot interrupt a sequence that is not running.");
+ }
+}
+
+pub fn update_cursor(state: &mut State) {
+ // Calculate new cursor position
+ let new_coords = calculate_cursor_position(state);
+
+ // Only update if the position changed
+ if new_coords != state.layout.cached_cursor_position {
+ match new_coords {
+ Some(coords) => show_cursor(Some(coords)),
+ None => show_cursor(None),
+ }
+ state.layout.cached_cursor_position = new_coords;
+ }
+}
+
+pub fn update_title(state: &mut State) {
+ let Some(plugin_id) = state.plugin_id else {
+ return;
+ };
+
+ let title = if state.all_commands_are_pending() {
+ "Run one or more commands in sequence"
+ } else {
+ let has_running_commands = state
+ .execution
+ .all_commands
+ .iter()
+ .any(|c| matches!(c.get_status(), CommandStatus::Running(..)));
+ let has_interrupted_commands = state
+ .execution
+ .all_commands
+ .iter()
+ .any(|c| matches!(c.get_status(), CommandStatus::Interrupted(..)));
+ let all_commands_complete = state
+ .execution
+ .all_commands
+ .iter()
+ .all(|c| matches!(c.get_status(), CommandStatus::Exited(..)));
+ if has_running_commands {
+ let current = state.execution.current_running_command_index + 1;
+ let total = state.execution.all_commands.len();
+ return rename_plugin_pane(plugin_id, &format!("Running {}/{}", current, total));
+ } else if has_interrupted_commands {
+ "Sequence Interrupted"
+ } else if all_commands_complete {
+ "Sequence Complete"
+ } else {
+ "Stopped"
+ }
+ };
+ rename_plugin_pane(plugin_id, title);
+}
diff --git a/default-plugins/sequence/src/path_formatting.rs b/default-plugins/sequence/src/path_formatting.rs
new file mode 100644
index 00000000..01bf50e6
--- /dev/null
+++ b/default-plugins/sequence/src/path_formatting.rs
@@ -0,0 +1,71 @@
+use std::path::PathBuf;
+
+/// Expand a path string to an absolute PathBuf
+/// Handles ~, ./, .., relative and absolute paths
+pub fn expand_path(path_str: &str, current_cwd: Option<&PathBuf>) -> Option<PathBuf> {
+ let expanded = if path_str.starts_with("~/") || path_str == "~" {
+ let home_dir = std::env::var("HOME").ok()?;
+ if path_str == "~" {
+ PathBuf::from(home_dir)
+ } else {
+ PathBuf::from(home_dir).join(&path_str[2..])
+ }
+ } else if path_str.starts_with('/') {
+ PathBuf::from(path_str)
+ } else {
+ let base = current_cwd?;
+ base.join(path_str)
+ };
+
+ let mut normalized = PathBuf::new();
+ for component in expanded.components() {
+ match component {
+ std::path::Component::ParentDir => {
+ normalized.pop();
+ },
+ std::path::Component::CurDir => {},
+ _ => {
+ normalized.push(component);
+ },
+ }
+ }
+
+ Some(normalized)
+}
+
+/// Format a path for display in the prompt
+/// Example: /home/user/some/long/path -> ~/s/l/path
+pub fn format_cwd(cwd: &PathBuf) -> String {
+ let path_str = cwd.to_string_lossy().to_string();
+
+ let home_dir = std::env::var("HOME").unwrap_or_default();
+ let path_str = if !home_dir.is_empty() && path_str.starts_with(&home_dir) {
+ path_str.replacen(&home_dir, "~", 1)
+ } else {
+ path_str
+ };
+
+ let parts: Vec<&str> = path_str.split('/').collect();
+
+ if parts.len() <= 1 {
+ return path_str;
+ }
+
+ let mut formatted_parts = Vec::new();
+ for (i, part) in parts.iter().enumerate() {
+ if i == parts.len() - 1 {
+ formatted_parts.push(part.to_string());
+ } else if part.is_empty() {
+ formatted_parts.push(String::new());
+ } else {
+ formatted_parts.push(part.chars().next().unwrap_or_default().to_string());
+ }
+ }
+
+ formatted_parts.join("/")
+}
+
+/// Alias for expand_path for backwards compatibility
+pub fn resolve_path(current_cwd: Option<&PathBuf>, path_str: &str) -> Option<PathBuf> {
+ expand_path(path_str, current_cwd)
+}
diff --git a/default-plugins/sequence/src/state/chain_type.rs b/default-plugins/sequence/src/state/chain_type.rs
new file mode 100644
index 00000000..3da3316c
--- /dev/null
+++ b/default-plugins/sequence/src/state/chain_type.rs
@@ -0,0 +1,45 @@
+use serde::{Deserialize, Serialize};
+use zellij_tile::prelude::*;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
+pub enum ChainType {
+ And,
+ Or,
+ Then,
+ None,
+}
+
+impl Default for ChainType {
+ fn default() -> Self {
+ ChainType::None
+ }
+}
+
+impl ChainType {
+ pub fn to_unblock_condition(&self) -> Option<UnblockCondition> {
+ match self {
+ ChainType::And => Some(UnblockCondition::OnExitSuccess),
+ ChainType::Or => Some(UnblockCondition::OnExitFailure),
+ ChainType::Then => Some(UnblockCondition::OnAnyExit),
+ ChainType::None => None,
+ }
+ }
+
+ pub fn as_str(&self) -> &str {
+ match self {
+ ChainType::And => "&&",
+ ChainType::Or => "||",
+ ChainType::Then => ";",
+ ChainType::None => "",
+ }
+ }
+
+ pub fn cycle_next(&mut self) {
+ *self = match self {
+ ChainType::And => ChainType::Or,
+ ChainType::Or => ChainType::Then,
+ ChainType::Then => ChainType::And,
+ ChainType::None => ChainType::And,
+ };
+ }
+}
diff --git a/default-plugins/sequence/src/state/command_entry.rs b/default-plugins/sequence/src/state/command_entry.rs
new file mode 100644
index 00000000..96a0020b
--- /dev/null
+++ b/default-plugins/sequence/src/state/command_entry.rs
@@ -0,0 +1,86 @@
+use super::{ChainType, CommandStatus};
+use std::path::PathBuf;
+use std::time::Instant;
+use zellij_tile::prelude::PaneId;
+
+#[derive(Debug, Clone)]
+pub struct CommandEntry {
+ text: String,
+ cwd: Option<PathBuf>,
+ pub(super) chain_type: ChainType,
+ pub(super) status: CommandStatus,
+ pub(super) start_time: std::time::Instant,
+}
+
+impl Default for CommandEntry {
+ fn default() -> Self {
+ CommandEntry {
+ text: String::default(),
+ cwd: None,
+ chain_type: ChainType::default(),
+ status: CommandStatus::default(),
+ start_time: Instant::now(),
+ }
+ }
+}
+
+impl CommandEntry {
+ pub fn new(text: &str, cwd: Option<PathBuf>) -> Self {
+ CommandEntry {
+ text: text.to_owned(),
+ cwd,
+ ..Default::default()
+ }
+ }
+ pub fn with_and(mut self) -> Self {
+ self.chain_type = ChainType::And;
+ self
+ }
+ pub fn get_text(&self) -> String {
+ self.text.clone()
+ }
+ pub fn set_text(&mut self, text: String) {
+ self.text = text;
+ }
+ pub fn clear_text(&mut self) {
+ self.text.clear();
+ }
+ pub fn get_chain_type(&self) -> ChainType {
+ self.chain_type
+ }
+ pub fn set_chain_type(&mut self, chain_type: ChainType) {
+ self.chain_type = chain_type;
+ }
+ pub fn get_status(&self) -> CommandStatus {
+ self.status.clone()
+ }
+ pub fn set_status(&mut self, status: CommandStatus) {
+ self.status = status;
+ }
+ pub fn get_pane_id(&self) -> Option<PaneId> {
+ self.status.get_pane_id()
+ }
+ pub fn is_empty(&self) -> bool {
+ self.text.trim().is_empty()
+ }
+ pub fn fill_chain_type_if_empty(&mut self) {
+ if let ChainType::None = self.chain_type {
+ self.chain_type = ChainType::And;
+ }
+ }
+ pub fn clear_chain_type(&mut self) {
+ self.chain_type = ChainType::None;
+ }
+ pub fn clear_status(&mut self) {
+ self.status = CommandStatus::Pending;
+ }
+ pub fn cycle_chain_type(&mut self) {
+ self.chain_type.cycle_next();
+ }
+ pub fn get_cwd(&self) -> Option<PathBuf> {
+ self.cwd.clone()
+ }
+ pub fn set_cwd(&mut self, cwd: Option<PathBuf>) {
+ self.cwd = cwd;
+ }
+}
diff --git a/default-plugins/sequence/src/state/command_parser.rs b/default-plugins/sequence/src/state/command_parser.rs
new file mode 100644
index 00000000..7f0898f5
--- /dev/null
+++ b/default-plugins/sequence/src/state/command_parser.rs
@@ -0,0 +1,327 @@
+use super::ChainType;
+
+#[derive(Debug, Clone, Copy, PartialEq)]
+enum ParseState {
+ Normal,
+ InSingleQuote,
+ InDoubleQuote,
+ Escaped(EscapeContext),
+}
+
+#[derive(Debug, Clone, Copy, PartialEq)]
+enum EscapeContext {
+ Normal,
+ DoubleQuote,
+}
+
+pub fn split_by_chain_operators(text: &str) -> Vec<(String, Option<ChainType>)> {
+ let mut segments = Vec::new();
+ let mut current_segment = String::new();
+ let mut state = ParseState::Normal;
+ let chars: Vec<char> = text.chars().collect();
+ let mut i = 0;
+
+ while i < chars.len() {
+ let ch = chars[i];
+
+ match state {
+ ParseState::Normal => {
+ if ch == '\\' {
+ state = ParseState::Escaped(EscapeContext::Normal);
+ current_segment.push(ch);
+ i += 1;
+ } else if ch == '\'' {
+ state = ParseState::InSingleQuote;
+ current_segment.push(ch);
+ i += 1;
+ } else if ch == '"' {
+ state = ParseState::InDoubleQuote;
+ current_segment.push(ch);
+ i += 1;
+ } else if ch == '&' && i + 1 < chars.len() && chars[i + 1] == '&' {
+ let segment_text = current_segment.trim().to_string();
+ if !segment_text.is_empty() {
+ segments.push((segment_text, Some(ChainType::And)));
+ }
+ current_segment.clear();
+ i += 2;
+ } else if ch == '|' && i + 1 < chars.len() && chars[i + 1] == '|' {
+ let segment_text = current_segment.trim().to_string();
+ if !segment_text.is_empty() {
+ segments.push((segment_text, Some(ChainType::Or)));
+ }
+ current_segment.clear();
+ i += 2;
+ } else if ch == ';' {
+ let segment_text = current_segment.trim().to_string();
+ if !segment_text.is_empty() {
+ segments.push((segment_text, Some(ChainType::Then)));
+ }
+ current_segment.clear();
+ i += 1;
+ } else {
+ current_segment.push(ch);
+ i += 1;
+ }
+ },
+ ParseState::InSingleQuote => {
+ current_segment.push(ch);
+ if ch == '\'' {
+ state = ParseState::Normal;
+ }
+ i += 1;
+ },
+ ParseState::InDoubleQuote => {
+ if ch == '\\' {
+ state = ParseState::Escaped(EscapeContext::DoubleQuote);
+ current_segment.push(ch);
+ i += 1;
+ } else if ch == '"' {
+ current_segment.push(ch);
+ state = ParseState::Normal;
+ i += 1;
+ } else {
+ current_segment.push(ch);
+ i += 1;
+ }
+ },
+ ParseState::Escaped(context) => {
+ current_segment.push(ch);
+ state = match context {
+ EscapeContext::Normal => ParseState::Normal,
+ EscapeContext::DoubleQuote => ParseState::InDoubleQuote,
+ };
+ i += 1;
+ },
+ }
+ }
+
+ let final_segment = current_segment.trim().to_string();
+ if !final_segment.is_empty() {
+ segments.push((final_segment, None));
+ }
+
+ segments
+}
+
+pub fn detect_chain_operator_at_end(text: &str) -> Option<(String, ChainType)> {
+ let segments = split_by_chain_operators(text);
+
+ if segments.is_empty() {
+ return None;
+ }
+
+ if segments.len() == 1 {
+ if let Some((text, Some(chain_type))) = segments.first() {
+ return Some((text.clone(), *chain_type));
+ }
+ return None;
+ }
+
+ if let Some((first_text, Some(chain_type))) = segments.first() {
+ return Some((first_text.clone(), *chain_type));
+ }
+
+ None
+}
+
+pub fn get_remaining_after_first_segment(text: &str) -> Option<String> {
+ let segments = split_by_chain_operators(text);
+
+ if segments.len() <= 1 {
+ return None;
+ }
+
+ let mut result = String::new();
+ for (i, (segment_text, chain_type_opt)) in segments.iter().enumerate().skip(1) {
+ if i > 1 {
+ if let Some(chain_type) = chain_type_opt {
+ result.push_str(&format!(" {} ", chain_type.as_str()));
+ }
+ }
+ result.push_str(segment_text);
+ if let Some(chain_type) = chain_type_opt {
+ result.push_str(&format!(" {} ", chain_type.as_str()));
+ }
+ }
+
+ Some(result.trim().to_string())
+}
+
+pub fn detect_cd_command(text: &str) -> Option<String> {
+ let trimmed = text.trim();
+
+ if trimmed == "cd" {
+ return Some("~".to_string());
+ }
+
+ if trimmed.starts_with("cd ") {
+ let path = trimmed[3..].trim();
+ return Some(path.to_string());
+ }
+
+ None
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_single_command_no_operator() {
+ let result = split_by_chain_operators("ls");
+ assert_eq!(result, vec![("ls".to_string(), None)]);
+ }
+
+ #[test]
+ fn test_two_commands_with_and() {
+ let result = split_by_chain_operators("ls && pwd");
+ assert_eq!(
+ result,
+ vec![
+ ("ls".to_string(), Some(ChainType::And)),
+ ("pwd".to_string(), None)
+ ]
+ );
+ }
+
+ #[test]
+ fn test_two_commands_with_or() {
+ let result = split_by_chain_operators("cmd1 || cmd2");
+ assert_eq!(
+ result,
+ vec![
+ ("cmd1".to_string(), Some(ChainType::Or)),
+ ("cmd2".to_string(), None)
+ ]
+ );
+ }
+
+ #[test]
+ fn test_two_commands_with_semicolon() {
+ let result = split_by_chain_operators("echo hi ; ls");
+ assert_eq!(
+ result,
+ vec![
+ ("echo hi".to_string(), Some(ChainType::Then)),
+ ("ls".to_string(), None)
+ ]
+ );
+ }
+
+ #[test]
+ fn test_three_commands_mixed() {
+ let result = split_by_chain_operators("a && b || c");
+ assert_eq!(
+ result,
+ vec![
+ ("a".to_string(), Some(ChainType::And)),
+ ("b".to_string(), Some(ChainType::Or)),
+ ("c".to_string(), None)
+ ]
+ );
+ }
+
+ #[test]
+ fn test_operator_in_single_quotes() {
+ let result = split_by_chain_operators("echo '&&' && ls");
+ assert_eq!(
+ result,
+ vec![
+ ("echo '&&'".to_string(), Some(ChainType::And)),
+ ("ls".to_string(), None)
+ ]
+ );
+ }
+
+ #[test]
+ fn test_operator_in_double_quotes() {
+ let result = split_by_chain_operators("echo \"||\" || pwd");
+ assert_eq!(
+ result,
+ vec![
+ ("echo \"||\"".to_string(), Some(ChainType::Or)),
+ ("pwd".to_string(), None)
+ ]
+ );
+ }
+
+ #[test]
+ fn test_escaped_operator() {
+ let result = split_by_chain_operators("echo \\&& test");
+ assert_eq!(result, vec![("echo \\&& test".to_string(), None)]);
+ }
+
+ #[test]
+ fn test_complex_quoting() {
+ let result = split_by_chain_operators("cmd1 && echo \"a || b\" && cmd2");
+ assert_eq!(
+ result,
+ vec![
+ ("cmd1".to_string(), Some(ChainType::And)),
+ ("echo \"a || b\"".to_string(), Some(ChainType::And)),
+ ("cmd2".to_string(), None)
+ ]
+ );
+ }
+
+ #[test]
+ fn test_empty_segments_filtered() {
+ let result = split_by_chain_operators("cmd1 && && cmd2");
+ assert_eq!(
+ result,
+ vec![
+ ("cmd1".to_string(), Some(ChainType::And)),
+ ("cmd2".to_string(), None)
+ ]
+ );
+ }
+
+ #[test]
+ fn test_detect_operator_just_typed_and() {
+ let result = detect_chain_operator_at_end("ls &&");
+ assert_eq!(result, Some(("ls".to_string(), ChainType::And)));
+ }
+
+ #[test]
+ fn test_detect_operator_just_typed_or() {
+ let result = detect_chain_operator_at_end("cmd ||");
+ assert_eq!(result, Some(("cmd".to_string(), ChainType::Or)));
+ }
+
+ #[test]
+ fn test_detect_operator_just_typed_semicolon() {
+ let result = detect_chain_operator_at_end("echo ;");
+ assert_eq!(result, Some(("echo".to_string(), ChainType::Then)));
+ }
+
+ #[test]
+ fn test_detect_operator_no_operator() {
+ let result = detect_chain_operator_at_end("ls");
+ assert_eq!(result, None);
+ }
+
+ #[test]
+ fn test_detect_operator_in_quotes() {
+ let result = detect_chain_operator_at_end("echo '&&'");
+ assert_eq!(result, None);
+ }
+
+ #[test]
+ fn test_whitespace_trimming() {
+ let result = split_by_chain_operators(" cmd1 && cmd2 ");
+ assert_eq!(
+ result,
+ vec![
+ ("cmd1".to_string(), Some(ChainType::And)),
+ ("cmd2".to_string(), None)
+ ]
+ );
+ }
+
+ #[test]
+ fn test_trailing_operator_with_space() {
+ let result = split_by_chain_operators("ls && ");
+ assert_eq!(result, vec![("ls".to_string(), Some(ChainType::And))]);
+ }
+}
diff --git a/default-plugins/sequence/src/state/command_status.rs b/default-plugins/sequence/src/state/command_status.rs
new file mode 100644
index 00000000..39717e8b
--- /dev/null
+++ b/default-plugins/sequence/src/state/command_status.rs
@@ -0,0 +1,26 @@
+use zellij_tile::prelude::*;
+
+#[derive(Debug, Clone)]
+pub enum CommandStatus {
+ Exited(Option<i32>, Option<PaneId>),
+ Running(Option<PaneId>),
+ Pending,
+ Interrupted(Option<PaneId>),
+}
+
+impl CommandStatus {
+ pub fn get_pane_id(&self) -> Option<PaneId> {
+ match self {
+ CommandStatus::Exited(_, pane_id) => *pane_id,
+ CommandStatus::Running(pane_id) => *pane_id,
+ CommandStatus::Pending => None,
+ CommandStatus::Interrupted(pane_id) => *pane_id,
+ }
+ }
+}
+
+impl Default for CommandStatus {
+ fn default() -> Self {
+ CommandStatus::Pending
+ }
+}
diff --git a/default-plugins/sequence/src/state/editing.rs b/default-plugins/sequence/src/state/editing.rs
new file mode 100644
index 00000000..3deac619
--- /dev/null
+++ b/default-plugins/sequence/src/state/editing.rs
@@ -0,0 +1,80 @@
+use crate::path_formatting;
+use crate::state::CommandEntry;
+use crate::ui::text_input::TextInput;
+use std::path::PathBuf;
+
+pub struct Editing {
+ pub editing_input: Option<TextInput>,
+}
+
+impl Editing {
+ pub fn new() -> Self {
+ Self {
+ editing_input: Some(TextInput::new("".to_owned())),
+ }
+ }
+
+ pub fn start_editing(&mut self, text: String) {
+ self.editing_input = Some(TextInput::new(text));
+ }
+
+ pub fn cancel_editing(&mut self) {
+ self.editing_input = None;
+ }
+
+ pub fn input_text(&self) -> Option<String> {
+ self.editing_input.as_ref().map(|e| e.get_text().to_owned())
+ }
+
+ pub fn set_input_text(&mut self, text: String) {
+ self.editing_input.as_mut().map(|e| e.set_text(text));
+ }
+
+ pub fn handle_submit(
+ &mut self,
+ selection_index: Option<usize>,
+ all_commands: &mut Vec<CommandEntry>,
+ current_cwd: &Option<PathBuf>,
+ ) -> (bool, Option<usize>) {
+ let mut handled_internally = false;
+ let mut new_selection_index = None;
+
+ if let Some(current_text) = self.editing_input.as_ref().map(|i| i.get_text()) {
+ if let Some(index) = selection_index {
+ if let Some(command) = all_commands.get_mut(index) {
+ if current_text.starts_with("cd ") || current_text == "cd" {
+ let path = if current_text == "cd" {
+ "~"
+ } else {
+ current_text[3..].trim()
+ };
+
+ if let Some(new_cwd) =
+ path_formatting::resolve_path(current_cwd.as_ref(), path)
+ {
+ command.set_cwd(Some(new_cwd));
+ }
+
+ command.set_text("".to_owned());
+ self.editing_input.as_mut().map(|c| c.clear());
+ new_selection_index = selection_index; // remain editing after a cd command
+ handled_internally = true;
+ return (handled_internally, new_selection_index); // avoid setting edit_input to None below, we
+ // still want to be in editing mode after
+ // changing directory with cd
+ } else {
+ command.set_text(current_text.to_owned());
+ }
+ }
+ }
+ }
+ self.editing_input = None;
+ (handled_internally, new_selection_index)
+ }
+}
+
+impl Default for Editing {
+ fn default() -> Self {
+ Self::new()
+ }
+}
diff --git a/default-plugins/sequence/src/state/execution.rs b/default-plugins/sequence/src/state/execution.rs
new file mode 100644
index 00000000..ff630ec0
--- /dev/null
+++ b/default-plugins/sequence/src/state/execution.rs
@@ -0,0 +1,265 @@
+use crate::path_formatting::format_cwd;
+use crate::state::{ChainType, CommandEntry, CommandStatus};
+use std::collections::BTreeMap;
+use std::path::PathBuf;
+use std::time::Duration;
+use zellij_tile::prelude::*;
+
+pub struct Execution {
+ pub all_commands: Vec<CommandEntry>,
+ pub current_running_command_index: usize,
+ pub is_running: bool,
+ pub sequence_id: u64,
+ pub primary_pane_id_before_sequence: Option<PaneId>,
+}
+
+impl Execution {
+ pub fn new() -> Self {
+ Self {
+ all_commands: vec![CommandEntry::default()],
+ current_running_command_index: 0,
+ is_running: false,
+ sequence_id: 0,
+ primary_pane_id_before_sequence: None,
+ }
+ }
+
+ pub fn longest_cwd_display(&self, global_cwd: &Option<PathBuf>) -> String {
+ self.all_commands
+ .iter()
+ .map(|cmd| {
+ let cwd = cmd.get_cwd().or_else(|| global_cwd.clone());
+ if let Some(cwd) = &cwd {
+ format_cwd(cwd)
+ } else {
+ "~".to_string()
+ }
+ })
+ .max_by_key(|s| s.len())
+ .unwrap_or_else(|| "~".to_string())
+ }
+
+ pub fn remove_empty_commands(&mut self) {
+ if self.all_commands.iter().len() > 1 {
+ self.all_commands.retain(|c| !c.get_text().is_empty());
+ }
+ }
+
+ pub fn get_first_command(&self) -> Option<CommandEntry> {
+ self.all_commands.iter().next().cloned()
+ }
+
+ pub fn set_command_status(&mut self, command_index: usize, status: CommandStatus) {
+ self.all_commands
+ .get_mut(command_index)
+ .map(|c| c.set_status(status));
+ }
+
+ pub fn set_current_running_command_status(&mut self, status: CommandStatus) {
+ self.all_commands
+ .get_mut(self.current_running_command_index)
+ .map(|c| c.set_status(status));
+ }
+
+ pub fn get_current_running_command_status(&self) -> Option<CommandStatus> {
+ self.all_commands
+ .get(self.current_running_command_index)
+ .map(|c| c.get_status())
+ }
+
+ pub fn can_run_sequence(&self) -> bool {
+ self.all_commands.iter().any(|command| !command.is_empty())
+ }
+
+ pub fn copy_to_clipboard(&self) {
+ let text_to_copy = self
+ .all_commands
+ .iter()
+ .map(|c| format!("{}", c.get_text()))
+ .collect::<Vec<_>>()
+ .join("\n");
+ if !text_to_copy.is_empty() {
+ copy_to_clipboard(text_to_copy);
+ }
+ }
+
+ pub fn update_pane_id_for_command(&mut self, pane_id: PaneId, command_text: &str) {
+ for command in self.all_commands.iter_mut() {
+ if let CommandStatus::Pending | CommandStatus::Running(None) = command.get_status() {
+ let cmd_text = command.get_text();
+ if cmd_text == command_text {
+ command.set_status(CommandStatus::Running(Some(pane_id)));
+ break;
+ }
+ }
+ }
+ }
+
+ pub fn update_exited_command_statuses(&mut self, pane_manifest: &PaneManifest) -> bool {
+ let mut updated = false;
+ for command in self.all_commands.iter_mut() {
+ let status = command.get_status();
+ let pane_id_opt = match &status {
+ CommandStatus::Running(pid) => *pid,
+ CommandStatus::Exited(_, pid) => *pid,
+ CommandStatus::Interrupted(pid) => *pid,
+ _ => None,
+ };
+
+ if let Some(pane_id) = pane_id_opt {
+ let mut pane_found = false;
+ for (_tab_index, panes) in &pane_manifest.panes {
+ for pane_info in panes {
+ let pane_matches = match pane_id {
+ PaneId::Terminal(id) => !pane_info.is_plugin && pane_info.id == id,
+ PaneId::Plugin(id) => pane_info.is_plugin && pane_info.id == id,
+ };
+
+ if pane_matches {
+ pane_found = true;
+ if pane_info.exited {
+ command.set_status(CommandStatus::Exited(
+ pane_info.exit_status,
+ Some(pane_id),
+ ));
+ updated = true;
+ }
+ break;
+ }
+ }
+ if pane_found {
+ break;
+ }
+ }
+
+ if !pane_found && command.start_time.elapsed() > Duration::from_millis(400) {
+ match command.get_status() {
+ CommandStatus::Running(_) => {
+ eprintln!(
+ "Pane {:?} was closed while running, setting pane_id to None",
+ pane_id
+ );
+ command.set_status(CommandStatus::Running(None));
+ updated = true;
+ },
+ CommandStatus::Exited(exit_code, _) => {
+ eprintln!(
+ "Pane {:?} was closed after exiting, setting pane_id to None",
+ pane_id
+ );
+ command.set_status(CommandStatus::Exited(exit_code, None));
+ updated = true;
+ },
+ CommandStatus::Interrupted(_) => {
+ eprintln!("Pane {:?} was closed after being interrupted, setting pane_id to None", pane_id);
+ command.set_status(CommandStatus::Interrupted(None));
+ updated = true;
+ },
+ _ => {},
+ }
+ }
+ }
+ }
+ updated
+ }
+
+ pub fn update_sequence_stopped_state(&mut self) -> bool {
+ let mut needs_rerender = false;
+ if self.is_running {
+ let current_idx = self.current_running_command_index;
+ if let Some(command) = self.all_commands.get_mut(current_idx) {
+ if let CommandStatus::Exited(exit_code, _) = command.get_status() {
+ let should_stop;
+ if current_idx >= self.all_commands.len().saturating_sub(1) {
+ should_stop = true;
+ } else {
+ if let Some(chain_type) = &self
+ .all_commands
+ .get(current_idx)
+ .map(|c| c.get_chain_type())
+ {
+ match chain_type {
+ ChainType::And => {
+ should_stop = exit_code.unwrap_or(0) != 0;
+ },
+ ChainType::Or => {
+ should_stop = exit_code.unwrap_or(0) == 0;
+ },
+ ChainType::Then => {
+ should_stop = false;
+ },
+ ChainType::None => {
+ should_stop = true;
+ },
+ }
+ } else {
+ should_stop = true;
+ }
+ };
+ if should_stop {
+ self.is_running = false;
+ }
+ }
+ }
+ needs_rerender = true;
+ }
+ needs_rerender
+ }
+
+ pub fn execute_command_sequence(
+ &mut self,
+ shell: &Option<PathBuf>,
+ global_cwd: &Option<PathBuf>,
+ primary_pane_id: Option<PaneId>,
+ ) {
+ use zellij_tile::prelude::actions::{Action, RunCommandAction};
+
+ self.all_commands.retain(|c| !c.is_empty());
+
+ let Some(first_active_sequence_command) = self.get_first_command() else {
+ return;
+ };
+
+ let shell = shell.clone().unwrap_or_else(|| PathBuf::from("/bin/bash"));
+
+ let first_command = first_active_sequence_command.get_text();
+ let first_chain_type = first_active_sequence_command.get_chain_type();
+ let command_cwd = first_active_sequence_command
+ .get_cwd()
+ .or_else(|| global_cwd.clone());
+
+ let command = RunCommandAction {
+ command: shell.clone(),
+ args: vec!["-ic".to_string(), first_command.trim().to_string()],
+ cwd: command_cwd,
+ hold_on_close: true,
+ ..Default::default()
+ };
+
+ let placement = NewPanePlacement::InPlace {
+ pane_id_to_replace: primary_pane_id,
+ close_replaced_pane: false,
+ };
+
+ let action = Action::NewBlockingPane {
+ placement,
+ command: Some(command),
+ pane_name: Some(first_command.trim().to_string()),
+ unblock_condition: first_chain_type.to_unblock_condition(),
+ near_current_pane: true,
+ };
+
+ self.sequence_id += 1;
+
+ let mut context = BTreeMap::new();
+ context.insert("sequence_id".to_string(), self.sequence_id.to_string());
+ run_action(action, context);
+ self.set_command_status(0, CommandStatus::Running(None));
+ }
+}
+
+impl Default for Execution {
+ fn default() -> Self {
+ Self::new()
+ }
+}
diff --git a/default-plugins/sequence/src/state/layout.rs b/default-plugins/sequence/src/state/layout.rs
new file mode 100644
index 00000000..65a267e0
--- /dev/null
+++ b/default-plugins/sequence/src/state/layout.rs
@@ -0,0 +1,73 @@
+use crate::state::positioning;
+use std::path::PathBuf;
+use zellij_tile::prelude::*;
+
+pub struct Layout {
+ pub needs_reposition: bool,
+ pub cached_cursor_position: Option<(usize, usize)>,
+ pub spinner_frame: usize,
+ pub spinner_timer_scheduled: bool,
+}
+
+impl Layout {
+ pub fn new() -> Self {
+ Self {
+ needs_reposition: false,
+ cached_cursor_position: None,
+ spinner_frame: 0,
+ spinner_timer_scheduled: false,
+ }
+ }
+
+ pub fn reposition_plugin_based_on_sequence_state(
+ &mut self,
+ is_running: bool,
+ first_command_pane_id: Option<PaneId>,
+ pane_manifest: &PaneManifest,
+ plugin_id: Option<u32>,
+ total_viewport_columns: Option<usize>,
+ total_viewport_rows: Option<usize>,
+ total_commands: usize,
+ _selected_index: Option<usize>,
+ _running_index: usize,
+ _cwd: &Option<PathBuf>,
+ ) {
+ let Some(plugin_id) = plugin_id else {
+ return;
+ };
+ let (Some(total_cols), Some(total_rows)) = (total_viewport_columns, total_viewport_rows)
+ else {
+ return;
+ };
+
+ if !self.needs_reposition || !is_running {
+ return;
+ }
+
+ let Some(first_pane_id) = first_command_pane_id else {
+ return;
+ };
+
+ for (_tab_index, panes) in &pane_manifest.panes {
+ for pane in panes {
+ if !pane.is_plugin && PaneId::Terminal(pane.id) == first_pane_id {
+ positioning::reposition_plugin_for_sequence(
+ plugin_id,
+ total_cols,
+ total_rows,
+ total_commands,
+ );
+
+ self.needs_reposition = false;
+ return;
+ }
+ }
+ }
+ }
+}
+
+impl Default for Layout {
+ fn default() -> Self {
+ Self::new()
+ }
+}
diff --git a/default-plugins/sequence/src/state/mod.rs b/default-plugins/sequence/src/state/mod.rs
new file mode 100644
index 00000000..19738be7
--- /dev/null
+++ b/default-plugins/sequence/src/state/mod.rs
@@ -0,0 +1,446 @@
+mod chain_type;
+mod command_entry;
+mod command_parser;
+mod command_status;
+mod editing;
+mod execution;
+mod layout;
+pub mod positioning;
+mod selection;
+
+pub use chain_type::ChainType;
+pub use command_entry::CommandEntry;
+pub use command_parser::{
+ detect_cd_command, detect_chain_operator_at_end, get_remaining_after_first_segment,
+ split_by_chain_operators,
+};
+pub use command_status::CommandStatus;
+pub use editing::Editing;
+pub use execution::Execution;
+pub use layout::Layout;
+pub use selection::Selection;
+
+use crate::ui::text_input::TextInput;
+use std::path::PathBuf;
+use zellij_tile::prelude::*;
+
+pub struct State {
+ pub shell: Option<PathBuf>,
+ pub plugin_id: Option<u32>,
+ pub client_id: Option<u16>,
+ pub own_rows: Option<usize>,
+ pub own_columns: Option<usize>,
+ pub cwd: Option<PathBuf>,
+ pub original_pane_id: Option<PaneId>,
+ pub primary_pane_id: Option<PaneId>,
+ pub is_first_run: bool,
+ pub pane_manifest: Option<PaneManifest>,
+ pub total_viewport_columns: Option<usize>,
+ pub total_viewport_rows: Option<usize>,
+ pub selection: Selection,
+ pub editing: Editing,
+ pub execution: Execution,
+ pub layout: Layout,
+ pub current_position: Option<FloatingPaneCoordinates>,
+}
+
+impl State {
+ pub fn new() -> Self {
+ Self {
+ shell: None,
+ plugin_id: None,
+ client_id: None,
+ own_rows: None,
+ own_columns: None,
+ cwd: None,
+ original_pane_id: None,
+ primary_pane_id: None,
+ is_first_run: true,
+ pane_manifest: None,
+ total_viewport_columns: None,
+ total_viewport_rows: None,
+ selection: Selection::new(),
+ editing: Editing::new(),
+ execution: Execution::new(),
+ layout: Layout::new(),
+ current_position: None,
+ }
+ }
+
+ pub fn set_plugin_id(&mut self, plugin_id: u32) {
+ self.plugin_id = Some(plugin_id);
+ }
+
+ /// Check if the sequence has finished executing (all commands are done)
+ pub fn has_finished(&self) -> bool {
+ self.execution
+ .all_commands
+ .iter()
+ .all(|command| matches!(command.status, CommandStatus::Exited(_, _)))
+ }
+ pub fn add_empty_command_after_current_selected(&mut self) {
+ self.selection.add_empty_command_after_current_selected(
+ &mut self.execution.all_commands,
+ &mut self.editing,
+ &self.cwd,
+ );
+ }
+
+ pub fn current_selected_command_is_empty(&self) -> bool {
+ self.selection
+ .current_selected_command_is_empty(&self.execution.all_commands, &self.editing)
+ }
+
+ pub fn remove_current_selected_command(&mut self) {
+ let removed_pane_id = self
+ .selection
+ .remove_current_selected_command(&mut self.execution.all_commands, &mut self.editing);
+ if let Some(removed_pane_id) = removed_pane_id {
+ close_pane_with_id(removed_pane_id);
+ }
+ }
+
+ pub fn clear_current_selected_command(&mut self) {
+ self.selection
+ .clear_current_selected_command(&mut self.execution.all_commands, &mut self.editing);
+ }
+
+ pub fn move_selection_up(&mut self) {
+ self.selection
+ .move_up(&mut self.execution.all_commands, &mut self.editing);
+ if let Some(pane_id) = self
+ .current_selected_command()
+ .and_then(|c| c.get_pane_id())
+ {
+ show_pane_with_id(pane_id, true, false);
+ }
+ }
+
+ pub fn move_selection_down(&mut self) {
+ self.selection
+ .move_down(&mut self.execution.all_commands, &mut self.editing);
+ if let Some(pane_id) = self
+ .current_selected_command()
+ .and_then(|c| c.get_pane_id())
+ {
+ show_pane_with_id(pane_id, true, false);
+ }
+ }
+ pub fn start_editing_selected(&mut self) {
+ if let Some(current_command_text) = self.current_selected_command().map(|c| c.get_text()) {
+ self.editing.start_editing(current_command_text);
+ }
+ }
+
+ pub fn cancel_editing_selected(&mut self) {
+ self.editing.cancel_editing();
+ }
+
+ pub fn editing_input_text(&self) -> Option<String> {
+ self.editing.input_text()
+ }
+
+ pub fn set_editing_input_text(&mut self, text: String) {
+ self.editing.set_input_text(text);
+ }
+ fn handle_first_pasted_segment(
+ &mut self,
+ current_text: &str,
+ segment_text: &str,
+ chain_type_opt: &Option<ChainType>,
+ ) {
+ let new_text = if current_text.trim().is_empty() {
+ segment_text.to_string()
+ } else {
+ format!("{}{}", current_text, segment_text)
+ };
+
+ if let Some(path) = detect_cd_command(&new_text) {
+ use crate::path_formatting;
+ if let Some(new_cwd) = path_formatting::resolve_path(self.cwd.as_ref(), &path) {
+ self.current_selected_command_mut().map(|c| {
+ c.set_cwd(Some(new_cwd));
+ c.set_text("".to_owned());
+ if let Some(chain_type) = chain_type_opt {
+ c.set_chain_type(*chain_type);
+ }
+ });
+ return;
+ }
+ }
+
+ self.current_selected_command_mut().map(|c| {
+ c.set_text(new_text);
+ if let Some(chain_type) = chain_type_opt {
+ c.set_chain_type(*chain_type);
+ }
+ });
+ }
+
+ fn insert_new_pasted_segment(
+ &mut self,
+ segment_text: &str,
+ chain_type_opt: &Option<ChainType>,
+ is_last_line: bool,
+ ) {
+ use crate::path_formatting;
+
+ let cd_path = detect_cd_command(segment_text);
+
+ if let Some(path) = cd_path {
+ if let Some(new_cwd) = path_formatting::resolve_path(self.cwd.as_ref(), &path) {
+ self.current_selected_command_mut().map(|c| {
+ c.set_cwd(Some(new_cwd));
+ if let Some(chain_type) = chain_type_opt {
+ c.set_chain_type(*chain_type);
+ }
+ });
+ return;
+ }
+ }
+
+ let Some(new_selected_index) = self.selection.current_selected_command_index.map(|i| i + 1)
+ else {
+ return;
+ };
+
+ let mut new_command = CommandEntry::new(segment_text, self.cwd.clone());
+ if let Some(chain_type) = chain_type_opt {
+ new_command.set_chain_type(*chain_type);
+ } else if !is_last_line {
+ new_command.set_chain_type(ChainType::And);
+ }
+
+ self.execution
+ .all_commands
+ .insert(new_selected_index, new_command);
+ self.selection.current_selected_command_index = Some(new_selected_index);
+ }
+
+ fn ensure_line_end_chain_type(&mut self) {
+ if let Some(last_cmd_index) = self.selection.current_selected_command_index {
+ self.execution
+ .all_commands
+ .get_mut(last_cmd_index)
+ .map(|c| {
+ if matches!(c.get_chain_type(), ChainType::None) {
+ c.set_chain_type(ChainType::And);
+ }
+ });
+ }
+ }
+
+ pub fn pasted_lines(&mut self, lines: Vec<&str>) {
+ let Some(current_text) = self.editing_input_text() else {
+ return;
+ };
+
+ for (line_index, line) in lines.iter().enumerate() {
+ let segments = split_by_chain_operators(line);
+ let is_last_line = line_index == lines.len().saturating_sub(1);
+
+ for (seg_index, (segment_text, chain_type_opt)) in segments.iter().enumerate() {
+ if line_index == 0 && seg_index == 0 {
+ self.handle_first_pasted_segment(&current_text, segment_text, chain_type_opt);
+ } else {
+ self.insert_new_pasted_segment(segment_text, chain_type_opt, is_last_line);
+ }
+ }
+
+ if !is_last_line {
+ self.ensure_line_end_chain_type();
+ }
+ }
+
+ self.start_editing_selected();
+ }
+ pub fn remove_empty_commands(&mut self) {
+ self.execution.remove_empty_commands();
+ }
+
+ pub fn get_first_command(&self) -> Option<CommandEntry> {
+ self.execution.get_first_command()
+ }
+
+ pub fn set_command_status(&mut self, command_index: usize, status: CommandStatus) {
+ self.execution.set_command_status(command_index, status);
+ }
+
+ pub fn set_current_running_command_status(&mut self, status: CommandStatus) {
+ self.execution.set_current_running_command_status(status);
+ }
+
+ pub fn get_current_running_command_status(&mut self) -> Option<CommandStatus> {
+ self.execution.get_current_running_command_status()
+ }
+
+ pub fn handle_editing_submit(&mut self, current_cwd: &Option<PathBuf>) -> bool {
+ let (handled_internally, new_selection_index) = self.editing.handle_submit(
+ self.selection.current_selected_command_index,
+ &mut self.execution.all_commands,
+ current_cwd,
+ );
+ self.selection.current_selected_command_index = new_selection_index;
+ handled_internally
+ }
+
+ pub fn update_pane_id_for_command(&mut self, pane_id: PaneId, command_text: &str) {
+ self.execution
+ .update_pane_id_for_command(pane_id, command_text);
+ }
+ pub fn update_exited_command_statuses(&mut self, pane_manifest: &PaneManifest) -> bool {
+ self.execution.update_exited_command_statuses(pane_manifest)
+ }
+ pub fn update_sequence_stopped_state(&mut self) -> bool {
+ self.execution.update_sequence_stopped_state()
+ }
+
+ pub fn cycle_chain_type(&mut self) {
+ self.current_selected_command_mut()
+ .map(|c| c.cycle_chain_type());
+ }
+
+ pub fn set_primary_pane_id_before_sequence(&mut self, pane_id: Option<PaneId>) {
+ if pane_id.is_some() {
+ self.execution.primary_pane_id_before_sequence = pane_id;
+ }
+ }
+ pub fn clear_all_commands(&mut self) {
+ self.execution.all_commands = vec![CommandEntry::new("", self.cwd.clone())];
+ self.editing.editing_input = Some(TextInput::new("".to_owned()));
+ self.selection.current_selected_command_index = Some(0);
+ self.execution.current_running_command_index = 0;
+ }
+ pub fn update_running_state(&mut self, primary_pane_id: Option<PaneId>) {
+ self.remove_empty_commands();
+ self.set_primary_pane_id_before_sequence(primary_pane_id);
+ self.layout.needs_reposition = true;
+ self.execution.is_running = true;
+ }
+
+ pub fn can_run_sequence(&self) -> bool {
+ self.execution.can_run_sequence()
+ }
+
+ pub fn copy_to_clipboard(&mut self) {
+ self.execution.copy_to_clipboard();
+ }
+
+ pub fn current_selected_command_mut(&mut self) -> Option<&mut CommandEntry> {
+ let Some(i) = self.selection.current_selected_command_index else {
+ return None;
+ };
+ self.execution.all_commands.get_mut(i)
+ }
+ pub fn reposition_plugin(&mut self) -> bool {
+ let mut repositioned = false;
+ let Some(plugin_id) = self.plugin_id else {
+ return repositioned;
+ };
+ let Some(total_viewport_rows) = self.total_viewport_rows else {
+ return repositioned;
+ };
+ let Some(total_viewport_columns) = self.total_viewport_columns else {
+ return repositioned;
+ };
+ let total_commands = std::cmp::max(self.execution.all_commands.iter().len(), 1);
+ let height_padding = 7;
+ if self.all_commands_are_pending() {
+ let initial_height = total_commands + height_padding;
+ let y = total_viewport_rows.saturating_sub(initial_height) / 2;
+ let coordinates = FloatingPaneCoordinates::new(
+ Some(format!("25%")),
+ Some(format!("{}", y)),
+ Some(format!("50%")),
+ Some(format!("{}", initial_height)),
+ Some(false), // should not be pinned when running sequence
+ );
+ coordinates.map(|coordinates| {
+ if Some(&coordinates) != self.current_position.as_ref() {
+ repositioned = true;
+ self.current_position = Some(coordinates.clone());
+ change_floating_panes_coordinates(vec![(
+ PaneId::Plugin(plugin_id),
+ coordinates,
+ )]);
+ }
+ });
+ } else {
+ // Calculate longest table row width
+ let longest_cwd_display = self.execution.longest_cwd_display(&self.cwd);
+ let longest_command = self
+ .execution
+ .all_commands
+ .iter()
+ .map(|cmd| cmd.get_text().chars().count())
+ .max()
+ .unwrap_or(1);
+
+ let (max_chain_width, max_status_width) = crate::ui::components::calculate_max_widths(
+ &self.execution.all_commands,
+ self.layout.spinner_frame,
+ );
+
+ let longest_line = crate::ui::components::calculate_longest_line(
+ &longest_cwd_display,
+ longest_command,
+ max_chain_width,
+ max_status_width,
+ );
+
+ let ui_width = std::cmp::max(longest_line, 50) + 4; // 2 for ui-padding, 2 for pane frame
+ let width = std::cmp::min(ui_width, total_viewport_columns / 2);
+
+ let height = (height_padding + total_commands).min((total_viewport_rows * 25) / 100);
+
+ // Position: top-right with 1-space margins
+ let x = total_viewport_columns.saturating_sub(width);
+ let y = 1;
+
+ let coordinates = FloatingPaneCoordinates::new(
+ Some(x.to_string()),
+ Some(y.to_string()),
+ Some(width.to_string()),
+ Some(height.to_string()),
+ Some(true), // should be pinned for sequence
+ );
+ coordinates.map(|coordinates| {
+ if Some(&coordinates) != self.current_position.as_ref() {
+ self.current_position = Some(coordinates.clone());
+ repositioned = true;
+ change_floating_panes_coordinates(vec![(
+ PaneId::Plugin(plugin_id),
+ coordinates,
+ )]);
+ }
+ });
+ }
+ repositioned
+ }
+ pub fn all_commands_are_pending(&self) -> bool {
+ self.execution
+ .all_commands
+ .iter()
+ .all(|command| matches!(command.get_status(), CommandStatus::Pending))
+ }
+
+ fn current_selected_command(&self) -> Option<&CommandEntry> {
+ let Some(i) = self.selection.current_selected_command_index else {
+ return None;
+ };
+ self.execution.all_commands.get(i)
+ }
+
+ pub fn execute_command_sequence(&mut self) {
+ self.execution
+ .execute_command_sequence(&self.shell, &self.cwd, self.primary_pane_id);
+ }
+}
+
+impl Default for State {
+ fn default() -> Self {
+ let mut state = Self::new();
+ state.is_first_run = true;
+ state
+ }
+}
diff --git a/default-plugins/sequence/src/state/positioning.rs b/default-plugins/sequence/src/state/positioning.rs
new file mode 100644
index 00000000..6acadfff
--- /dev/null
+++ b/default-plugins/sequence/src/state/positioning.rs
@@ -0,0 +1,48 @@
+use zellij_tile::prelude::*;
+
+pub fn reposition_plugin_for_sequence(
+ plugin_id: u32,
+ total_cols: usize,
+ total_rows: usize,
+ total_commands: usize,
+) {
+ // Width: 30% of viewport
+ let width = std::cmp::max((total_cols * 30) / 100, 50);
+
+ // Height: UI overhead + commands, capped at 25% of viewport
+ let overhead_rows = 6;
+ let height = (overhead_rows + total_commands).min((total_rows * 25) / 100);
+
+ // Position: top-right with 1-space margins
+ let x = total_cols.saturating_sub(width);
+ let y = 1;
+
+ change_floating_pane_coordinates_absolute(
+ plugin_id,
+ Some(x),
+ Some(y),
+ Some(width),
+ Some(height),
+ true,
+ );
+}
+
+pub fn change_floating_pane_coordinates_absolute(
+ own_plugin_id: u32,
+ x: Option<usize>,
+ y: Option<usize>,
+ width: Option<usize>,
+ height: Option<usize>,
+ should_be_pinned: bool,
+) {
+ let coordinates = FloatingPaneCoordinates::new(
+ x.map(|x| x.to_string()),
+ y.map(|y| y.to_string()),
+ width.map(|width| width.to_string()),
+ height.map(|height| height.to_string()),
+ Some(should_be_pinned),
+ );
+ if let Some(coordinates) = coordinates {
+ change_floating_panes_coordinates(vec![(PaneId::Plugin(own_plugin_id), coordinates)]);
+ }
+}
diff --git a/default-plugins/sequence/src/state/selection.rs b/default-plugins/sequence/src/state/selection.rs
new file mode 100644
index 00000000..18f83bcd
--- /dev/null
+++ b/default-plugins/sequence/src/state/selection.rs
@@ -0,0 +1,214 @@
+use crate::state::{CommandEntry, Editing};
+use std::path::PathBuf;
+use zellij_tile::prelude::*;
+
+pub struct Selection {
+ pub current_selected_command_index: Option<usize>,
+ pub scroll_offset: usize,
+}
+
+impl Selection {
+ pub fn new() -> Self {
+ Self {
+ current_selected_command_index: Some(0),
+ scroll_offset: 0,
+ }
+ }
+
+ pub fn add_empty_command_after_current_selected(
+ &mut self,
+ all_commands: &mut Vec<CommandEntry>,
+ editing: &mut Editing,
+ cwd: &Option<PathBuf>,
+ ) {
+ self.save_editing_buffer_to_current_selected(all_commands, editing);
+ editing.editing_input.as_mut().map(|i| i.clear());
+ if !self.current_selected_command_is_empty(all_commands, editing) {
+ self.current_selected_command_mut(all_commands)
+ .map(|c| c.fill_chain_type_if_empty());
+
+ if let Some(current_selected_command_index) = self.current_selected_command_index.take()
+ {
+ let new_command = CommandEntry::new("", cwd.clone());
+ if current_selected_command_index == all_commands.len().saturating_sub(1) {
+ all_commands.push(new_command);
+ } else {
+ all_commands.insert(current_selected_command_index + 1, new_command);
+ }
+
+ self.current_selected_command_index = Some(current_selected_command_index + 1);
+ }
+ }
+ }
+
+ pub fn current_selected_command_is_empty(
+ &self,
+ all_commands: &[CommandEntry],
+ editing: &Editing,
+ ) -> bool {
+ let text_input_is_empty = editing
+ .editing_input
+ .as_ref()
+ .map(|i| i.is_empty())
+ .unwrap_or(true);
+ let current_selected_command_is_empty = self
+ .current_selected_command_index
+ .and_then(|i| all_commands.get(i).map(|c| c.is_empty()))
+ .unwrap_or(true);
+ text_input_is_empty && current_selected_command_is_empty
+ }
+
+ pub fn remove_current_selected_command(
+ &mut self,
+ all_commands: &mut Vec<CommandEntry>,
+ editing: &mut Editing,
+ ) -> Option<PaneId> {
+ // returns the pane_id of the removed command, if any
+ let mut removed_pane_id = None;
+ let Some(mut current_selected_command_index) = self.current_selected_command_index else {
+ return removed_pane_id;
+ };
+ if all_commands.len() > 1 && all_commands.len() > current_selected_command_index {
+ let command = all_commands.remove(current_selected_command_index);
+ removed_pane_id = command.get_pane_id();
+ } else {
+ self.clear_current_selected_command(all_commands, editing);
+ }
+ if current_selected_command_index >= all_commands.len()
+ && current_selected_command_index > 0
+ {
+ current_selected_command_index -= 1;
+ }
+ self.update_current_selected_command_index(
+ current_selected_command_index,
+ all_commands,
+ editing,
+ );
+ if current_selected_command_index == all_commands.len().saturating_sub(1) {
+ self.current_selected_command_mut(all_commands)
+ .map(|c| c.clear_chain_type());
+ }
+ removed_pane_id
+ }
+
+ pub fn clear_current_selected_command(
+ &mut self,
+ all_commands: &mut [CommandEntry],
+ editing: &mut Editing,
+ ) {
+ let Some(current_selected_command_index) = self.current_selected_command_index else {
+ return;
+ };
+ self.current_selected_command_mut(all_commands)
+ .map(|c| c.clear_text());
+ self.clear_editing_buffer(editing);
+ if current_selected_command_index == all_commands.len().saturating_sub(1) {
+ self.current_selected_command_mut(all_commands)
+ .map(|c| c.clear_chain_type());
+ }
+ }
+
+ pub fn move_up(&mut self, all_commands: &mut [CommandEntry], editing: &mut Editing) {
+ self.save_editing_buffer_to_current_selected(all_commands, editing);
+ match self.current_selected_command_index.as_mut() {
+ Some(i) if *i > 0 => {
+ *i -= 1;
+ },
+ None => {
+ self.current_selected_command_index = Some(all_commands.len().saturating_sub(1));
+ },
+ _ => {
+ self.current_selected_command_index = None;
+ },
+ }
+ self.update_editing_buffer_to_current_selected(all_commands, editing);
+ }
+
+ pub fn move_down(&mut self, all_commands: &mut [CommandEntry], editing: &mut Editing) {
+ self.save_editing_buffer_to_current_selected(all_commands, editing);
+ match self.current_selected_command_index.as_mut() {
+ Some(i) if *i < all_commands.len().saturating_sub(1) => {
+ *i += 1;
+ },
+ None => {
+ self.current_selected_command_index = Some(0);
+ },
+ _ => {
+ self.current_selected_command_index = None;
+ },
+ }
+ self.update_editing_buffer_to_current_selected(all_commands, editing);
+ }
+
+ fn current_selected_command_mut<'a>(
+ &self,
+ all_commands: &'a mut [CommandEntry],
+ ) -> Option<&'a mut CommandEntry> {
+ let Some(i) = self.current_selected_command_index else {
+ return None;
+ };
+ all_commands.get_mut(i)
+ }
+
+ fn current_selected_command<'a>(
+ &self,
+ all_commands: &'a [CommandEntry],
+ ) -> Option<&'a CommandEntry> {
+ let Some(i) = self.current_selected_command_index else {
+ return None;
+ };
+ all_commands.get(i)
+ }
+
+ fn clear_editing_buffer(&self, editing: &mut Editing) {
+ editing.editing_input.as_mut().map(|c| c.clear());
+ }
+
+ fn update_current_selected_command_index(
+ &mut self,
+ new_index: usize,
+ all_commands: &[CommandEntry],
+ editing: &mut Editing,
+ ) {
+ self.current_selected_command_index = Some(new_index);
+ self.update_editing_buffer_to_current_selected(all_commands, editing);
+ }
+
+ fn get_text_of_current_selected_command(
+ &self,
+ all_commands: &[CommandEntry],
+ ) -> Option<String> {
+ self.current_selected_command(all_commands)
+ .map(|c| c.get_text())
+ }
+
+ fn save_editing_buffer_to_current_selected(
+ &self,
+ all_commands: &mut [CommandEntry],
+ editing: &mut Editing,
+ ) {
+ if let Some(text_input) = editing.editing_input.as_ref().map(|i| i.get_text()) {
+ self.current_selected_command_mut(all_commands)
+ .map(|c| c.set_text(text_input.to_owned()));
+ }
+ }
+
+ fn update_editing_buffer_to_current_selected(
+ &self,
+ all_commands: &[CommandEntry],
+ editing: &mut Editing,
+ ) {
+ let new_text_of_current_selected_command = self
+ .get_text_of_current_selected_command(all_commands)
+ .unwrap_or_else(|| String::new());
+ if let Some(editing_input) = editing.editing_input.as_mut() {
+ editing_input.set_text(new_text_of_current_selected_command);
+ }
+ }
+}
+
+impl Default for Selection {
+ fn default() -> Self {
+ Self::new()
+ }
+}
diff --git a/default-plugins/sequence/src/ui/components.rs b/default-plugins/sequence/src/ui/components.rs
new file mode 100644
index 00000000..3fe2ae1a
--- /dev/null
+++ b/default-plugins/sequence/src/ui/components.rs
@@ -0,0 +1,596 @@
+use crate::path_formatting::format_cwd;
+use crate::state::{CommandStatus, State};
+use crate::ui::truncation::{calculate_available_cmd_width, truncate_middle};
+use std::path::PathBuf;
+use zellij_tile::prelude::*;
+
+const SPINNER_FRAMES: &[&str] = &["β ‹", "β ™", "β Ή", "β Έ", "β Ό", "β ΄", "β ¦", "β §"];
+
+const HELP_RUNNING_WITH_SELECTION: &str = "<Ctrl c> - interrupt, <Enter> - run from selected";
+const HELP_RUNNING_NO_SELECTION: &str = "<Ctrl c> - interrupt, <↓↑> - navigate";
+const HELP_STOPPED_WITH_SELECTION: &str =
+ "<Ctrl w> - close all, <Enter> - run from selected, <e> - edit";
+const HELP_STOPPED_NO_SELECTION: &str =
+ "<Ctrl w> - close all, <↓↑> - navigate, <Enter> - run from first";
+
+const HELP_ONE_PENDING_COMMAND: &str = "<Enter> - run, <Ctrl Enter> - add command";
+
+const HELP_ALL_COMMANDS_PENDING: &str =
+ "<Enter> - run, <Ctrl Enter> - add command, <↓↑> - navigate";
+const HELP_ALL_COMMANDS_PENDING_WITH_SELECTION: &str =
+ "<Enter> - run, <Ctrl Enter> - add command, <↓↑> - navigate, <e> - edit selected";
+
+const HELP_EDITING_FIRST_LINE: &str =
+ "<Enter> - accept, <Ctrl Enter> - add command, <↓↑> - navigate";
+
+fn select_help_text(sequence: &State) -> &'static str {
+ let is_running = sequence
+ .execution
+ .all_commands
+ .iter()
+ .any(|command| matches!(command.get_status(), CommandStatus::Running(_)));
+ let all_pending = sequence
+ .execution
+ .all_commands
+ .iter()
+ .all(|command| matches!(command.get_status(), CommandStatus::Pending));
+ let has_selection = sequence.selection.current_selected_command_index.is_some();
+ let is_editing = sequence.editing.editing_input.is_some();
+
+ if all_pending && !sequence.execution.is_running {
+ if sequence.execution.all_commands.len() == 1 {
+ HELP_ONE_PENDING_COMMAND
+ } else if has_selection && !is_editing {
+ HELP_ALL_COMMANDS_PENDING_WITH_SELECTION
+ } else if is_editing {
+ HELP_EDITING_FIRST_LINE
+ } else {
+ HELP_ALL_COMMANDS_PENDING
+ }
+ } else if is_running {
+ if has_selection {
+ HELP_RUNNING_WITH_SELECTION
+ } else {
+ HELP_RUNNING_NO_SELECTION
+ }
+ } else {
+ if has_selection && !is_editing {
+ HELP_STOPPED_WITH_SELECTION
+ } else if is_editing {
+ HELP_EDITING_FIRST_LINE
+ } else {
+ HELP_STOPPED_NO_SELECTION
+ }
+ }
+}
+
+fn style_help_text(text: &str) -> Text {
+ Text::new(text)
+ .color_substring(3, "<Ctrl c>")
+ .color_substring(3, "<Ctrl w>")
+ .color_substring(3, "<↓↑>")
+ .color_substring(3, "<Esc>")
+ .color_substring(3, "<Enter>")
+ .color_substring(3, "<Ctrl Enter>")
+ .color_substring(3, "<e>")
+}
+
+pub fn render_help_lines(
+ sequence: &State,
+ max_width: Option<usize>,
+) -> (Text, usize, Option<(Text, usize)>) {
+ let is_editing = sequence.editing.editing_input.is_some();
+
+ if is_editing {
+ let help_text = select_help_text(sequence);
+ let (truncated_text, help_len) = if let Some(width) = max_width {
+ truncate_help_line(help_text, width)
+ } else {
+ (help_text.to_string(), help_text.chars().count())
+ };
+ let first_line = (style_help_text(&truncated_text).unbold_all(), help_len);
+
+ let editing_help_text = "Navigate with cd, chain with ||, &&, ; ";
+ let editing_help = Text::new(editing_help_text)
+ .color_substring(3, "cd")
+ .color_substring(3, "||")
+ .color_substring(3, "&&")
+ .color_substring(3, ";")
+ .unbold_all();
+ let editing_len = editing_help_text.len();
+
+ return (
+ first_line.0,
+ first_line.1,
+ Some((editing_help, editing_len)),
+ );
+ }
+
+ let help_text = select_help_text(sequence);
+
+ if let Some(width) = max_width {
+ if let Some((first_line, second_line)) = split_help_line(help_text, width) {
+ let first_len = first_line.chars().count();
+ let first_styled = style_help_text(&first_line).unbold_all();
+
+ let second_line_width = second_line.chars().count();
+ let (second_styled, second_len) = if second_line_width > width {
+ let (truncated, len) = truncate_help_line(&second_line, width);
+ (style_help_text(&truncated).unbold_all(), len)
+ } else {
+ (
+ style_help_text(&second_line).unbold_all(),
+ second_line_width,
+ )
+ };
+
+ return (first_styled, first_len, Some((second_styled, second_len)));
+ }
+
+ let (truncated_text, help_len) = truncate_help_line(help_text, width);
+ (
+ style_help_text(&truncated_text).unbold_all(),
+ help_len,
+ None,
+ )
+ } else {
+ let help_len = help_text.chars().count();
+ (style_help_text(help_text).unbold_all(), help_len, None)
+ }
+}
+
+fn split_help_line(help_text: &str, max_width: usize) -> Option<(String, String)> {
+ let text_width = help_text.chars().count();
+
+ if text_width <= max_width {
+ return None;
+ }
+
+ let keybindings = parse_help_keybindings(help_text);
+
+ if keybindings.is_empty() {
+ return None;
+ }
+
+ for split_point in 1..keybindings.len() {
+ let first_part: Vec<String> = keybindings
+ .iter()
+ .take(split_point)
+ .map(|(key, _, action)| format!("{} - {}", key, action))
+ .collect();
+ let first_line = first_part.join(", ");
+
+ let second_part: Vec<String> = keybindings
+ .iter()
+ .skip(split_point)
+ .map(|(key, _, action)| format!("{} - {}", key, action))
+ .collect();
+ let second_line = second_part.join(", ");
+
+ if first_line.chars().count() <= max_width && second_line.chars().count() <= max_width {
+ return Some((first_line, second_line));
+ }
+ }
+
+ None
+}
+
+fn folder_cell(cwd: &Option<PathBuf>) -> (Text, String) {
+ let cwd_display = if let Some(cwd) = cwd {
+ format_cwd(cwd)
+ } else {
+ "~".to_string()
+ };
+ let folder_display = format!("{} >", cwd_display);
+ let text = Text::new(&folder_display).color_range(0, 0..cwd_display.len());
+ (text, folder_display)
+}
+
+fn command_cell(text: &str) -> Text {
+ let text = if text.is_empty() { " " } else { text };
+ Text::new(text)
+}
+
+fn chain_cell(chain_type: &crate::state::ChainType) -> Text {
+ let text = chain_type_to_str(chain_type);
+ if !text.is_empty() {
+ Text::new(text).color_range(1, 0..text.len())
+ } else {
+ Text::new(text)
+ }
+}
+
+fn get_spinner_frame(frame: usize) -> &'static str {
+ SPINNER_FRAMES[frame % SPINNER_FRAMES.len()]
+}
+
+pub fn format_status_text(status: &CommandStatus, spinner_frame: usize) -> String {
+ match status {
+ CommandStatus::Running(_) => {
+ let spinner = get_spinner_frame(spinner_frame);
+ format!("[RUNNING] {}", spinner)
+ },
+ CommandStatus::Interrupted(_) => "[INTERRUPTED]".to_string(),
+ CommandStatus::Exited(Some(code), _) => format!("[EXIT CODE: {}]", code),
+ CommandStatus::Exited(None, _) => "[EXITED]".to_string(),
+ CommandStatus::Pending => " ".to_string(),
+ }
+}
+
+fn apply_status_color(text: Text, status: &CommandStatus, status_str: &str) -> Text {
+ match status {
+ CommandStatus::Running(_) => text.color_range(3, 0..status_str.len()),
+ CommandStatus::Interrupted(_) => text.error_color_range(0..status_str.len()),
+ CommandStatus::Exited(Some(0), _) => {
+ let number_start = 12;
+ let number_end = status_str.len() - 1;
+ text.success_color_range(number_start..number_end)
+ },
+ CommandStatus::Exited(Some(_), _) => {
+ let number_start = 12;
+ let number_end = status_str.len() - 1;
+ text.error_color_range(number_start..number_end)
+ },
+ _ => text,
+ }
+}
+
+fn status_cell(status: &CommandStatus, spinner_frame: usize) -> Text {
+ let status_str = format_status_text(status, spinner_frame);
+ let text = Text::new(&status_str);
+ apply_status_color(text, status, &status_str)
+}
+
+fn apply_row_styles(cells: Vec<Text>, is_selected: bool) -> Vec<Text> {
+ let mut styled_cells = cells;
+
+ if is_selected {
+ styled_cells = styled_cells.into_iter().map(|c| c.selected()).collect();
+ }
+
+ styled_cells
+}
+
+fn overflow_cell(indicator: &str, is_selected: bool) -> Text {
+ let mut text = Text::new(indicator).dim_all();
+ if is_selected {
+ text = text.selected();
+ }
+ text
+}
+
+fn command_sequence_row(
+ state: &State,
+ cwd: &Option<PathBuf>,
+ truncated_cmd_text: &str,
+ chain_type: &crate::state::ChainType,
+ status: &crate::state::CommandStatus,
+ is_selected: bool,
+ overflow_indicator: Option<String>,
+) -> (Vec<Text>, usize) {
+ let (folder_text, folder_display) = folder_cell(cwd);
+
+ let mut cells = vec![
+ folder_text,
+ command_cell(truncated_cmd_text),
+ chain_cell(chain_type),
+ status_cell(status, state.layout.spinner_frame),
+ ];
+
+ cells = apply_row_styles(cells, is_selected);
+
+ let truncated_cmd_text = if truncated_cmd_text.is_empty() {
+ " "
+ } else {
+ truncated_cmd_text
+ };
+ let mut row_length = folder_display.chars().count()
+ + truncated_cmd_text.chars().count()
+ + chain_type_to_str(chain_type).chars().count()
+ + format_status_text(status, state.layout.spinner_frame)
+ .chars()
+ .count()
+ + 3;
+
+ if let Some(indicator) = overflow_indicator {
+ let indicator_len = indicator.chars().count();
+ cells.push(overflow_cell(&indicator, is_selected));
+ row_length += indicator_len + 1;
+ }
+
+ (cells, row_length)
+}
+
+pub fn build_table_header(has_overflow: bool) -> Table {
+ let header_cols = if has_overflow {
+ vec![" ", " ", " ", " ", " "]
+ } else {
+ vec![" ", " ", " ", " "]
+ };
+ Table::new().add_row(header_cols)
+}
+
+fn calculate_overflow_indicator(
+ visible_idx: usize,
+ visible_count: usize,
+ hidden_above: usize,
+ hidden_below: usize,
+) -> Option<String> {
+ if visible_idx == 0 && hidden_above > 0 {
+ Some(format!("[+{}]", hidden_above))
+ } else if visible_idx == visible_count.saturating_sub(1) && hidden_below > 0 {
+ Some(format!("[+{}]", hidden_below))
+ } else if hidden_above > 0 || hidden_below > 0 {
+ Some(format!(" "))
+ } else {
+ None
+ }
+}
+
+pub fn calculate_row_layout_info(
+ index: usize,
+ offset: usize,
+ visible_count: usize,
+ hidden_above: usize,
+ hidden_below: usize,
+ cols: usize,
+ folder_width: usize,
+ max_chain_width: usize,
+ max_status_width: usize,
+) -> Option<(Option<String>, usize)> {
+ if index < offset || index >= offset + visible_count {
+ return None;
+ }
+
+ let visible_idx = index.saturating_sub(offset);
+ let overflow_indicator =
+ calculate_overflow_indicator(visible_idx, visible_count, hidden_above, hidden_below);
+
+ let available_cmd_width = calculate_available_cmd_width(
+ cols,
+ folder_width,
+ overflow_indicator.as_ref(),
+ max_chain_width,
+ max_status_width,
+ );
+
+ Some((overflow_indicator, available_cmd_width))
+}
+
+pub fn add_command_row(
+ table: Table,
+ state: &State,
+ index: usize,
+ offset: usize,
+ visible_count: usize,
+ hidden_above: usize,
+ hidden_below: usize,
+) -> Table {
+ let command = match state.execution.all_commands.get(index) {
+ Some(cmd) => cmd,
+ None => return table,
+ };
+
+ let cmd_text = &command.get_text();
+ let chain_type = &command.get_chain_type();
+ let status = &command.get_status();
+ let command_cwd = command.get_cwd().or_else(|| state.cwd.clone());
+
+ let cols = state.own_columns.unwrap_or(80);
+ let longest_cwd_display = state.execution.longest_cwd_display(&state.cwd);
+ let folder_display = format!("{} >", longest_cwd_display);
+ let folder_width = folder_display.chars().count();
+
+ let (max_chain_width, max_status_width) =
+ calculate_max_widths(&state.execution.all_commands, state.layout.spinner_frame);
+
+ let Some((overflow_indicator, available_cmd_width)) = calculate_row_layout_info(
+ index,
+ offset,
+ visible_count,
+ hidden_above,
+ hidden_below,
+ cols,
+ folder_width,
+ max_chain_width,
+ max_status_width,
+ ) else {
+ return table;
+ };
+
+ if let Some(text_input) = &state.editing.editing_input {
+ if state.selection.current_selected_command_index == Some(index) {
+ let (truncated_editing_text, _) = truncate_middle(
+ text_input.get_text(),
+ available_cmd_width,
+ Some(text_input.cursor_position()),
+ );
+ let (row, _) = command_sequence_row(
+ state,
+ &command_cwd,
+ &truncated_editing_text,
+ chain_type,
+ &CommandStatus::Pending,
+ false,
+ overflow_indicator,
+ );
+ return table.add_styled_row(row);
+ }
+ }
+
+ let truncated_cmd_text = truncate_middle(cmd_text, available_cmd_width, None).0;
+
+ let (row, _) = command_sequence_row(
+ state,
+ &command_cwd,
+ &truncated_cmd_text,
+ chain_type,
+ status,
+ state.selection.current_selected_command_index == Some(index),
+ overflow_indicator,
+ );
+
+ table.add_styled_row(row)
+}
+
+pub fn calculate_max_widths(
+ commands: &[crate::state::CommandEntry],
+ spinner_frame: usize,
+) -> (usize, usize) {
+ let max_chain_width = commands
+ .iter()
+ .map(|cmd| chain_type_to_str(&cmd.get_chain_type()).chars().count())
+ .max()
+ .unwrap_or(0);
+
+ let max_status_width = commands
+ .iter()
+ .map(|cmd| {
+ format_status_text(&cmd.get_status(), spinner_frame)
+ .chars()
+ .count()
+ })
+ .max()
+ .unwrap_or(0);
+
+ (max_chain_width, max_status_width)
+}
+
+pub fn calculate_longest_line(
+ longest_cwd_display: &str,
+ longest_command: usize,
+ max_chain_width: usize,
+ max_status_width: usize,
+) -> usize {
+ let folder_display = format!("{} >", longest_cwd_display);
+ let cell_padding = 3;
+ folder_display.chars().count()
+ + longest_command
+ + max_chain_width
+ + max_status_width
+ + cell_padding
+}
+
+pub fn chain_type_to_str(chain_type: &crate::state::ChainType) -> &'static str {
+ match chain_type {
+ crate::state::ChainType::And => "AND",
+ crate::state::ChainType::Or => "OR",
+ crate::state::ChainType::Then => "THEN",
+ crate::state::ChainType::None => " ",
+ }
+}
+
+pub fn truncate_help_line(help_text: &str, max_width: usize) -> (String, usize) {
+ let text_width = help_text.chars().count();
+
+ if text_width <= max_width {
+ return (help_text.to_string(), text_width);
+ }
+
+ let keybindings = parse_help_keybindings(help_text);
+
+ if let Some(result) = try_shortened_help(&keybindings, max_width) {
+ return result;
+ }
+
+ if let Some(result) = try_keys_spaced(&keybindings, max_width) {
+ return result;
+ }
+
+ if let Some(result) = try_keys_tight(&keybindings, max_width) {
+ return result;
+ }
+
+ truncate_with_ellipsis(
+ &keybindings
+ .iter()
+ .map(|(k, _, _)| *k)
+ .collect::<Vec<_>>()
+ .join("/"),
+ max_width,
+ )
+}
+
+fn parse_help_keybindings(help_text: &str) -> Vec<(&str, &str, &str)> {
+ help_text
+ .split(", ")
+ .filter_map(|part| {
+ part.find(" - ").map(|dash_pos| {
+ let key = &part[..dash_pos];
+ let action = &part[dash_pos + 3..];
+ let first_word = action.split_whitespace().next().unwrap_or(action);
+ (key, first_word, action)
+ })
+ })
+ .collect()
+}
+
+fn try_shortened_help(
+ keybindings: &[(&str, &str, &str)],
+ max_width: usize,
+) -> Option<(String, usize)> {
+ let shortened: Vec<String> = keybindings
+ .iter()
+ .map(|(key, first_word, _)| format!("{} - {}", key, first_word))
+ .collect();
+ let shortened_text = shortened.join(", ");
+ let shortened_width = shortened_text.chars().count();
+
+ if shortened_width <= max_width {
+ Some((shortened_text, shortened_width))
+ } else {
+ None
+ }
+}
+
+fn try_keys_spaced(
+ keybindings: &[(&str, &str, &str)],
+ max_width: usize,
+) -> Option<(String, usize)> {
+ let keys_spaced_text = keybindings
+ .iter()
+ .map(|(key, _, _)| *key)
+ .collect::<Vec<_>>()
+ .join(" / ");
+ let keys_spaced_width = keys_spaced_text.chars().count();
+
+ if keys_spaced_width <= max_width {
+ Some((keys_spaced_text, keys_spaced_width))
+ } else {
+ None
+ }
+}
+
+fn try_keys_tight(keybindings: &[(&str, &str, &str)], max_width: usize) -> Option<(String, usize)> {
+ let keys_tight_text = keybindings
+ .iter()
+ .map(|(key, _, _)| *key)
+ .collect::<Vec<_>>()
+ .join("/");
+ let keys_tight_width = keys_tight_text.chars().count();
+
+ if keys_tight_width <= max_width {
+ Some((keys_tight_text, keys_tight_width))
+ } else {
+ None
+ }
+}
+
+fn truncate_with_ellipsis(text: &str, max_width: usize) -> (String, usize) {
+ let available = max_width.saturating_sub(3);
+ let mut truncated = String::new();
+ let mut current_width = 0;
+
+ for ch in text.chars() {
+ if current_width + 1 <= available {
+ truncated.push(ch);
+ current_width += 1;
+ } else {
+ break;
+ }
+ }
+
+ truncated.push_str("...");
+ let truncated_width = truncated.chars().count();
+ (truncated, truncated_width)
+}
diff --git a/default-plugins/sequence/src/ui/fuzzy_complete.rs b/default-plugins/sequence/src/ui/fuzzy_complete.rs
new file mode 100644
index 00000000..0feae7e4
--- /dev/null
+++ b/default-plugins/sequence/src/ui/fuzzy_complete.rs
@@ -0,0 +1,321 @@
+use fuzzy_matcher::skim::SkimMatcherV2;
+use fuzzy_matcher::FuzzyMatcher;
+use std::collections::BTreeMap;
+use std::fs;
+use std::path::PathBuf;
+
+/// Result of a fuzzy completion operation
+#[derive(Debug, Clone)]
+pub struct CompletionResult {
+ pub completed_text: String,
+ pub score: i64,
+ pub is_directory: bool,
+ pub is_prefix_completion: bool, // True if this is the shortest of multiple matches with same prefix
+}
+
+/// Type of completion
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub enum CompletionType {
+ Command,
+ Path,
+}
+
+/// Fuzzy match executables (commands) from the PATH
+pub fn fuzzy_complete_command(
+ query: &str,
+ executables: &BTreeMap<String, PathBuf>,
+) -> Option<CompletionResult> {
+ if query.is_empty() {
+ return None;
+ }
+
+ let matcher = SkimMatcherV2::default().ignore_case();
+
+ // Collect all matches with their scores
+ let mut all_matches: Vec<(String, i64)> = Vec::new();
+
+ for executable_name in executables.keys() {
+ if let Some((score, _indices)) = matcher.fuzzy_indices(executable_name, query) {
+ all_matches.push((executable_name.clone(), score));
+ }
+ }
+
+ if all_matches.is_empty() {
+ return None;
+ }
+
+ // Sort by score (highest first), then by length (shortest first)
+ all_matches.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.len().cmp(&b.0.len())));
+
+ // Get the best match
+ let (best_name, best_score) = &all_matches[0];
+
+ // Check if there are multiple matches that share the same prefix
+ let is_prefix_completion = check_for_prefix_matches(&all_matches, best_name);
+
+ Some(CompletionResult {
+ completed_text: best_name.clone(),
+ score: *best_score,
+ is_directory: false, // Commands are not directories
+ is_prefix_completion,
+ })
+}
+
+/// Fuzzy match paths (directories and files) relative to the current working directory
+pub fn fuzzy_complete_path(query: &str, cwd: Option<&PathBuf>) -> Option<CompletionResult> {
+ if query.is_empty() {
+ return None;
+ }
+
+ let matcher = SkimMatcherV2::default().ignore_case();
+
+ // Determine the base directory to search in
+ let (base_dir, search_prefix) = if query.starts_with('/') {
+ // Absolute path
+ let parts: Vec<&str> = query.rsplitn(2, '/').collect();
+ if parts.len() == 2 {
+ // Has a directory component
+ let dir = parts[1];
+ let prefix = parts[0];
+ (PathBuf::from(dir), prefix.to_string())
+ } else {
+ // Just "/"
+ (PathBuf::from("/"), String::new())
+ }
+ } else if query.starts_with("~/") {
+ // Home directory
+ let home_dir = std::env::var("HOME").ok()?;
+ let query_without_tilde = &query[2..];
+ let parts: Vec<&str> = query_without_tilde.rsplitn(2, '/').collect();
+ if parts.len() == 2 {
+ let dir = PathBuf::from(&home_dir).join(parts[1]);
+ let prefix = parts[0];
+ (dir, prefix.to_string())
+ } else {
+ // Just "~/" or "~/something"
+ (PathBuf::from(home_dir), query_without_tilde.to_string())
+ }
+ } else {
+ // Relative path
+ let base = cwd?;
+ let parts: Vec<&str> = query.rsplitn(2, '/').collect();
+ if parts.len() == 2 {
+ // Has a directory component
+ let dir = base.join(parts[1]);
+ let prefix = parts[0];
+ (dir, prefix.to_string())
+ } else {
+ // Just a name in the current directory
+ (base.clone(), query.to_string())
+ }
+ };
+
+ // Convert to host path
+ let host_base_dir =
+ PathBuf::from("/host").join(base_dir.strip_prefix("/").unwrap_or(&base_dir));
+
+ // Read directory entries
+ let entries = fs::read_dir(&host_base_dir).ok()?;
+
+ // Collect all matches with their scores and directory status
+ let mut all_matches: Vec<(String, i64, bool)> = Vec::new();
+
+ for entry in entries.flatten() {
+ if let Some(name) = entry.file_name().to_str() {
+ // Skip hidden files unless the query starts with a dot
+ if name.starts_with('.') && !search_prefix.starts_with('.') {
+ continue;
+ }
+
+ if let Some((score, _indices)) = matcher.fuzzy_indices(name, &search_prefix) {
+ // Check if this entry is a directory
+ let is_dir = entry.metadata().ok().map(|m| m.is_dir()).unwrap_or(false);
+ all_matches.push((name.to_string(), score, is_dir));
+ }
+ }
+ }
+
+ if all_matches.is_empty() {
+ return None;
+ }
+
+ // Sort by score (highest first), then by length (shortest first)
+ all_matches.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.len().cmp(&b.0.len())));
+
+ // Get the best match
+ let (name, score, is_directory) = all_matches[0].clone();
+
+ // Check if there are multiple matches that share the same prefix
+ let name_matches: Vec<(String, i64)> = all_matches
+ .iter()
+ .map(|(n, s, _)| (n.clone(), *s))
+ .collect();
+ let is_prefix_completion = check_for_prefix_matches(&name_matches, &name);
+
+ // Reconstruct the full path
+ let completed_text = if query.starts_with('/') {
+ // Absolute path
+ let dir_part = query.rsplitn(2, '/').nth(1).unwrap_or("");
+ if dir_part.is_empty() {
+ format!("/{}", name)
+ } else {
+ format!("{}/{}", dir_part, name)
+ }
+ } else if query.starts_with("~/") {
+ // Home directory
+ let query_without_tilde = &query[2..];
+ let dir_part = query_without_tilde.rsplitn(2, '/').nth(1).unwrap_or("");
+ if dir_part.is_empty() {
+ format!("~/{}", name)
+ } else {
+ format!("~/{}/{}", dir_part, name)
+ }
+ } else {
+ // Relative path
+ let dir_part = query.rsplitn(2, '/').nth(1).unwrap_or("");
+ if dir_part.is_empty() {
+ name.clone()
+ } else {
+ format!("{}/{}", dir_part, name)
+ }
+ };
+
+ Some(CompletionResult {
+ completed_text,
+ score,
+ is_directory,
+ is_prefix_completion,
+ })
+}
+
+/// Check if there are multiple matches that start with the best match's name
+/// Returns true if the best match is a prefix of other matches
+fn check_for_prefix_matches(matches: &[(String, i64)], best_match: &str) -> bool {
+ if matches.len() <= 1 {
+ return false;
+ }
+
+ // Count how many matches start with the best_match text
+ let prefix_count = matches
+ .iter()
+ .filter(|(name, _)| name.starts_with(best_match) && name != best_match)
+ .count();
+
+ prefix_count > 0
+}
+
+/// Perform fuzzy completion on the current input
+/// Returns the best completion (command or path) with appropriate suffix (space or slash)
+pub fn fuzzy_complete(
+ input: &str,
+ executables: &BTreeMap<String, PathBuf>,
+ cwd: Option<&PathBuf>,
+) -> Option<String> {
+ // If input is empty, nothing to complete
+ if input.is_empty() {
+ return None;
+ }
+
+ // Extract the last word/token to complete
+ // For simplicity, we'll complete the entire input if it's a single word,
+ // or the last space-separated token if there are multiple words
+ let tokens: Vec<&str> = input.split_whitespace().collect();
+ let to_complete = if tokens.is_empty() {
+ input
+ } else {
+ tokens.last().unwrap()
+ };
+
+ // Get both command and path completions
+ let command_result = fuzzy_complete_command(to_complete, executables);
+ let path_result = fuzzy_complete_path(to_complete, cwd);
+
+ // Choose the better match based on score
+ match (command_result, path_result) {
+ (Some(cmd), Some(path)) => {
+ // Both matched, use the higher score
+ let (result, completion_type, is_dir, is_prefix) = if cmd.score >= path.score {
+ (
+ cmd.completed_text,
+ CompletionType::Command,
+ false,
+ cmd.is_prefix_completion,
+ )
+ } else {
+ (
+ path.completed_text,
+ CompletionType::Path,
+ path.is_directory,
+ path.is_prefix_completion,
+ )
+ };
+
+ // Add appropriate suffix (skip slash if this is a prefix completion)
+ let suffix = get_completion_suffix(completion_type, is_dir, is_prefix);
+ let completed = format!("{}{}", result, suffix);
+
+ // Replace the last token with the completion
+ Some(replace_last_token(input, &completed))
+ },
+ (Some(cmd), None) => {
+ // Command completion - add space (unless it's a prefix completion)
+ let suffix = if cmd.is_prefix_completion { "" } else { " " };
+ let completed = format!("{}{}", cmd.completed_text, suffix);
+ Some(replace_last_token(input, &completed))
+ },
+ (None, Some(path)) => {
+ // Path completion - add slash if directory (unless it's a prefix completion)
+ let suffix = if path.is_prefix_completion {
+ ""
+ } else if path.is_directory {
+ "/"
+ } else {
+ ""
+ };
+ let completed = format!("{}{}", path.completed_text, suffix);
+ Some(replace_last_token(input, &completed))
+ },
+ (None, None) => None,
+ }
+}
+
+/// Get the appropriate suffix for a completion
+fn get_completion_suffix(
+ completion_type: CompletionType,
+ is_directory: bool,
+ is_prefix_completion: bool,
+) -> &'static str {
+ // No suffix for prefix completions
+ if is_prefix_completion {
+ return "";
+ }
+
+ match completion_type {
+ CompletionType::Command => " ",
+ CompletionType::Path => {
+ if is_directory {
+ "/"
+ } else {
+ ""
+ }
+ },
+ }
+}
+
+/// Replace the last whitespace-separated token in the input with the completion
+fn replace_last_token(input: &str, completion: &str) -> String {
+ let tokens: Vec<&str> = input.split_whitespace().collect();
+ if tokens.is_empty() {
+ return completion.to_string();
+ }
+
+ if tokens.len() == 1 {
+ completion.to_string()
+ } else {
+ // Join all tokens except the last one, then add the completion
+ let mut result = tokens[..tokens.len() - 1].join(" ");
+ result.push(' ');
+ result.push_str(completion);
+ result
+ }
+}
diff --git a/default-plugins/sequence/src/ui/layout_calculations.rs b/default-plugins/sequence/src/ui/layout_calculations.rs
new file mode 100644
index 00000000..911dbc62
--- /dev/null
+++ b/default-plugins/sequence/src/ui/layout_calculations.rs
@@ -0,0 +1,28 @@
+pub fn calculate_viewport(
+ total_commands: usize,
+ max_visible: usize,
+ selected_index: Option<usize>,
+ running_index: usize,
+) -> (usize, usize, usize, usize) {
+ if total_commands <= max_visible {
+ return (0, total_commands, 0, 0);
+ }
+
+ let focus_index = selected_index.unwrap_or(running_index);
+ let half_visible = max_visible / 2;
+
+ let offset = if focus_index < half_visible {
+ 0
+ } else if focus_index >= total_commands.saturating_sub(half_visible) {
+ total_commands.saturating_sub(max_visible)
+ } else {
+ focus_index.saturating_sub(half_visible)
+ };
+
+ let offset = offset.min(total_commands.saturating_sub(max_visible));
+ let visible_count = max_visible.min(total_commands.saturating_sub(offset));
+ let hidden_above = offset;
+ let hidden_below = total_commands.saturating_sub(offset + visible_count);
+
+ (offset, visible_count, hidden_above, hidden_below)
+}
diff --git a/default-plugins/sequence/src/ui/mod.rs b/default-plugins/sequence/src/ui/mod.rs
new file mode 100644
index 00000000..6d1ec303
--- /dev/null
+++ b/default-plugins/sequence/src/ui/mod.rs
@@ -0,0 +1,5 @@
+pub mod components;
+pub mod fuzzy_complete;
+pub mod layout_calculations;
+pub mod text_input;
+pub mod truncation;
diff --git a/default-plugins/sequence/src/ui/text_input.rs b/default-plugins/sequence/src/ui/text_input.rs
new file mode 100644
index 00000000..2694e17a
--- /dev/null
+++ b/default-plugins/sequence/src/ui/text_input.rs
@@ -0,0 +1,997 @@
+use zellij_tile::prelude::*;
+
+const MAX_UNDO_STACK_SIZE: usize = 100;
+
+/// Action returned by TextInput after handling a key event
+#[derive(Debug, Clone, PartialEq)]
+#[cfg_attr(test, derive(Eq))]
+pub enum InputAction {
+ /// Continue editing
+ Continue,
+ /// User pressed Enter to submit
+ Submit,
+ /// User pressed Esc to cancel
+ Cancel,
+ /// User pressed Tab to request completion
+ Complete,
+ /// Key was not handled by the input
+ NoAction,
+}
+
+/// A reusable text input component with cursor support and standard editing keybindings
+#[derive(Debug, Clone)]
+pub struct TextInput {
+ buffer: String,
+ cursor_position: usize, // Character position (0-based), NOT byte position
+ undo_stack: Vec<(String, usize)>, // (buffer, cursor) snapshots
+ redo_stack: Vec<(String, usize)>,
+ last_edit_was_insert: bool, // For coalescing consecutive inserts
+}
+
+impl TextInput {
+ /// Create a new TextInput with the given initial text
+ /// Cursor is positioned at the end of the text
+ pub fn new(initial_text: String) -> Self {
+ let cursor_position = initial_text.chars().count();
+ Self {
+ buffer: initial_text,
+ cursor_position,
+ undo_stack: Vec::new(),
+ redo_stack: Vec::new(),
+ last_edit_was_insert: false,
+ }
+ }
+
+ /// Create an empty TextInput
+ pub fn empty() -> Self {
+ Self::new(String::new())
+ }
+
+ /// Get the current text
+ pub fn get_text(&self) -> &str {
+ &self.buffer
+ }
+
+ /// Get the cursor position (in characters, not bytes)
+ pub fn get_cursor_position(&self) -> usize {
+ self.cursor_position
+ }
+
+ /// Check if the input is empty
+ pub fn is_empty(&self) -> bool {
+ self.buffer.is_empty()
+ }
+
+ /// Get a shorthand for cursor_position
+ pub fn cursor_position(&self) -> usize {
+ self.cursor_position
+ }
+
+ /// Get mutable access to the underlying buffer for direct manipulation
+ pub fn get_text_mut(&mut self) -> &mut String {
+ &mut self.buffer
+ }
+
+ /// Set the text and move cursor to the end
+ pub fn set_text(&mut self, text: String) {
+ self.break_coalescing();
+ self.save_undo_state();
+ self.cursor_position = text.chars().count();
+ self.buffer = text;
+ }
+
+ /// Set cursor position (clamped to text length)
+ pub fn set_cursor_position(&mut self, pos: usize) {
+ let text_len = self.buffer.chars().count();
+ self.cursor_position = pos.min(text_len);
+ }
+
+ /// Clear all text and reset cursor
+ pub fn clear(&mut self) {
+ self.break_coalescing();
+ self.save_undo_state();
+ self.buffer.clear();
+ self.cursor_position = 0;
+ }
+
+ /// Insert a character at the current cursor position
+ pub fn insert_char(&mut self, c: char) {
+ self.save_undo_state_unless_coalescing();
+ // Convert cursor position (char index) to byte index
+ let byte_index = self.char_index_to_byte_index(self.cursor_position);
+ self.buffer.insert(byte_index, c);
+ self.cursor_position += 1;
+ }
+
+ /// Delete the character before the cursor (backspace)
+ pub fn backspace(&mut self) {
+ if self.cursor_position > 0 {
+ self.break_coalescing();
+ self.save_undo_state();
+ self.cursor_position -= 1;
+ let byte_index = self.char_index_to_byte_index(self.cursor_position);
+ self.buffer.remove(byte_index);
+ }
+ }
+
+ /// Delete the character at the cursor position (delete key)
+ pub fn delete(&mut self) {
+ let len = self.buffer.chars().count();
+ if self.cursor_position < len {
+ self.break_coalescing();
+ self.save_undo_state();
+ let byte_index = self.char_index_to_byte_index(self.cursor_position);
+ self.buffer.remove(byte_index);
+ }
+ }
+
+ /// Delete the word before the cursor (Ctrl/Alt + Backspace)
+ pub fn delete_word_backward(&mut self) {
+ if self.cursor_position == 0 {
+ return;
+ }
+
+ self.break_coalescing();
+ self.save_undo_state();
+
+ let old_position = self.cursor_position;
+ self.move_word_left();
+ let new_position = self.cursor_position;
+
+ // Delete from new position to old position
+ let start_byte = self.char_index_to_byte_index(new_position);
+ let end_byte = self.char_index_to_byte_index(old_position);
+ self.buffer.drain(start_byte..end_byte);
+ }
+
+ /// Delete the word after the cursor (Ctrl/Alt + Delete)
+ pub fn delete_word_forward(&mut self) {
+ let chars: Vec<char> = self.buffer.chars().collect();
+ let len = chars.len();
+
+ if self.cursor_position >= len {
+ return;
+ }
+
+ self.break_coalescing();
+ self.save_undo_state();
+
+ let start_position = self.cursor_position;
+ let mut end_position = start_position;
+
+ // Skip the current word
+ while end_position < len && !chars[end_position].is_whitespace() {
+ end_position += 1;
+ }
+
+ // Skip any whitespace after the word
+ while end_position < len && chars[end_position].is_whitespace() {
+ end_position += 1;
+ }
+
+ // Delete from start to end position
+ let start_byte = self.char_index_to_byte_index(start_position);
+ let end_byte = self.char_index_to_byte_index(end_position);
+ self.buffer.drain(start_byte..end_byte);
+ }
+
+ /// Move cursor one position to the left
+ pub fn move_left(&mut self) {
+ if self.cursor_position > 0 {
+ self.break_coalescing();
+ self.cursor_position -= 1;
+ }
+ }
+
+ /// Move cursor one position to the right
+ pub fn move_right(&mut self) {
+ let len = self.buffer.chars().count();
+ if self.cursor_position < len {
+ self.break_coalescing();
+ self.cursor_position += 1;
+ }
+ }
+
+ /// Move cursor to the start of the text (Ctrl-A / Home)
+ pub fn move_to_start(&mut self) {
+ self.break_coalescing();
+ self.cursor_position = 0;
+ }
+
+ /// Move cursor to the end of the text (Ctrl-E / End)
+ pub fn move_to_end(&mut self) {
+ self.break_coalescing();
+ self.cursor_position = self.buffer.chars().count();
+ }
+
+ /// Move cursor to the start of the previous word (Ctrl/Alt + Left)
+ pub fn move_word_left(&mut self) {
+ if self.cursor_position == 0 {
+ return;
+ }
+
+ self.break_coalescing();
+
+ let chars: Vec<char> = self.buffer.chars().collect();
+ let mut pos = self.cursor_position;
+
+ // Skip any whitespace immediately to the left
+ while pos > 0 && chars[pos - 1].is_whitespace() {
+ pos -= 1;
+ }
+
+ // Skip the word characters
+ while pos > 0 && !chars[pos - 1].is_whitespace() {
+ pos -= 1;
+ }
+
+ self.cursor_position = pos;
+ }
+
+ /// Move cursor to the start of the next word (Ctrl/Alt + Right)
+ pub fn move_word_right(&mut self) {
+ let chars: Vec<char> = self.buffer.chars().collect();
+ let len = chars.len();
+
+ if self.cursor_position >= len {
+ return;
+ }
+
+ self.break_coalescing();
+
+ let mut pos = self.cursor_position;
+
+ // Skip the current word
+ while pos < len && !chars[pos].is_whitespace() {
+ pos += 1;
+ }
+
+ // Skip any whitespace
+ while pos < len && chars[pos].is_whitespace() {
+ pos += 1;
+ }
+
+ self.cursor_position = pos;
+ }
+
+ /// Handle a key event and return the appropriate action
+ /// This is the main entry point for key handling
+ pub fn handle_key(&mut self, key: KeyWithModifier) -> InputAction {
+ // Check for Ctrl modifiers
+ if key.has_modifiers(&[KeyModifier::Ctrl]) {
+ match key.bare_key {
+ BareKey::Char('a') => {
+ self.move_to_start();
+ return InputAction::Continue;
+ },
+ BareKey::Char('e') => {
+ self.move_to_end();
+ return InputAction::Continue;
+ },
+ BareKey::Char('c') => {
+ // Ctrl-C clears the prompt
+ return InputAction::Cancel;
+ },
+ BareKey::Char('z') => {
+ // Ctrl-Z: Undo
+ self.undo();
+ return InputAction::Continue;
+ },
+ BareKey::Char('y') => {
+ // Ctrl-Y: Redo
+ self.redo();
+ return InputAction::Continue;
+ },
+ BareKey::Left => {
+ self.move_word_left();
+ return InputAction::Continue;
+ },
+ BareKey::Right => {
+ self.move_word_right();
+ return InputAction::Continue;
+ },
+ BareKey::Backspace => {
+ self.delete_word_backward();
+ return InputAction::Continue;
+ },
+ BareKey::Delete => {
+ self.delete_word_forward();
+ return InputAction::Continue;
+ },
+ _ => {},
+ }
+ }
+
+ // Check for Ctrl+Shift modifiers (alternative redo: Ctrl+Shift+Z)
+ if key.has_modifiers(&[KeyModifier::Ctrl, KeyModifier::Shift]) {
+ match key.bare_key {
+ BareKey::Char('Z') => {
+ // Ctrl-Shift-Z: Redo (alternative)
+ self.redo();
+ return InputAction::Continue;
+ },
+ _ => {},
+ }
+ }
+
+ // Check for Alt modifiers
+ if key.has_modifiers(&[KeyModifier::Alt]) {
+ match key.bare_key {
+ BareKey::Left => {
+ self.move_word_left();
+ return InputAction::Continue;
+ },
+ BareKey::Right => {
+ self.move_word_right();
+ return InputAction::Continue;
+ },
+ BareKey::Backspace => {
+ self.delete_word_backward();
+ return InputAction::Continue;
+ },
+ BareKey::Delete => {
+ self.delete_word_forward();
+ return InputAction::Continue;
+ },
+ _ => {},
+ }
+ }
+
+ // Handle bare keys (no modifiers)
+ match key.bare_key {
+ BareKey::Enter => InputAction::Submit,
+ BareKey::Esc => InputAction::Cancel,
+ BareKey::Tab => InputAction::Complete,
+ BareKey::Backspace => {
+ self.backspace();
+ InputAction::Continue
+ },
+ BareKey::Delete => {
+ self.delete();
+ InputAction::Continue
+ },
+ BareKey::Left => {
+ self.move_left();
+ InputAction::Continue
+ },
+ BareKey::Right => {
+ self.move_right();
+ InputAction::Continue
+ },
+ BareKey::Home => {
+ self.move_to_start();
+ InputAction::Continue
+ },
+ BareKey::End => {
+ self.move_to_end();
+ InputAction::Continue
+ },
+ BareKey::Char(c) => {
+ self.insert_char(c);
+ InputAction::Continue
+ },
+ _ => InputAction::NoAction,
+ }
+ }
+
+ /// Helper: Convert character index to byte index
+ fn char_index_to_byte_index(&self, char_index: usize) -> usize {
+ self.buffer
+ .char_indices()
+ .nth(char_index)
+ .map(|(byte_idx, _)| byte_idx)
+ .unwrap_or(self.buffer.len())
+ }
+
+ /// Save current state to undo stack before making changes
+ fn save_undo_state(&mut self) {
+ if self.undo_stack.len() >= MAX_UNDO_STACK_SIZE {
+ self.undo_stack.remove(0);
+ }
+ self.undo_stack
+ .push((self.buffer.clone(), self.cursor_position));
+ self.redo_stack.clear();
+ }
+
+ /// Save state only if not coalescing with previous insert
+ fn save_undo_state_unless_coalescing(&mut self) {
+ if !self.last_edit_was_insert {
+ self.save_undo_state();
+ }
+ self.last_edit_was_insert = true;
+ }
+
+ /// Mark that a non-insert edit occurred (breaks coalescing)
+ fn break_coalescing(&mut self) {
+ self.last_edit_was_insert = false;
+ }
+
+ /// Undo last change
+ pub fn undo(&mut self) -> bool {
+ if let Some((buffer, cursor)) = self.undo_stack.pop() {
+ self.redo_stack
+ .push((self.buffer.clone(), self.cursor_position));
+ self.buffer = buffer;
+ self.cursor_position = cursor;
+ self.break_coalescing();
+ true
+ } else {
+ false
+ }
+ }
+
+ /// Redo last undone change
+ pub fn redo(&mut self) -> bool {
+ if let Some((buffer, cursor)) = self.redo_stack.pop() {
+ self.undo_stack
+ .push((self.buffer.clone(), self.cursor_position));
+ self.buffer = buffer;
+ self.cursor_position = cursor;
+ self.break_coalescing();
+ true
+ } else {
+ false
+ }
+ }
+
+ /// Check if undo is available
+ pub fn can_undo(&self) -> bool {
+ !self.undo_stack.is_empty()
+ }
+
+ /// Check if redo is available
+ pub fn can_redo(&self) -> bool {
+ !self.redo_stack.is_empty()
+ }
+
+ pub fn drain_text(&mut self) -> String {
+ self.cursor_position = 0;
+ self.buffer.drain(..).collect()
+ }
+}
+
+// run with:
+// cargo test --lib --target x86_64-unknown-linux-gnu
+//
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_new_and_empty() {
+ let input = TextInput::new("hello".to_string());
+ assert_eq!(input.get_text(), "hello");
+ assert_eq!(input.get_cursor_position(), 5);
+
+ let empty = TextInput::empty();
+ assert_eq!(empty.get_text(), "");
+ assert_eq!(empty.get_cursor_position(), 0);
+ }
+
+ #[test]
+ fn test_insert_char() {
+ let mut input = TextInput::new("helo".to_string());
+ input.cursor_position = 3; // Position after "hel"
+ input.insert_char('l');
+ assert_eq!(input.get_text(), "hello");
+ assert_eq!(input.get_cursor_position(), 4);
+ }
+
+ #[test]
+ fn test_backspace() {
+ let mut input = TextInput::new("hello".to_string());
+ input.backspace();
+ assert_eq!(input.get_text(), "hell");
+ assert_eq!(input.get_cursor_position(), 4);
+
+ // Backspace at start does nothing
+ input.cursor_position = 0;
+ input.backspace();
+ assert_eq!(input.get_text(), "hell");
+ assert_eq!(input.get_cursor_position(), 0);
+ }
+
+ #[test]
+ fn test_delete() {
+ let mut input = TextInput::new("hello".to_string());
+ input.cursor_position = 0;
+ input.delete();
+ assert_eq!(input.get_text(), "ello");
+ assert_eq!(input.get_cursor_position(), 0);
+
+ // Delete at end does nothing
+ input.move_to_end();
+ input.delete();
+ assert_eq!(input.get_text(), "ello");
+ }
+
+ #[test]
+ fn test_cursor_movement() {
+ let mut input = TextInput::new("hello".to_string());
+ assert_eq!(input.get_cursor_position(), 5);
+
+ input.move_left();
+ assert_eq!(input.get_cursor_position(), 4);
+
+ input.move_right();
+ assert_eq!(input.get_cursor_position(), 5);
+
+ input.move_to_start();
+ assert_eq!(input.get_cursor_position(), 0);
+
+ input.move_to_end();
+ assert_eq!(input.get_cursor_position(), 5);
+ }
+
+ #[test]
+ fn test_unicode_support() {
+ let mut input = TextInput::new("hello πŸ¦€ world".to_string());
+ assert_eq!(input.get_cursor_position(), 13); // 13 characters
+
+ input.cursor_position = 6; // After "hello "
+ input.insert_char('🐱');
+ assert_eq!(input.get_text(), "hello πŸ±πŸ¦€ world");
+ }
+
+ #[test]
+ fn test_word_jump_right() {
+ let mut input = TextInput::new("hello world foo bar".to_string());
+ input.cursor_position = 0;
+
+ // Jump from start to "world"
+ input.move_word_right();
+ assert_eq!(input.get_cursor_position(), 6); // After "hello "
+
+ // Jump to "foo"
+ input.move_word_right();
+ assert_eq!(input.get_cursor_position(), 12); // After "world "
+
+ // Jump to "bar"
+ input.move_word_right();
+ assert_eq!(input.get_cursor_position(), 16); // After "foo "
+
+ // Jump to end
+ input.move_word_right();
+ assert_eq!(input.get_cursor_position(), 19); // At end
+ }
+
+ #[test]
+ fn test_word_jump_left() {
+ let mut input = TextInput::new("hello world foo bar".to_string());
+ input.move_to_end();
+ assert_eq!(input.get_cursor_position(), 19);
+
+ // Jump back to "bar"
+ input.move_word_left();
+ assert_eq!(input.get_cursor_position(), 16); // Start of "bar"
+
+ // Jump back to "foo"
+ input.move_word_left();
+ assert_eq!(input.get_cursor_position(), 12); // Start of "foo"
+
+ // Jump back to "world"
+ input.move_word_left();
+ assert_eq!(input.get_cursor_position(), 6); // Start of "world"
+
+ // Jump back to "hello"
+ input.move_word_left();
+ assert_eq!(input.get_cursor_position(), 0); // Start of "hello"
+ }
+
+ #[test]
+ fn test_word_jump_with_multiple_spaces() {
+ let mut input = TextInput::new("hello world".to_string());
+ input.cursor_position = 0;
+
+ // Jump over multiple spaces
+ input.move_word_right();
+ assert_eq!(input.get_cursor_position(), 8); // After "hello ", at start of "world"
+
+ // Jump back should skip spaces
+ input.move_word_left();
+ assert_eq!(input.get_cursor_position(), 0); // Back to start of "hello"
+ }
+
+ #[test]
+ fn test_word_jump_boundaries() {
+ let mut input = TextInput::new("test".to_string());
+
+ // At start - word left does nothing
+ input.cursor_position = 0;
+ input.move_word_left();
+ assert_eq!(input.get_cursor_position(), 0);
+
+ // At end - word right does nothing
+ input.move_to_end();
+ let end_pos = input.get_cursor_position();
+ input.move_word_right();
+ assert_eq!(input.get_cursor_position(), end_pos);
+ }
+
+ #[test]
+ fn test_up_down_arrows() {
+ let mut input = TextInput::new("hello world".to_string());
+
+ // Start in the middle
+ input.cursor_position = 5;
+ assert_eq!(input.get_cursor_position(), 5);
+
+ // Up arrow should go to start
+ input.move_to_start();
+ assert_eq!(input.get_cursor_position(), 0);
+
+ // Move back to middle
+ input.cursor_position = 5;
+
+ // Down arrow should go to end
+ input.move_to_end();
+ assert_eq!(input.get_cursor_position(), 11);
+ }
+
+ #[test]
+ fn test_delete_word_backward() {
+ let mut input = TextInput::new("hello world foo".to_string());
+
+ // Delete "foo" from end
+ input.move_to_end();
+ input.delete_word_backward();
+ assert_eq!(input.get_text(), "hello world ");
+ assert_eq!(input.get_cursor_position(), 12);
+
+ // Delete "world "
+ input.delete_word_backward();
+ assert_eq!(input.get_text(), "hello ");
+ assert_eq!(input.get_cursor_position(), 6);
+
+ // Delete "hello "
+ input.delete_word_backward();
+ assert_eq!(input.get_text(), "");
+ assert_eq!(input.get_cursor_position(), 0);
+
+ // Delete on empty buffer does nothing
+ input.delete_word_backward();
+ assert_eq!(input.get_text(), "");
+ assert_eq!(input.get_cursor_position(), 0);
+ }
+
+ #[test]
+ fn test_delete_word_backward_middle() {
+ let mut input = TextInput::new("hello world foo".to_string());
+
+ // Position in middle of "world"
+ input.cursor_position = 8; // After "hello wo"
+ input.delete_word_backward();
+ assert_eq!(input.get_text(), "hello rld foo");
+ assert_eq!(input.get_cursor_position(), 6); // After "hello "
+ }
+
+ #[test]
+ fn test_delete_word_forward() {
+ let mut input = TextInput::new("hello world foo".to_string());
+
+ // Delete "hello " from start
+ input.cursor_position = 0;
+ input.delete_word_forward();
+ assert_eq!(input.get_text(), "world foo");
+ assert_eq!(input.get_cursor_position(), 0);
+
+ // Delete "world "
+ input.delete_word_forward();
+ assert_eq!(input.get_text(), "foo");
+ assert_eq!(input.get_cursor_position(), 0);
+
+ // Delete "foo"
+ input.delete_word_forward();
+ assert_eq!(input.get_text(), "");
+ assert_eq!(input.get_cursor_position(), 0);
+
+ // Delete on empty buffer does nothing
+ input.delete_word_forward();
+ assert_eq!(input.get_text(), "");
+ assert_eq!(input.get_cursor_position(), 0);
+ }
+
+ #[test]
+ fn test_delete_word_forward_middle() {
+ let mut input = TextInput::new("hello world foo".to_string());
+
+ // Position in middle of "world"
+ input.cursor_position = 8; // After "hello wo"
+ input.delete_word_forward();
+ assert_eq!(input.get_text(), "hello wofoo");
+ assert_eq!(input.get_cursor_position(), 8); // Same position, text deleted forward
+ }
+
+ #[test]
+ fn test_delete_word_with_multiple_spaces() {
+ let mut input = TextInput::new("hello world".to_string());
+
+ // Delete forward includes trailing spaces
+ input.cursor_position = 0;
+ input.delete_word_forward();
+ assert_eq!(input.get_text(), "world");
+ assert_eq!(input.get_cursor_position(), 0);
+ }
+
+ #[test]
+ fn test_undo_redo_basic() {
+ let mut input = TextInput::empty();
+
+ // Type "hello"
+ input.insert_char('h');
+ input.insert_char('e');
+ input.insert_char('l');
+ input.insert_char('l');
+ input.insert_char('o');
+ assert_eq!(input.get_text(), "hello");
+
+ // Undo should remove all characters (coalesced into one undo entry)
+ assert!(input.can_undo());
+ assert!(input.undo());
+ assert_eq!(input.get_text(), "");
+ assert_eq!(input.get_cursor_position(), 0);
+
+ // Redo should restore "hello"
+ assert!(input.can_redo());
+ assert!(input.redo());
+ assert_eq!(input.get_text(), "hello");
+ assert_eq!(input.get_cursor_position(), 5);
+ }
+
+ #[test]
+ fn test_undo_coalescing_breaks_on_cursor_move() {
+ let mut input = TextInput::empty();
+
+ // Type "he"
+ input.insert_char('h');
+ input.insert_char('e');
+
+ // Move cursor to start (breaks coalescing)
+ input.move_to_start();
+
+ // Type "llo"
+ input.insert_char('l');
+ input.insert_char('l');
+ input.insert_char('o');
+
+ assert_eq!(input.get_text(), "llohe");
+
+ // First undo removes "llo" (second coalesced group)
+ input.undo();
+ assert_eq!(input.get_text(), "he");
+
+ // Second undo removes "he" (first coalesced group)
+ input.undo();
+ assert_eq!(input.get_text(), "");
+ }
+
+ #[test]
+ fn test_undo_backspace() {
+ let mut input = TextInput::new("hello".to_string());
+
+ // Backspace once
+ input.backspace();
+ assert_eq!(input.get_text(), "hell");
+
+ // Undo should restore "hello"
+ input.undo();
+ assert_eq!(input.get_text(), "hello");
+ assert_eq!(input.get_cursor_position(), 5);
+ }
+
+ #[test]
+ fn test_undo_delete() {
+ let mut input = TextInput::new("hello".to_string());
+ input.cursor_position = 0;
+
+ // Delete first character
+ input.delete();
+ assert_eq!(input.get_text(), "ello");
+
+ // Undo should restore "hello"
+ input.undo();
+ assert_eq!(input.get_text(), "hello");
+ assert_eq!(input.get_cursor_position(), 0);
+ }
+
+ #[test]
+ fn test_undo_word_delete() {
+ let mut input = TextInput::new("hello world".to_string());
+
+ // Delete "world" backward
+ input.delete_word_backward();
+ assert_eq!(input.get_text(), "hello ");
+
+ // Undo should restore "hello world"
+ input.undo();
+ assert_eq!(input.get_text(), "hello world");
+ assert_eq!(input.get_cursor_position(), 11);
+ }
+
+ #[test]
+ fn test_undo_clear() {
+ let mut input = TextInput::new("hello world".to_string());
+
+ // Clear the buffer
+ input.clear();
+ assert_eq!(input.get_text(), "");
+
+ // Undo should restore the text
+ input.undo();
+ assert_eq!(input.get_text(), "hello world");
+ }
+
+ #[test]
+ fn test_undo_set_text() {
+ let mut input = TextInput::new("hello".to_string());
+
+ // Replace with new text
+ input.set_text("goodbye".to_string());
+ assert_eq!(input.get_text(), "goodbye");
+
+ // Undo should restore "hello"
+ input.undo();
+ assert_eq!(input.get_text(), "hello");
+ }
+
+ #[test]
+ fn test_redo_clears_on_new_edit() {
+ let mut input = TextInput::empty();
+
+ // Type "hello"
+ input.insert_char('h');
+ input.insert_char('e');
+ input.insert_char('l');
+ input.insert_char('l');
+ input.insert_char('o');
+
+ // Undo
+ input.undo();
+ assert_eq!(input.get_text(), "");
+ assert!(input.can_redo());
+
+ // Make a new edit (should clear redo stack)
+ input.insert_char('x');
+ assert!(!input.can_redo());
+ }
+
+ #[test]
+ fn test_multiple_undo_redo() {
+ let mut input = TextInput::empty();
+
+ // First edit: type "hello"
+ for c in "hello".chars() {
+ input.insert_char(c);
+ }
+
+ // Break coalescing
+ input.move_left();
+
+ // Second edit: type "world"
+ for c in "world".chars() {
+ input.insert_char(c);
+ }
+
+ assert_eq!(input.get_text(), "hellworldo");
+
+ // Undo "world"
+ input.undo();
+ assert_eq!(input.get_text(), "hello");
+
+ // Undo "hello"
+ input.undo();
+ assert_eq!(input.get_text(), "");
+
+ // Redo "hello"
+ input.redo();
+ assert_eq!(input.get_text(), "hello");
+
+ // Redo "world"
+ input.redo();
+ assert_eq!(input.get_text(), "hellworldo");
+ }
+
+ #[test]
+ fn test_undo_stack_limit() {
+ let mut input = TextInput::empty();
+
+ // Perform 102 separate edits (breaking coalescing each time)
+ for _i in 0..102 {
+ input.backspace(); // Break coalescing
+ input.insert_char('x');
+ }
+
+ // Should have at most 100 undo entries
+ let mut undo_count = 0;
+ while input.undo() {
+ undo_count += 1;
+ }
+
+ // We should have 100 undo entries (the stack limit)
+ // Plus the final state change from the last coalescing break
+ assert!(
+ undo_count <= 100,
+ "Undo count should be at most 100, got {}",
+ undo_count
+ );
+ }
+
+ #[test]
+ fn test_undo_redo_empty_stack() {
+ let mut input = TextInput::empty();
+
+ // Undo on empty stack should return false
+ assert!(!input.can_undo());
+ assert!(!input.undo());
+
+ // Redo on empty stack should return false
+ assert!(!input.can_redo());
+ assert!(!input.redo());
+ }
+
+ #[test]
+ fn test_undo_restores_cursor_position() {
+ let mut input = TextInput::new("hello world".to_string());
+
+ // Move cursor to position 5 (before "world")
+ input.cursor_position = 5;
+
+ // Insert a space
+ input.insert_char(' ');
+ assert_eq!(input.get_text(), "hello world");
+ assert_eq!(input.get_cursor_position(), 6);
+
+ // Undo should restore both text and cursor position
+ input.undo();
+ assert_eq!(input.get_text(), "hello world");
+ assert_eq!(input.get_cursor_position(), 5);
+ }
+
+ #[test]
+ fn test_coalescing_consecutive_inserts() {
+ let mut input = TextInput::empty();
+
+ // Type several characters
+ input.insert_char('a');
+ input.insert_char('b');
+ input.insert_char('c');
+
+ assert_eq!(input.get_text(), "abc");
+
+ // Single undo should remove all three (they were coalesced)
+ input.undo();
+ assert_eq!(input.get_text(), "");
+
+ // No more undo available
+ assert!(!input.can_undo());
+ }
+
+ #[test]
+ fn test_backspace_breaks_coalescing() {
+ let mut input = TextInput::empty();
+
+ // Type "ab"
+ input.insert_char('a');
+ input.insert_char('b');
+
+ // Backspace
+ input.backspace();
+ assert_eq!(input.get_text(), "a");
+
+ // Type "c"
+ input.insert_char('c');
+ assert_eq!(input.get_text(), "ac");
+
+ // Undo should remove just "c"
+ input.undo();
+ assert_eq!(input.get_text(), "a");
+
+ // Undo should remove backspace operation
+ input.undo();
+ assert_eq!(input.get_text(), "ab");
+
+ // Undo should remove "ab"
+ input.undo();
+ assert_eq!(input.get_text(), "");
+ }
+}
diff --git a/default-plugins/sequence/src/ui/truncation.rs b/default-plugins/sequence/src/ui/truncation.rs
new file mode 100644
index 00000000..0c4073a3
--- /dev/null
+++ b/default-plugins/sequence/src/ui/truncation.rs
@@ -0,0 +1,373 @@
+use unicode_width::UnicodeWidthStr;
+
+const BASE_SEPARATOR_WIDTH: usize = 2;
+
+pub fn calculate_available_cmd_width(
+ cols: usize,
+ folder_width: usize,
+ overflow_indicator: Option<&String>,
+ chain_width: usize,
+ status_width: usize,
+) -> usize {
+ let mut separator_width = BASE_SEPARATOR_WIDTH;
+ if overflow_indicator.is_some() {
+ separator_width += 1;
+ }
+ if status_width > 0 {
+ separator_width += 1;
+ }
+
+ let overflow_width = overflow_indicator.map(|s| s.chars().count()).unwrap_or(0);
+
+ cols.saturating_sub(folder_width)
+ .saturating_sub(chain_width)
+ .saturating_sub(status_width)
+ .saturating_sub(separator_width)
+ .saturating_sub(overflow_width)
+ .saturating_sub(2)
+ .max(1)
+}
+
+pub fn truncate_middle(
+ text: &str,
+ max_width: usize,
+ cursor_position: Option<usize>,
+) -> (String, Option<usize>) {
+ let text_width = text.width();
+
+ if text_width <= max_width {
+ return (text.to_string(), cursor_position);
+ }
+
+ if max_width < 5 {
+ return truncate_minimal(text, max_width, cursor_position);
+ }
+
+ if let Some(cursor_char_idx) = cursor_position {
+ return truncate_with_cursor(text, max_width, cursor_char_idx);
+ }
+
+ truncate_no_cursor(text, max_width)
+}
+
+fn truncate_minimal(
+ text: &str,
+ max_width: usize,
+ cursor_position: Option<usize>,
+) -> (String, Option<usize>) {
+ let mut result = String::new();
+ let mut current_width = 0;
+ let mut new_cursor_pos = None;
+ let mut char_pos = 0;
+
+ for ch in text.chars() {
+ let ch_width = ch.to_string().width();
+ if current_width + ch_width <= max_width {
+ result.push(ch);
+ if let Some(cursor_char) = cursor_position {
+ if char_pos == cursor_char {
+ new_cursor_pos = Some(char_pos);
+ }
+ }
+ char_pos += 1;
+ current_width += ch_width;
+ } else {
+ break;
+ }
+ }
+ (result, new_cursor_pos)
+}
+
+fn truncate_no_cursor(text: &str, max_width: usize) -> (String, Option<usize>) {
+ let available_for_text = max_width.saturating_sub(3);
+ let left_width = available_for_text / 2;
+ let right_width = available_for_text - left_width;
+
+ let mut left_part = String::new();
+ let mut current_width = 0;
+ for ch in text.chars() {
+ let ch_width = ch.to_string().width();
+ if current_width + ch_width <= left_width {
+ left_part.push(ch);
+ current_width += ch_width;
+ } else {
+ break;
+ }
+ }
+
+ let chars: Vec<char> = text.chars().collect();
+ let mut right_part = String::new();
+ let mut current_width = 0;
+ for ch in chars.iter().rev() {
+ let ch_width = ch.to_string().width();
+ if current_width + ch_width <= right_width {
+ right_part.insert(0, *ch);
+ current_width += ch_width;
+ } else {
+ break;
+ }
+ }
+
+ (format!("{}...{}", left_part, right_part), None)
+}
+
+fn truncate_with_cursor(
+ text: &str,
+ max_width: usize,
+ cursor_char_idx: usize,
+) -> (String, Option<usize>) {
+ let chars: Vec<char> = text.chars().collect();
+ let char_widths: Vec<usize> = chars.iter().map(|ch| ch.to_string().width()).collect();
+
+ let width_before_cursor: usize = char_widths[..cursor_char_idx].iter().sum();
+ let width_after_cursor: usize = char_widths[cursor_char_idx..].iter().sum();
+ let available_one_ellipsis = max_width.saturating_sub(3);
+
+ let (start_idx, end_idx) = if width_before_cursor <= available_one_ellipsis {
+ calculate_end_truncation(&chars, &char_widths, available_one_ellipsis)
+ } else if width_after_cursor <= available_one_ellipsis {
+ calculate_start_truncation(&chars, &char_widths, available_one_ellipsis)
+ } else {
+ calculate_middle_truncation(&char_widths, max_width, cursor_char_idx)
+ };
+
+ let (start_idx, end_idx) =
+ adjust_small_truncations(start_idx, end_idx, &char_widths, cursor_char_idx, &chars);
+ let (start_idx, end_idx) =
+ trim_excess(&char_widths, start_idx, end_idx, max_width, cursor_char_idx);
+
+ build_truncated_result(&chars, start_idx, end_idx, cursor_char_idx)
+}
+
+fn calculate_end_truncation(
+ chars: &[char],
+ char_widths: &[usize],
+ available: usize,
+) -> (usize, usize) {
+ let mut end_idx = 0;
+ let mut width = 0;
+ while end_idx < chars.len() && width + char_widths[end_idx] <= available {
+ width += char_widths[end_idx];
+ end_idx += 1;
+ }
+ (0, end_idx)
+}
+
+fn calculate_start_truncation(
+ chars: &[char],
+ char_widths: &[usize],
+ available: usize,
+) -> (usize, usize) {
+ let mut start_idx = chars.len();
+ let mut width = 0;
+ while start_idx > 0 && width + char_widths[start_idx - 1] <= available {
+ start_idx -= 1;
+ width += char_widths[start_idx];
+ }
+ (start_idx, chars.len())
+}
+
+fn calculate_middle_truncation(
+ char_widths: &[usize],
+ max_width: usize,
+ cursor_char_idx: usize,
+) -> (usize, usize) {
+ let available_both_ellipsis = max_width.saturating_sub(6);
+ let target_before = available_both_ellipsis / 2;
+ let target_after = available_both_ellipsis - target_before;
+
+ let mut start_idx = cursor_char_idx;
+ let mut width_before = 0;
+ while start_idx > 0 && width_before + char_widths[start_idx - 1] <= target_before {
+ start_idx -= 1;
+ width_before += char_widths[start_idx];
+ }
+
+ let mut end_idx = cursor_char_idx;
+ let mut width_after = 0;
+ while end_idx < char_widths.len() && width_after + char_widths[end_idx] <= target_after {
+ width_after += char_widths[end_idx];
+ end_idx += 1;
+ }
+
+ let leftover_before = target_before.saturating_sub(width_before);
+ let leftover_after = target_after.saturating_sub(width_after);
+
+ if leftover_before > 0 {
+ let mut extra = leftover_before;
+ while end_idx < char_widths.len() && char_widths[end_idx] <= extra {
+ extra -= char_widths[end_idx];
+ end_idx += 1;
+ }
+ }
+ if leftover_after > 0 {
+ let mut extra = leftover_after;
+ while start_idx > 0 && char_widths[start_idx - 1] <= extra {
+ start_idx -= 1;
+ extra -= char_widths[start_idx];
+ }
+ }
+
+ (start_idx, end_idx)
+}
+
+fn adjust_small_truncations(
+ start_idx: usize,
+ end_idx: usize,
+ char_widths: &[usize],
+ cursor_char_idx: usize,
+ chars: &[char],
+) -> (usize, usize) {
+ let width_truncated_start: usize = char_widths[..start_idx].iter().sum();
+ let width_truncated_end: usize = char_widths[end_idx..].iter().sum();
+
+ let mut start_idx = start_idx;
+ let mut end_idx = end_idx;
+
+ if width_truncated_start > 0 && width_truncated_start < 3 && end_idx < chars.len() {
+ let gained = width_truncated_start;
+ start_idx = 0;
+ let mut removed = 0;
+ while end_idx > cursor_char_idx + 1 && removed < gained {
+ end_idx -= 1;
+ removed += char_widths[end_idx];
+ }
+ } else if width_truncated_end > 0 && width_truncated_end < 3 && start_idx > 0 {
+ let gained = width_truncated_end;
+ end_idx = chars.len();
+ let mut removed = 0;
+ while start_idx < cursor_char_idx && removed < gained {
+ removed += char_widths[start_idx];
+ start_idx += 1;
+ }
+ }
+
+ (start_idx, end_idx)
+}
+
+fn trim_excess(
+ char_widths: &[usize],
+ start_idx: usize,
+ end_idx: usize,
+ max_width: usize,
+ cursor_char_idx: usize,
+) -> (usize, usize) {
+ let truncate_start = start_idx > 0;
+ let truncate_end = end_idx < char_widths.len();
+ let ellipsis_width = match (truncate_start, truncate_end) {
+ (true, true) => 6,
+ (true, false) | (false, true) => 3,
+ (false, false) => 0,
+ };
+
+ let visible_width: usize = char_widths[start_idx..end_idx].iter().sum();
+ let mut start_idx = start_idx;
+ let mut end_idx = end_idx;
+
+ if visible_width + ellipsis_width > max_width {
+ let mut excess = visible_width + ellipsis_width - max_width;
+ if truncate_end {
+ while excess > 0 && end_idx > cursor_char_idx + 1 {
+ end_idx -= 1;
+ excess = excess.saturating_sub(char_widths[end_idx]);
+ }
+ }
+ if excess > 0 && truncate_start {
+ while excess > 0 && start_idx < cursor_char_idx {
+ excess = excess.saturating_sub(char_widths[start_idx]);
+ start_idx += 1;
+ }
+ }
+ }
+
+ (start_idx, end_idx)
+}
+
+fn build_truncated_result(
+ chars: &[char],
+ start_idx: usize,
+ end_idx: usize,
+ cursor_char_idx: usize,
+) -> (String, Option<usize>) {
+ let mut result = String::new();
+ let mut new_cursor_char_pos = 0;
+ let mut current_char_pos = 0;
+
+ if start_idx > 0 {
+ result.push_str("...");
+ current_char_pos = 3;
+ }
+
+ for i in start_idx..end_idx {
+ if i == cursor_char_idx {
+ new_cursor_char_pos = current_char_pos;
+ }
+ result.push(chars[i]);
+ current_char_pos += 1;
+ }
+
+ if cursor_char_idx >= end_idx {
+ new_cursor_char_pos = current_char_pos;
+ }
+
+ if end_idx < chars.len() {
+ result.push_str("...");
+ }
+
+ (result, Some(new_cursor_char_pos))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_truncate_middle_no_truncation_needed() {
+ let text = "hello world";
+ let (result, cursor_pos) = truncate_middle(text, 20, Some(6));
+ assert_eq!(result, "hello world");
+ assert_eq!(cursor_pos, Some(6));
+ }
+
+ #[test]
+ fn test_truncate_middle_no_cursor() {
+ let text = "this is a very long string that needs truncation";
+ let (result, cursor_pos) = truncate_middle(text, 20, None);
+ assert!(result.contains("..."));
+ assert_eq!(cursor_pos, None);
+ }
+
+ #[test]
+ fn test_truncate_middle_cursor_at_start() {
+ let text = "this is a very long string that needs truncation";
+ let (result, cursor_pos) = truncate_middle(text, 20, Some(0));
+ assert!(result.starts_with("this"));
+ assert!(result.ends_with("..."));
+ assert_eq!(cursor_pos, Some(0));
+ }
+
+ #[test]
+ fn test_truncate_middle_cursor_at_end() {
+ let text = "this is a very long string that needs truncation";
+ let cursor_char = text.chars().count();
+ let (result, _cursor_pos) = truncate_middle(text, 20, Some(cursor_char));
+ assert!(result.starts_with("..."));
+ assert!(result.ends_with("truncation"));
+ }
+
+ #[test]
+ fn test_truncate_middle_cursor_in_middle() {
+ let text = "this is a very long string that needs truncation";
+ let cursor_char = "this is a very ".chars().count();
+ let (result, cursor_pos) = truncate_middle(text, 20, Some(cursor_char));
+ assert!(result.contains("very"));
+ assert!(cursor_pos.is_some());
+ }
+
+ #[test]
+ fn test_truncate_middle_wide_chars() {
+ let text = "γ“γ‚“γ«γ‘γ―δΈ–η•Œ";
+ let (result, _) = truncate_middle(text, 10, None);
+ assert!(result.width() <= 10);
+ }
+}
diff --git a/default-plugins/session-manager/src/main.rs b/default-plugins/session-manager/src/main.rs
index 6be29dc5..5c014779 100644
--- a/default-plugins/session-manager/src/main.rs
+++ b/default-plugins/session-manager/src/main.rs
@@ -508,9 +508,9 @@ impl State {
if is_current_session {
if let Some((pane_id, is_plugin)) = selected_pane {
if is_plugin {
- focus_plugin_pane(pane_id, true);
+ focus_plugin_pane(pane_id, true, false);
} else {
- focus_terminal_pane(pane_id, true);
+ focus_terminal_pane(pane_id, true, false);
}
} else if let Some(tab_position) = selected_tab {
go_to_tab(tab_position as u32);
diff --git a/default-plugins/status-bar/src/one_line_ui.rs b/default-plugins/status-bar/src/one_line_ui.rs
index a3fdec77..a73c7b80 100644
--- a/default-plugins/status-bar/src/one_line_ui.rs
+++ b/default-plugins/status-bar/src/one_line_ui.rs
@@ -1386,7 +1386,7 @@ fn get_keys_and_hints(mi: &ModeInfo) -> Vec<(String, String, Vec<KeyWithModifier
(s("Toggle Embed"), s("Embed"), single_action_key(&km, &[A::TogglePaneEmbedOrFloating, TO_NORMAL])),
(s("Split Right"), s("Right"), single_action_key(&km, &[A::NewPane{direction: Some(Direction::Right), pane_name: None, start_suppressed: false}, TO_NORMAL])),
(s("Split Down"), s("Down"), single_action_key(&km, &[A::NewPane{direction: Some(Direction::Down), pane_name: None, start_suppressed: false}, TO_NORMAL])),
- (s("Stack"), s("Stack"), single_action_key(&km, &[A::NewStackedPane{command: None, pane_name: None}, TO_NORMAL])),
+ (s("Stack"), s("Stack"), single_action_key(&km, &[A::NewStackedPane{command: None, pane_name: None, near_current_pane: false}, TO_NORMAL])),
(s("Select pane"), s("Select"), to_basemode_key),
]} else if mi.mode == IM::Tab {
// With the default bindings, "Move focus" for tabs is tricky: It binds all the arrow keys
@@ -1406,7 +1406,17 @@ fn get_keys_and_hints(mi: &ModeInfo) -> Vec<(String, String, Vec<KeyWithModifier
};
vec![
- (s("New"), s("New"), single_action_key(&km, &[A::NewTab{tiled_layout: None, floating_layouts: vec![], swap_tiled_layouts: None, swap_floating_layouts: None, tab_name: None, should_change_focus_to_new_tab: true, cwd: None}, TO_NORMAL])),
+ (s("New"), s("New"), single_action_key(&km, &[A::NewTab{
+ tiled_layout: None,
+ floating_layouts: vec![],
+ swap_tiled_layouts: None,
+ swap_floating_layouts: None,
+ tab_name: None,
+ should_change_focus_to_new_tab: true,
+ cwd: None,
+ initial_panes: None,
+ first_pane_unblock_condition: None,
+ }, TO_NORMAL])),
(s("Change focus"), s("Move"), focus_keys),
(s("Close"), s("Close"), single_action_key(&km, &[A::CloseTab, TO_NORMAL])),
(s("Rename"), s("Rename"),
@@ -1491,7 +1501,17 @@ fn get_keys_and_hints(mi: &ModeInfo) -> Vec<(String, String, Vec<KeyWithModifier
(s("Split down"), s("Down"), action_key(&km, &[A::NewPane{direction: Some(Dir::Down), pane_name: None, start_suppressed: false}, TO_NORMAL])),
(s("Split right"), s("Right"), action_key(&km, &[A::NewPane{direction: Some(Dir::Right), pane_name: None, start_suppressed: false}, TO_NORMAL])),
(s("Fullscreen"), s("Fullscreen"), action_key(&km, &[A::ToggleFocusFullscreen, TO_NORMAL])),
- (s("New tab"), s("New"), action_key(&km, &[A::NewTab{tiled_layout: None, floating_layouts: vec![], swap_tiled_layouts: None, swap_floating_layouts: None, tab_name: None, should_change_focus_to_new_tab: true, cwd: None}, TO_NORMAL])),
+ (s("New tab"), s("New"), action_key(&km, &[A::NewTab{
+ tiled_layout: None,
+ floating_layouts: vec![],
+ swap_tiled_layouts: None,
+ swap_floating_layouts: None,
+ tab_name: None,
+ should_change_focus_to_new_tab: true,
+ cwd: None,
+ initial_panes: None,
+ first_pane_unblock_condition: None,
+ }, TO_NORMAL])),
(s("Rename tab"), s("Rename"),
action_key(&km, &[A::SwitchToMode{input_mode: IM::RenameTab}, A::TabNameInput{input: vec![0]}])),
(s("Previous Tab"), s("Previous"), action_key(&km, &[A::GoToPreviousTab, TO_NORMAL])),
diff --git a/default-plugins/status-bar/src/second_line.rs b/default-plugins/status-bar/src/second_line.rs
index 6f3fb02c..00e2b83b 100644
--- a/default-plugins/status-bar/src/second_line.rs
+++ b/default-plugins/status-bar/src/second_line.rs
@@ -173,7 +173,17 @@ fn get_keys_and_hints(mi: &ModeInfo) -> Vec<(String, String, Vec<KeyWithModifier
};
vec![
- (s("New"), s("New"), action_key(&km, &[A::NewTab{tiled_layout: None, floating_layouts: vec![], swap_tiled_layouts: None, swap_floating_layouts: None, tab_name: None, should_change_focus_to_new_tab: true, cwd: None}, TO_NORMAL])),
+ (s("New"), s("New"), action_key(&km, &[A::NewTab{
+ tiled_layout: None,
+ floating_layouts: vec![],
+ swap_tiled_layouts: None,
+ swap_floating_layouts: None,
+ tab_name: None,
+ should_change_focus_to_new_tab: true,
+ cwd: None,
+ initial_panes: None,
+ first_pane_unblock_condition: None,
+ }, TO_NORMAL])),
(s("Change focus"), s("Move"), focus_keys),
(s("Close"), s("Close"), action_key(&km, &[A::CloseTab, TO_NORMAL])),
(s("Rename"), s("Rename"),
@@ -253,7 +263,17 @@ fn get_keys_and_hints(mi: &ModeInfo) -> Vec<(String, String, Vec<KeyWithModifier
(s("Split down"), s("Down"), action_key(&km, &[A::NewPane{direction: Some(Dir::Down), pane_name: None, start_suppressed: false}, TO_NORMAL])),
(s("Split right"), s("Right"), action_key(&km, &[A::NewPane{direction: Some(Dir::Right), pane_name: None, start_suppressed: false}, TO_NORMAL])),
(s("Fullscreen"), s("Fullscreen"), action_key(&km, &[A::ToggleFocusFullscreen, TO_NORMAL])),
- (s("New tab"), s("New"), action_key(&km, &[A::NewTab{tiled_layout: None, floating_layouts: vec![], swap_tiled_layouts: None, swap_floating_layouts: None, tab_name: None, should_change_focus_to_new_tab: true, cwd: None}, TO_NORMAL])),
+ (s("New tab"), s("New"), action_key(&km, &[A::NewTab{
+ tiled_layout: None,
+ floating_layouts: vec![],
+ swap_tiled_layouts: None,
+ swap_floating_layouts: None,
+ tab_name: None,
+ should_change_focus_to_new_tab: true,
+ cwd: None,
+ initial_panes: None,
+ first_pane_unblock_condition: None,
+ }, TO_NORMAL])),
(s("Rename tab"), s("Rename"),
action_key(&km, &[A::SwitchToMode{input_mode: IM::RenameTab}, A::TabNameInput{input: vec![0]}])),
(s("Previous Tab"), s("Previous"), action_key(&km, &[A::GoToPreviousTab, TO_NORMAL])),