aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAram Drevekenin <aram@poor.dev>2026-03-05 21:13:02 +0100
committerGitHub <noreply@github.com>2026-03-05 21:13:02 +0100
commit903cdf5d32476a8d6cc208d2c418a1f2c67e5a53 (patch)
tree9a4647d9dc3efcdea8149fd78fdfa956b3c8d8cb
parentbuild: make test mirror build command for plugins (#4801) (diff)
downloadsl0ppij-903cdf5d32476a8d6cc208d2c418a1f2c67e5a53.tar.gz
sl0ppij-903cdf5d32476a8d6cc208d2c418a1f2c67e5a53.tar.xz
sl0ppij-903cdf5d32476a8d6cc208d2c418a1f2c67e5a53.zip
Click file paths to open in editor, allow plugins to change styles and hover of viewport (#4802)
* initial implementation * implement hover * add styles and foreground/background emphases, fix upsert hover collisions * initial implementation of link plugin * suppress highlighting + clicks on unfocused and mouse mode panes * also make italic * fix env var and ~ expansions * fix regex issues and add defensive validations * fix tests and add plugin assets * add tests * refactor all the things * add pr
-rw-r--r--CHANGELOG.md1
-rw-r--r--Cargo.lock7
-rw-r--r--Cargo.toml1
-rw-r--r--default-plugins/fixture-plugin-for-tests/src/main.rs18
-rw-r--r--default-plugins/link/.cargo/config.toml2
-rw-r--r--default-plugins/link/Cargo.toml9
-rw-r--r--default-plugins/link/src/main.rs459
-rw-r--r--default-plugins/strider/src/main.rs21
-rw-r--r--xtask/src/main.rs4
-rw-r--r--zellij-server/src/output/mod.rs59
-rw-r--r--zellij-server/src/panes/grid.rs449
-rw-r--r--zellij-server/src/panes/terminal_pane.rs34
-rw-r--r--zellij-server/src/panes/unit/grid_tests.rs299
-rw-r--r--zellij-server/src/plugins/mod.rs26
-rw-r--r--zellij-server/src/plugins/unit/plugin_tests.rs271
-rw-r--r--zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__highlight_clicked_event_delivered_to_plugin.snap7
-rw-r--r--zellij-server/src/plugins/wasm_bridge.rs5
-rw-r--r--zellij-server/src/plugins/zellij_exports.rs41
-rw-r--r--zellij-server/src/screen.rs52
-rw-r--r--zellij-server/src/tab/mod.rs50
-rw-r--r--zellij-server/src/tab/mouse_handler.rs79
-rw-r--r--zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__hover_on_unfocused_pane_no_highlight.snap24
-rw-r--r--zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__hover_over_highlight_shows_styling-2.snap24
-rw-r--r--zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__hover_over_highlight_shows_styling.snap24
-rw-r--r--zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__set_and_clear_highlights_across_tiled_and_floating.snap24
-rw-r--r--zellij-server/src/tab/unit/tab_integration_tests.rs467
-rw-r--r--zellij-tile/src/shim.rs31
-rw-r--r--zellij-utils/assets/config/default.kdl5
-rwxr-xr-xzellij-utils/assets/plugins/about.wasmbin1452614 -> 1453169 bytes
-rwxr-xr-xzellij-utils/assets/plugins/compact-bar.wasmbin1621317 -> 1625878 bytes
-rwxr-xr-xzellij-utils/assets/plugins/configuration.wasmbin1518813 -> 1522706 bytes
-rwxr-xr-xzellij-utils/assets/plugins/fixture-plugin-for-tests.wasmbin1518051 -> 1609922 bytes
-rwxr-xr-xzellij-utils/assets/plugins/layout-manager.wasmbin1808818 -> 1809140 bytes
-rwxr-xr-xzellij-utils/assets/plugins/link.wasmbin0 -> 1253321 bytes
-rwxr-xr-xzellij-utils/assets/plugins/multiple-select.wasmbin1450066 -> 1460981 bytes
-rwxr-xr-xzellij-utils/assets/plugins/plugin-manager.wasmbin1427576 -> 1428169 bytes
-rwxr-xr-xzellij-utils/assets/plugins/session-manager.wasmbin1510388 -> 1529189 bytes
-rwxr-xr-xzellij-utils/assets/plugins/share.wasmbin1422243 -> 1423773 bytes
-rwxr-xr-xzellij-utils/assets/plugins/status-bar.wasmbin1617347 -> 1601061 bytes
-rwxr-xr-xzellij-utils/assets/plugins/strider.wasmbin1586209 -> 1600234 bytes
-rwxr-xr-xzellij-utils/assets/plugins/tab-bar.wasmbin1508719 -> 1499270 bytes
-rw-r--r--zellij-utils/assets/prost/api.event.rs19
-rw-r--r--zellij-utils/assets/prost/api.plugin_command.rs103
-rw-r--r--zellij-utils/src/consts.rs1
-rw-r--r--zellij-utils/src/data.rs43
-rw-r--r--zellij-utils/src/errors.rs3
-rw-r--r--zellij-utils/src/input/plugins.rs1
-rw-r--r--zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string.snap1
-rw-r--r--zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string_with_comments.snap1
-rw-r--r--zellij-utils/src/plugin_api/event.proto9
-rw-r--r--zellij-utils/src/plugin_api/event.rs41
-rw-r--r--zellij-utils/src/plugin_api/plugin_command.proto53
-rw-r--r--zellij-utils/src/plugin_api/plugin_command.rs228
-rw-r--r--zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments.snap17
-rw-r--r--zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_env_vars_override_config_env_vars.snap17
-rw-r--r--zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_keybinds_override_config_keybinds.snap17
-rw-r--r--zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_themes_override_config_themes.snap17
-rw-r--r--zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_ui_config_overrides_config_ui_config.snap17
58 files changed, 3002 insertions, 79 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index abb8db24..1018bdde 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -70,6 +70,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
* platform: native Windows support (https://github.com/zellij-org/zellij/pull/4720, https://github.com/zellij-org/zellij/pull/4721, https://github.com/zellij-org/zellij/pull/4722, https://github.com/zellij-org/zellij/pull/4738, https://github.com/zellij-org/zellij/pull/4740, https://github.com/zellij-org/zellij/pull/4746, https://github.com/zellij-org/zellij/pull/4759, https://github.com/zellij-org/zellij/pull/4768)
* feat: allow changing pane foreground/background color through the cli/plugin-api (https://github.com/zellij-org/zellij/pull/4737)
* build: fix redundant proto codegen, plugin batch building and testing, and profile mismatch between build and run (https://github.com/zellij-org/zellij/pull/4799, https://github.com/zellij-org/zellij/pull/4801)
+* feat: click to open file-paths in viewport, allow plugins to highlight viewport text (https://github.com/zellij-org/zellij/pull/4802)
## [0.43.1] - 2025-08-08
diff --git a/Cargo.lock b/Cargo.lock
index b491228d..559aeaf5 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2171,6 +2171,13 @@ dependencies = [
]
[[package]]
+name = "link"
+version = "0.1.0"
+dependencies = [
+ "zellij-tile",
+]
+
+[[package]]
name = "linked-hash-map"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
index e5f89ab9..d5c428cb 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -53,6 +53,7 @@ members = [
"default-plugins/share",
"default-plugins/multiple-select",
"default-plugins/layout-manager",
+ "default-plugins/link",
"zellij-client",
"zellij-server",
"zellij-utils",
diff --git a/default-plugins/fixture-plugin-for-tests/src/main.rs b/default-plugins/fixture-plugin-for-tests/src/main.rs
index 6091586c..da0e6482 100644
--- a/default-plugins/fixture-plugin-for-tests/src/main.rs
+++ b/default-plugins/fixture-plugin-for-tests/src/main.rs
@@ -76,6 +76,7 @@ impl ZellijPlugin for State {
EventType::FileSystemDelete,
EventType::BeforeClose,
EventType::PluginConfigurationChanged,
+ EventType::HighlightClicked,
]);
watch_filesystem();
}
@@ -590,6 +591,23 @@ impl ZellijPlugin for State {
// Test send_sigkill_to_pane_id
send_sigkill_to_pane_id(PaneId::Terminal(1));
},
+ BareKey::Char('g') if key.has_only_modifiers(&[KeyModifier::Super]) => {
+ // Test set_pane_regex_highlights
+ let highlights = vec![RegexHighlight {
+ pattern: "test_pattern".to_string(),
+ style: HighlightStyle::Emphasis0,
+ context: BTreeMap::new(),
+ on_hover: false,
+ bold: false,
+ italic: true,
+ underline: true,
+ }];
+ set_pane_regex_highlights(PaneId::Terminal(1), highlights);
+ },
+ BareKey::Char('h') if key.has_only_modifiers(&[KeyModifier::Super]) => {
+ // Test clear_pane_highlights
+ clear_pane_highlights(PaneId::Terminal(1));
+ },
BareKey::Char('a')
if key.has_only_modifiers(&[KeyModifier::Ctrl, KeyModifier::Shift]) =>
{
diff --git a/default-plugins/link/.cargo/config.toml b/default-plugins/link/.cargo/config.toml
new file mode 100644
index 00000000..6b509f5b
--- /dev/null
+++ b/default-plugins/link/.cargo/config.toml
@@ -0,0 +1,2 @@
+[build]
+target = "wasm32-wasip1"
diff --git a/default-plugins/link/Cargo.toml b/default-plugins/link/Cargo.toml
new file mode 100644
index 00000000..d4676b24
--- /dev/null
+++ b/default-plugins/link/Cargo.toml
@@ -0,0 +1,9 @@
+[package]
+name = "link"
+version = "0.1.0"
+authors = ["Aram Drevekenin <aram@poor.dev>"]
+edition.workspace = true
+license.workspace = true
+
+[dependencies]
+zellij-tile = { path = "../../zellij-tile" }
diff --git a/default-plugins/link/src/main.rs b/default-plugins/link/src/main.rs
new file mode 100644
index 00000000..3862e5d0
--- /dev/null
+++ b/default-plugins/link/src/main.rs
@@ -0,0 +1,459 @@
+use std::collections::{BTreeMap, HashMap, HashSet};
+use std::path::{Path, PathBuf};
+use zellij_tile::prelude::*;
+
+const FILE_PATH_REGEX: &str = r#"(?:(?:\./|\.\./|/)[A-Za-z0-9_./\-+@%,#=~!\$\{\}\[\]]+|~/[A-Za-z0-9_./\-+@%,#=~!\$\{\}\[\]]+|\$\{?[A-Za-z_][A-Za-z0-9_]*\}?/[A-Za-z0-9_./\-+@%,#=~!\$\{\}\[\]]+|[a-zA-Z0-9_][a-zA-Z0-9_.\-]*/[A-Za-z0-9_./\-+@%,#=~!\$\{\}\[\]]+)(?::\d+(?::\d+)?)?"#;
+
+const CWD_CONTEXT_KEY: &str = "cwd";
+
+#[derive(Default)]
+struct State {
+ known_terminal_panes: HashSet<PaneId>,
+ /// Tracks the current CWD for each terminal pane.
+ pane_cwds: HashMap<PaneId, PathBuf>,
+ /// Tracks the directory entry names highlighted for each pane,
+ /// so they can be removed when the CWD changes.
+ pane_dir_entries: HashMap<PaneId, Vec<String>>,
+ /// Session environment variables, fetched once on load.
+ /// Used for `~` and `$VAR` expansion in clicked paths.
+ env_vars: BTreeMap<String, String>,
+}
+
+register_plugin!(State);
+
+impl ZellijPlugin for State {
+ fn load(&mut self, _configuration: BTreeMap<String, String>) {
+ subscribe(&[
+ EventType::PaneUpdate,
+ EventType::HighlightClicked,
+ EventType::CwdChanged,
+ ]);
+ // Set host folder to "/" so that /host maps to the real filesystem root,
+ // allowing std::fs operations on /host/<absolute_path>.
+ change_host_folder(PathBuf::from("/"));
+ self.env_vars = get_session_environment_variables();
+ }
+
+ fn update(&mut self, event: Event) -> bool {
+ match event {
+ Event::PaneUpdate(pane_manifest) => {
+ self.handle_pane_update(pane_manifest);
+ },
+ Event::HighlightClicked {
+ pane_id: _,
+ pattern: _,
+ matched_string,
+ context,
+ } => {
+ self.handle_highlight_clicked(matched_string, context);
+ },
+ Event::CwdChanged(pane_id, new_cwd, _focused_client_ids) => {
+ self.handle_cwd_changed(pane_id, new_cwd);
+ },
+ _ => {},
+ }
+ false // never render — background-only plugin
+ }
+
+ fn render(&mut self, _rows: usize, _cols: usize) {
+ // Background-only plugin. Never rendered. Intentionally empty.
+ }
+}
+
+impl State {
+ fn handle_pane_update(&mut self, pane_manifest: PaneManifest) {
+ let mut current_panes: HashSet<PaneId> = HashSet::new();
+
+ for (_tab_index, panes) in &pane_manifest.panes {
+ for pane_info in panes {
+ if !pane_info.is_plugin {
+ let pane_id = PaneId::Terminal(pane_info.id);
+ current_panes.insert(pane_id);
+ }
+ }
+ }
+
+ // Set highlights on newly appeared terminal panes
+ for &pane_id in &current_panes {
+ if !self.known_terminal_panes.contains(&pane_id) {
+ // Fetch the pane's current CWD and scan its directory
+ if let Ok(cwd) = get_pane_cwd(pane_id) {
+ self.scan_and_store_dir_entries(pane_id, &cwd);
+ self.pane_cwds.insert(pane_id, cwd);
+ }
+ self.set_all_highlights_for_pane(pane_id);
+ }
+ }
+
+ // Clean up tracking state for panes that no longer exist
+ for &pane_id in &self.known_terminal_panes {
+ if !current_panes.contains(&pane_id) {
+ self.pane_cwds.remove(&pane_id);
+ self.pane_dir_entries.remove(&pane_id);
+ }
+ }
+
+ self.known_terminal_panes = current_panes;
+ }
+
+ fn handle_cwd_changed(&mut self, pane_id: PaneId, new_cwd: PathBuf) {
+ let old_cwd = self.pane_cwds.get(&pane_id);
+ if old_cwd == Some(&new_cwd) {
+ return;
+ }
+
+ self.pane_cwds.insert(pane_id, new_cwd.clone());
+ self.scan_and_store_dir_entries(pane_id, &new_cwd);
+
+ // clear_pane_highlights removes all highlights, then re-set everything
+ // (file-path regex + directory entry patterns)
+ clear_pane_highlights(pane_id);
+ self.set_all_highlights_for_pane(pane_id);
+ }
+
+ fn handle_highlight_clicked(&self, matched_string: String, context: BTreeMap<String, String>) {
+ let (path_str, line_number) = parse_path_and_line(&matched_string);
+ let path_str = path_str.trim();
+ let expanded = expand_path(path_str, &self.env_vars);
+ let path_str = expanded.as_str();
+
+ // Resolve to a fully qualified path: if relative, join with the
+ // pane CWD stored in the highlight context.
+ let absolute_path = if path_str.starts_with('/') {
+ PathBuf::from(path_str)
+ } else if let Some(cwd) = context.get(CWD_CONTEXT_KEY) {
+ PathBuf::from(cwd).join(path_str)
+ } else {
+ PathBuf::from(path_str)
+ };
+
+ // Validate the path exists via the /host/ filesystem mapping
+ // established at load time. This guards against regex false
+ // positives that match non-path text in terminal output.
+ let host_path =
+ Path::new("/host").join(absolute_path.strip_prefix("/").unwrap_or(&absolute_path));
+ let metadata = match std::fs::metadata(&host_path) {
+ Ok(m) => m,
+ Err(_) => return, // path does not exist — silently ignore
+ };
+
+ if metadata.is_dir() {
+ let mut args = BTreeMap::new();
+ let mut configuration = BTreeMap::new();
+ args.insert("open_directly".to_owned(), "true".to_owned());
+ configuration.insert("caller_cwd".to_owned(), absolute_path.display().to_string());
+ pipe_message_to_plugin(
+ MessageToPlugin::new("filepicker")
+ .with_plugin_url("filepicker")
+ .new_plugin_instance_should_have_pane_title(&format!(
+ "Browse: {}",
+ absolute_path.display()
+ ))
+ .new_plugin_instance_should_be_focused()
+ .new_plugin_instance_should_have_cwd(absolute_path)
+ .with_args(args)
+ .with_plugin_config(configuration),
+ );
+ } else {
+ let mut file_to_open = FileToOpen::new(&absolute_path);
+ if let Some(line) = line_number {
+ file_to_open = file_to_open.with_line_number(line);
+ }
+ open_file_floating(file_to_open, None, BTreeMap::new());
+ }
+ }
+
+ /// (Re-)set all regex highlights for a pane: the general file-path regex
+ /// plus any directory-entry patterns derived from the pane's CWD.
+ fn set_all_highlights_for_pane(&self, pane_id: PaneId) {
+ let mut highlights = Vec::new();
+
+ // Build context map containing the pane CWD (echoed back on click)
+ let context = self.cwd_context_for_pane(pane_id);
+
+ // General file-path regex (always present)
+ highlights.push(RegexHighlight {
+ pattern: FILE_PATH_REGEX.to_owned(),
+ style: HighlightStyle::None,
+ context: context.clone(),
+ on_hover: true,
+ bold: false,
+ italic: true,
+ underline: true,
+ });
+
+ // Directory-entry patterns for the pane's current CWD
+ if let Some(entries) = self.pane_dir_entries.get(&pane_id) {
+ for entry_name in entries {
+ let pattern = regex_escape(entry_name);
+ highlights.push(RegexHighlight {
+ pattern,
+ style: HighlightStyle::None,
+ context: context.clone(),
+ on_hover: true,
+ bold: false,
+ italic: true,
+ underline: true,
+ });
+ }
+ }
+
+ set_pane_regex_highlights(pane_id, highlights);
+ }
+
+ fn scan_and_store_dir_entries(&mut self, pane_id: PaneId, cwd: &Path) {
+ let host_path = Path::new("/host").join(cwd.strip_prefix("/").unwrap_or(cwd));
+ let dir_entries = scan_directory(&host_path);
+ self.pane_dir_entries.insert(pane_id, dir_entries);
+ }
+
+ fn cwd_context_for_pane(&self, pane_id: PaneId) -> BTreeMap<String, String> {
+ let mut context = BTreeMap::new();
+ if let Some(cwd) = self.pane_cwds.get(&pane_id) {
+ context.insert(CWD_CONTEXT_KEY.to_owned(), cwd.display().to_string());
+ }
+ context
+ }
+}
+
+/// Scan a directory for first-level file and folder names.
+/// Returns an empty vec on any error.
+fn scan_directory(path: &Path) -> Vec<String> {
+ let mut entries = Vec::new();
+ let read_dir = match std::fs::read_dir(path) {
+ Ok(rd) => rd,
+ Err(_) => return entries,
+ };
+ for entry in read_dir {
+ if let Ok(entry) = entry {
+ if let Some(name) = entry.file_name().to_str() {
+ entries.push(name.to_owned());
+ }
+ }
+ }
+ entries
+}
+
+/// Escape a string so it is treated as a literal in a regex pattern.
+fn regex_escape(s: &str) -> String {
+ let mut escaped = String::with_capacity(s.len() + 8);
+ for c in s.chars() {
+ match c {
+ '\\' | '.' | '+' | '*' | '?' | '(' | ')' | '|' | '[' | ']' | '{' | '}' | '^' | '$' => {
+ escaped.push('\\');
+ escaped.push(c);
+ },
+ _ => escaped.push(c),
+ }
+ }
+ escaped
+}
+
+/// Expand `~` and `$VAR` / `${VAR}` references in a path string.
+///
+/// - `~` at the start (followed by `/` or at end-of-string) is replaced with
+/// the value of `HOME` from `env_vars`.
+/// - `$VARNAME` and `${VARNAME}` anywhere in the string are replaced with the
+/// corresponding value from `env_vars`.
+/// - Unrecognized variables and a missing `HOME` are left as-is.
+fn expand_path(path: &str, env_vars: &BTreeMap<String, String>) -> String {
+ // Step 1: tilde expansion (only leading ~)
+ let after_tilde = if path == "~" {
+ match env_vars.get("HOME") {
+ Some(home) => home.clone(),
+ None => path.to_owned(),
+ }
+ } else if let Some(rest) = path.strip_prefix("~/") {
+ match env_vars.get("HOME") {
+ Some(home) => format!("{}/{}", home, rest),
+ None => path.to_owned(),
+ }
+ } else {
+ path.to_owned()
+ };
+
+ // Step 2: environment variable expansion ($VAR and ${VAR})
+ let bytes = after_tilde.as_bytes();
+ let len = bytes.len();
+ let mut result = String::with_capacity(len);
+ let mut i = 0;
+
+ while i < len {
+ if bytes[i] == b'$' && i + 1 < len {
+ let (var_name, end_idx) = if bytes[i + 1] == b'{' {
+ // ${VAR} form
+ if let Some(close) = after_tilde[i + 2..].find('}') {
+ let name = &after_tilde[i + 2..i + 2 + close];
+ (name, i + 2 + close + 1)
+ } else {
+ // No closing brace — not a valid variable reference
+ result.push('$');
+ i += 1;
+ continue;
+ }
+ } else {
+ // $VAR form — variable name is [A-Za-z_][A-Za-z0-9_]*
+ let start = i + 1;
+ if start < len && (bytes[start].is_ascii_alphabetic() || bytes[start] == b'_') {
+ let mut end = start + 1;
+ while end < len && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_') {
+ end += 1;
+ }
+ (&after_tilde[start..end], end)
+ } else {
+ result.push('$');
+ i += 1;
+ continue;
+ }
+ };
+
+ if let Some(value) = env_vars.get(var_name) {
+ result.push_str(value);
+ } else {
+ // Unknown variable — preserve the original text
+ result.push_str(&after_tilde[i..end_idx]);
+ }
+ i = end_idx;
+ } else {
+ result.push(bytes[i] as char);
+ i += 1;
+ }
+ }
+
+ result
+}
+
+fn parse_path_and_line(matched_string: &str) -> (&str, Option<usize>) {
+ let mut end = matched_string.len();
+ let mut numeric_segments: Vec<(usize, &str)> = Vec::new();
+
+ loop {
+ if end == 0 {
+ break;
+ }
+ let search_region = &matched_string[..end];
+ if let Some(colon_pos) = search_region.rfind(':') {
+ let segment = &matched_string[colon_pos + 1..end];
+ if !segment.is_empty() && segment.chars().all(|c| c.is_ascii_digit()) {
+ numeric_segments.push((colon_pos, segment));
+ end = colon_pos;
+ } else {
+ break;
+ }
+ } else {
+ break;
+ }
+ }
+
+ numeric_segments.reverse();
+
+ match numeric_segments.first() {
+ None => (matched_string, None),
+ Some(&(colon_pos, line_str)) => {
+ let path = &matched_string[..colon_pos];
+ (path, line_str.parse::<usize>().ok())
+ },
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ // --- parse_path_and_line tests ---
+
+ #[test]
+ fn parse_path_and_line_simple_path() {
+ let (path, line) = parse_path_and_line("src/main.rs");
+ assert_eq!(path, "src/main.rs");
+ assert_eq!(line, None);
+ }
+
+ #[test]
+ fn parse_path_and_line_with_line_number() {
+ let (path, line) = parse_path_and_line("src/main.rs:42");
+ assert_eq!(path, "src/main.rs");
+ assert_eq!(line, Some(42));
+ }
+
+ #[test]
+ fn parse_path_and_line_with_line_and_col() {
+ let (path, line) = parse_path_and_line("src/main.rs:42:10");
+ assert_eq!(path, "src/main.rs");
+ assert_eq!(line, Some(42));
+ }
+
+ #[test]
+ fn parse_path_and_line_no_trailing_number() {
+ let (path, line) = parse_path_and_line("src/main.rs:");
+ assert_eq!(path, "src/main.rs:");
+ assert_eq!(line, None);
+ }
+
+ // --- expand_path tests ---
+
+ #[test]
+ fn expand_path_tilde() {
+ let mut env = BTreeMap::new();
+ env.insert("HOME".into(), "/home/user".into());
+ assert_eq!(expand_path("~/foo/bar", &env), "/home/user/foo/bar");
+ }
+
+ #[test]
+ fn expand_path_env_var() {
+ let mut env = BTreeMap::new();
+ env.insert("HOME".into(), "/home/user".into());
+ assert_eq!(expand_path("$HOME/foo", &env), "/home/user/foo");
+ }
+
+ #[test]
+ fn expand_path_braced_var() {
+ let mut env = BTreeMap::new();
+ env.insert("HOME".into(), "/home/user".into());
+ assert_eq!(expand_path("${HOME}/foo", &env), "/home/user/foo");
+ }
+
+ #[test]
+ fn expand_path_unknown_var_preserved() {
+ let env = BTreeMap::new();
+ assert_eq!(expand_path("$UNKNOWN/foo", &env), "$UNKNOWN/foo");
+ }
+
+ #[test]
+ fn expand_path_no_expansion_needed() {
+ let env = BTreeMap::new();
+ assert_eq!(expand_path("/absolute/path", &env), "/absolute/path");
+ }
+
+ // --- regex_escape tests ---
+
+ #[test]
+ fn regex_escape_special_chars() {
+ assert_eq!(regex_escape("file.txt"), r"file\.txt");
+ assert_eq!(regex_escape("a+b*c?"), r"a\+b\*c\?");
+ assert_eq!(regex_escape("(foo)[bar]{baz}"), r"\(foo\)\[bar\]\{baz\}");
+ }
+
+ #[test]
+ fn regex_escape_no_special_chars() {
+ assert_eq!(regex_escape("foobar"), "foobar");
+ assert_eq!(regex_escape("hello_world"), "hello_world");
+ }
+
+ // --- scan_directory tests ---
+
+ #[test]
+ fn scan_directory_returns_entries() {
+ use std::fs;
+ let dir = std::env::temp_dir().join("zellij_test_scan_dir");
+ let _ = fs::remove_dir_all(&dir);
+ fs::create_dir_all(&dir).unwrap();
+ fs::write(dir.join("file1.txt"), "").unwrap();
+ fs::write(dir.join("file2.rs"), "").unwrap();
+ fs::create_dir(dir.join("subdir")).unwrap();
+ let mut entries = scan_directory(&dir);
+ entries.sort();
+ assert_eq!(entries, vec!["file1.txt", "file2.rs", "subdir"]);
+ let _ = fs::remove_dir_all(&dir);
+ }
+}
diff --git a/default-plugins/strider/src/main.rs b/default-plugins/strider/src/main.rs
index 8870232f..6357d955 100644
--- a/default-plugins/strider/src/main.rs
+++ b/default-plugins/strider/src/main.rs
@@ -138,11 +138,24 @@ impl ZellijPlugin for State {
fn pipe(&mut self, pipe_message: PipeMessage) -> bool {
if pipe_message.is_private && pipe_message.name == "filepicker" {
- if let PipeSource::Cli(pipe_id) = &pipe_message.source {
- #[cfg(target_family = "wasm")]
- block_cli_pipe_input(pipe_id);
+ let open_directly = pipe_message
+ .args
+ .get("open_directly")
+ .map(|v| v == "true")
+ .unwrap_or(false);
+ if open_directly {
+ // Standalone mode: selecting a file opens it directly,
+ // then the plugin closes itself.
+ self.close_on_selection = true;
+ } else {
+ // Filepicker callback mode: send result back to caller.
+ if let PipeSource::Cli(pipe_id) = &pipe_message.source {
+ #[cfg(target_family = "wasm")]
+ block_cli_pipe_input(pipe_id);
+ }
+ self.handling_filepick_request_from =
+ Some((pipe_message.source, pipe_message.args));
}
- self.handling_filepick_request_from = Some((pipe_message.source, pipe_message.args));
true
} else {
false
diff --git a/xtask/src/main.rs b/xtask/src/main.rs
index d78d9433..2b249b5a 100644
--- a/xtask/src/main.rs
+++ b/xtask/src/main.rs
@@ -83,6 +83,10 @@ fn workspace_members() -> &'static Vec<WorkspaceMember> {
build: true,
},
WorkspaceMember {
+ crate_name: "default-plugins/link",
+ build: true,
+ },
+ WorkspaceMember {
crate_name: "zellij-utils",
build: false,
},
diff --git a/zellij-server/src/output/mod.rs b/zellij-server/src/output/mod.rs
index 9297be11..5fd4ff2d 100644
--- a/zellij-server/src/output/mod.rs
+++ b/zellij-server/src/output/mod.rs
@@ -40,23 +40,44 @@ fn vte_hide_cursor_instruction(vte_output: &mut String) -> Result<()> {
write!(vte_output, "\u{1b}[?25l").context("failed to execute VTE instruction to hide cursor")
}
+/// A selection region with associated styling for highlights and text selection.
+#[derive(Debug, Clone, Copy)]
+pub struct HighlightSelection {
+ pub selection: Selection,
+ pub bg: Option<AnsiCode>,
+ pub fg: Option<AnsiCode>,
+ pub bold: bool,
+ pub italic: bool,
+ pub underline: bool,
+}
+
fn adjust_styles_for_possible_selection(
- chunk_selection_and_colors: Vec<(Selection, AnsiCode, Option<AnsiCode>)>,
+ chunk_selection_and_colors: &[HighlightSelection],
character_styles: CharacterStyles,
chunk_y: usize,
chunk_width: usize,
) -> CharacterStyles {
chunk_selection_and_colors
.iter()
- .find(|(selection, _background_color, _foreground_color)| {
- selection.contains(chunk_y, chunk_width)
- })
- .map(|(_selection, background_color, foreground_color)| {
- let mut character_styles = character_styles.background(Some(*background_color));
- if let Some(foreground_color) = foreground_color {
- character_styles = character_styles.foreground(Some(*foreground_color));
+ .find(|hs| hs.selection.contains(chunk_y, chunk_width))
+ .map(|hs| {
+ let mut styles = character_styles;
+ if let Some(bg) = hs.bg {
+ styles = styles.background(Some(bg));
+ }
+ if let Some(fg) = hs.fg {
+ styles = styles.foreground(Some(fg));
+ }
+ if hs.bold {
+ styles = styles.bold(Some(AnsiCode::On));
+ }
+ if hs.italic {
+ styles = styles.italic(Some(AnsiCode::On));
+ }
+ if hs.underline {
+ styles = styles.underline(Some(AnsiCode::Underline(None)));
}
- character_styles
+ styles
})
.unwrap_or(character_styles)
}
@@ -982,9 +1003,9 @@ pub struct CharacterChunk {
pub x: usize,
pub y: usize,
pub changed_colors: Option<[Option<AnsiCode>; 256]>,
- selection_and_colors: Vec<(Selection, AnsiCode, Option<AnsiCode>)>, // Selection, background color, optional foreground color
pub pane_default_fg: Option<AnsiCode>,
pub pane_default_bg: Option<AnsiCode>,
+ selection_and_colors: Vec<HighlightSelection>,
}
#[derive(Debug, Clone, Copy, Default)]
@@ -1009,21 +1030,17 @@ impl CharacterChunk {
}
pub fn add_selection_and_colors(
&mut self,
- selection: Selection,
- background_color: AnsiCode,
- foreground_color: Option<AnsiCode>,
+ highlight: HighlightSelection,
offset_x: usize,
offset_y: usize,
) {
- self.selection_and_colors.push((
- selection.offset(offset_x, offset_y),
- background_color,
- foreground_color,
- ));
+ self.selection_and_colors.push(HighlightSelection {
+ selection: highlight.selection.offset(offset_x, offset_y),
+ ..highlight
+ });
}
- pub fn selection_and_colors(&self) -> Vec<(Selection, AnsiCode, Option<AnsiCode>)> {
- // Selection, background color, optional foreground color
- self.selection_and_colors.clone()
+ pub fn selection_and_colors(&self) -> &[HighlightSelection] {
+ &self.selection_and_colors
}
pub fn add_changed_colors(&mut self, changed_colors: Option<[Option<AnsiCode>; 256]>) {
self.changed_colors = changed_colors;
diff --git a/zellij-server/src/panes/grid.rs b/zellij-server/src/panes/grid.rs
index c7eee368..cb629ac9 100644
--- a/zellij-server/src/panes/grid.rs
+++ b/zellij-server/src/panes/grid.rs
@@ -1,9 +1,11 @@
use super::sixel::{PixelRect, SixelGrid, SixelImageStore};
use std::borrow::Cow;
use std::cell::RefCell;
+use std::collections::BTreeMap;
use std::collections::HashMap;
use std::rc::Rc;
-use zellij_utils::data::Style;
+use unicode_width::UnicodeWidthChar;
+use zellij_utils::data::{HighlightStyle, RegexHighlight, Style};
use zellij_utils::errors::prelude::*;
use std::{
@@ -28,7 +30,7 @@ pub const MAX_TITLE_STACK_SIZE: usize = 1000;
use vte::{Params, Perform};
use zellij_utils::{consts::VERSION, shared::version_number};
-use crate::output::{CharacterChunk, OutputBuffer, SixelImageChunk};
+use crate::output::{CharacterChunk, HighlightSelection, OutputBuffer, SixelImageChunk};
use crate::panes::alacritty_functions::{parse_number, xparse_color};
use crate::panes::hyperlink_tracker::HyperlinkTracker;
use crate::panes::link_handler::LinkHandler;
@@ -349,6 +351,128 @@ fn utf8_mouse_coordinates(column: usize, line: isize) -> Vec<u8> {
coordinates
}
+/// Find the canonical root row for a logical line group containing `row_idx`,
+/// collect the non-canonical tail rows, and build the concatenated text with
+/// a byte-offset boundary table.
+///
+/// Returns `(canonical_idx, group_len, text, boundaries)` where `group_len`
+/// is the total number of viewport rows in the group (1 + tail count).
+/// Returns `None` if `row_idx` is out of bounds.
+fn collect_and_build_logical_line(
+ viewport: &VecDeque<Row>,
+ row_idx: usize,
+) -> Option<(usize, usize, String, Vec<(usize, usize)>)> {
+ if row_idx >= viewport.len() {
+ return None;
+ }
+ // Walk backward to find the canonical root.
+ let mut canonical = row_idx;
+ while canonical > 0 {
+ match viewport.get(canonical) {
+ Some(r) if !r.is_canonical => canonical -= 1,
+ _ => break,
+ }
+ }
+ let canonical_row = viewport.get(canonical)?;
+ // Collect non-canonical tail rows.
+ let mut tail_count = 0;
+ loop {
+ let tail_idx = canonical + tail_count + 1;
+ match viewport.get(tail_idx) {
+ Some(r) if !r.is_canonical => tail_count += 1,
+ _ => break,
+ }
+ }
+ let group_len = 1 + tail_count;
+ // Build concatenated text and boundary table.
+ let mut text = String::new();
+ let mut boundaries: Vec<(usize, usize)> = Vec::with_capacity(group_len);
+ boundaries.push((canonical, 0));
+ for ch in &canonical_row.columns {
+ text.push(ch.character);
+ }
+ for i in 0..tail_count {
+ let idx = canonical + 1 + i;
+ boundaries.push((idx, text.len()));
+ if let Some(row) = viewport.get(idx) {
+ for ch in &row.columns {
+ text.push(ch.character);
+ }
+ }
+ }
+ Some((canonical, group_len, text, boundaries))
+}
+
+/// Map a byte offset in the concatenated logical-line string back to
+/// (viewport_row_idx, display_column).
+///
+/// display_column is the sum of character.width() for all characters
+/// before the target character in that row — NOT a char count or byte count.
+/// This correctly handles wide characters (CJK, emoji).
+///
+/// `boundaries` is the table produced by `collect_and_build_logical_line`.
+/// `viewport` is `&self.viewport`.
+fn byte_offset_to_display_col(
+ byte_offset: usize,
+ boundaries: &[(usize, usize)],
+ viewport: &VecDeque<Row>,
+) -> Option<(usize, usize)> {
+ if boundaries.is_empty() {
+ return None;
+ }
+ // Find which row the byte offset falls in (last boundary whose byte_start <= offset).
+ let boundary_idx = boundaries
+ .partition_point(|&(_, byte_start)| byte_start <= byte_offset)
+ .saturating_sub(1);
+ let &(row_idx, row_byte_start) = boundaries.get(boundary_idx)?;
+ let intra_byte_offset = byte_offset - row_byte_start;
+
+ // Count display columns up to (but not including) the character at intra_byte_offset.
+ let row = viewport.get(row_idx)?;
+ let mut display_col = 0usize;
+ let mut bytes_seen = 0usize;
+ for ch in &row.columns {
+ if bytes_seen >= intra_byte_offset {
+ break;
+ }
+ bytes_seen += ch.character.len_utf8();
+ display_col += ch.character.width().unwrap_or(1);
+ }
+ Some((row_idx, display_col))
+}
+
+/// Convert a regex match into a `Selection` spanning the matched display region.
+/// Returns `None` if the boundary table or viewport lookup fails.
+fn match_to_selection(
+ mat: &regex::Match,
+ boundaries: &[(usize, usize)],
+ viewport: &VecDeque<Row>,
+) -> Option<(Selection, usize, usize, usize, usize)> {
+ let (start_row, start_col) = byte_offset_to_display_col(mat.start(), boundaries, viewport)?;
+ let (end_row, end_col) = byte_offset_to_display_col(mat.end(), boundaries, viewport)?;
+ let mut sel = Selection::default();
+ sel.set_start_and_end_positions(
+ Position::new(start_row as i32, start_col as u16),
+ Position::new(end_row as i32, end_col as u16),
+ );
+ Some((sel, start_row, start_col, end_row, end_col))
+}
+
+/// Check whether a (row, col) position falls within a display span.
+/// The span is inclusive at start and exclusive at end.
+fn position_in_span(
+ row: usize,
+ col: usize,
+ start_row: usize,
+ start_col: usize,
+ end_row: usize,
+ end_col: usize,
+) -> bool {
+ let after_start = row > start_row || (row == start_row && col >= start_col);
+ let before_end = row < end_row || (row == end_row && col < end_col);
+ after_start && before_end
+}
+
#[derive(Clone)]
pub struct Grid {
pub(crate) lines_above: VecDeque<Row>,
@@ -406,6 +530,9 @@ pub struct Grid {
hyperlink_tracker: HyperlinkTracker,
pub pane_default_fg: Option<AnsiCode>,
pub pane_default_bg: Option<AnsiCode>,
+ pub plugin_highlights: HashMap<u32, Vec<(String, CompiledHighlight)>>,
+ // key: plugin_id (u32), inner vec: (pattern, compiled) pairs
+ pub hover_position: Option<Position>, // pane-relative cursor cell; None when outside pane
}
impl Grid {
@@ -431,6 +558,26 @@ fn ansi_code_to_color_string(code: AnsiCode) -> Option<String> {
}
}
+/// A compiled highlight entry for one plugin/pattern combination.
+#[derive(Clone)]
+pub struct CompiledHighlight {
+ pub regex: regex::Regex,
+ pub fg: Option<AnsiCode>,
+ pub bg: Option<AnsiCode>,
+ pub context: BTreeMap<String, String>,
+ pub on_hover: bool,
+ pub bold: bool,
+ pub italic: bool,
+ pub underline: bool,
+}
+
+impl CompiledHighlight {
+ /// Whether this highlight would produce any visible styling change.
+ pub fn has_visual_effect(&self) -> bool {
+ self.bg.is_some() || self.fg.is_some() || self.bold || self.italic || self.underline
+ }
+}
+
const CLICK_TIME_THRESHOLD: u128 = 400; // Doherty Threshold
#[derive(Clone, Debug, Default)]
@@ -483,7 +630,7 @@ impl Default for MouseMode {
}
}
-#[derive(Clone, Debug)]
+#[derive(Clone, Debug, PartialEq)]
pub enum MouseTracking {
Off,
Normal,
@@ -544,6 +691,49 @@ impl Debug for Grid {
}
}
+fn resolve_highlight_colors(
+ style_decl: &HighlightStyle,
+ style: &Style,
+) -> (Option<AnsiCode>, Option<AnsiCode>) {
+ let palette_to_ansi = |c: PaletteColor| -> AnsiCode {
+ match c {
+ PaletteColor::Rgb(rgb) => AnsiCode::RgbCode(rgb),
+ PaletteColor::EightBit(i) => AnsiCode::ColorIndex(i),
+ }
+ };
+ let tu = &style.colors.text_unselected;
+ let emphasis = [tu.emphasis_0, tu.emphasis_1, tu.emphasis_2, tu.emphasis_3];
+ match style_decl {
+ HighlightStyle::None => (None, None),
+ HighlightStyle::Emphasis0 => (Some(palette_to_ansi(emphasis[0])), None),
+ HighlightStyle::Emphasis1 => (Some(palette_to_ansi(emphasis[1])), None),
+ HighlightStyle::Emphasis2 => (Some(palette_to_ansi(emphasis[2])), None),
+ HighlightStyle::Emphasis3 => (Some(palette_to_ansi(emphasis[3])), None),
+ HighlightStyle::BackgroundEmphasis0 => (
+ Some(palette_to_ansi(tu.background)),
+ Some(palette_to_ansi(emphasis[0])),
+ ),
+ HighlightStyle::BackgroundEmphasis1 => (
+ Some(palette_to_ansi(tu.background)),
+ Some(palette_to_ansi(emphasis[1])),
+ ),
+ HighlightStyle::BackgroundEmphasis2 => (
+ Some(palette_to_ansi(tu.background)),
+ Some(palette_to_ansi(emphasis[2])),
+ ),
+ HighlightStyle::BackgroundEmphasis3 => (
+ Some(palette_to_ansi(tu.background)),
+ Some(palette_to_ansi(emphasis[3])),
+ ),
+ HighlightStyle::CustomRgb { fg, bg } => {
+ (fg.map(AnsiCode::RgbCode), bg.map(AnsiCode::RgbCode))
+ },
+ HighlightStyle::CustomIndex { fg, bg } => {
+ (fg.map(AnsiCode::ColorIndex), bg.map(AnsiCode::ColorIndex))
+ },
+ }
+}
+
impl Grid {
pub fn new(
rows: usize,
@@ -621,6 +811,8 @@ impl Grid {
hyperlink_tracker: HyperlinkTracker::new(),
pane_default_fg: None,
pane_default_bg: None,
+ plugin_highlights: HashMap::new(),
+ hover_position: None,
}
}
pub fn render_full_viewport(&mut self) {
@@ -1199,6 +1391,9 @@ impl Grid {
let mut raw_vte_output = String::new();
let (mut character_chunks, sixel_image_chunks) = self.read_changes(content_x, content_y);
+
+ let plugin_highlight_selections = self.compute_plugin_highlight_selections();
+
for character_chunk in character_chunks.iter_mut() {
character_chunk.add_changed_colors(self.changed_colors);
character_chunk.add_pane_defaults(self.pane_default_fg, self.pane_default_bg);
@@ -1216,9 +1411,14 @@ impl Grid {
};
character_chunk.add_selection_and_colors(
- self.selection,
- background_color,
- Some(foreground_color),
+ HighlightSelection {
+ selection: self.selection,
+ bg: Some(background_color),
+ fg: Some(foreground_color),
+ bold: false,
+ italic: false,
+ underline: false,
+ },
content_x,
content_y,
);
@@ -1246,15 +1446,29 @@ impl Grid {
PaletteColor::EightBit(col) => AnsiCode::ColorIndex(col),
};
character_chunk.add_selection_and_colors(
- *res,
- background_color,
- Some(foreground_color),
+ HighlightSelection {
+ selection: *res,
+ bg: Some(background_color),
+ fg: Some(foreground_color),
+ bold: false,
+ italic: false,
+ underline: false,
+ },
content_x,
content_y,
);
}
}
}
+ // Apply pre-computed plugin highlight selections to this chunk.
+ for hs in &plugin_highlight_selections {
+ if hs
+ .selection
+ .contains_row(character_chunk.y.saturating_sub(content_y))
+ {
+ character_chunk.add_selection_and_colors(*hs, content_x, content_y);
+ }
+ }
}
return Ok(Some((
character_chunks,
@@ -1851,6 +2065,223 @@ impl Grid {
fn set_preceding_character(&mut self, terminal_character: TerminalCharacter) {
self.preceding_char = Some(terminal_character);
}
+ /// Called by the server-side handler for SetPaneRegexHighlights.
+ /// Upserts highlights keyed by pattern string for the given plugin.
+ pub fn set_plugin_regex_highlights(
+ &mut self,
+ plugin_id: u32,
+ highlights: Vec<RegexHighlight>,
+ style: &Style,
+ ) {
+ let slot = self
+ .plugin_highlights
+ .entry(plugin_id)
+ .or_insert_with(Vec::new);
+ for h in highlights {
+ let (fg, bg) = resolve_highlight_colors(&h.style, style);
+ if let Ok(regex) = regex::Regex::new(&h.pattern) {
+ // Upsert: replace existing entry with same pattern and on_hover flag, or push new
+ let on_hover = h.on_hover;
+ if let Some(existing) = slot
+ .iter_mut()
+ .find(|(p, c)| p == &h.pattern && c.on_hover == on_hover)
+ {
+ existing.1 = CompiledHighlight {
+ regex,
+ fg,
+ bg,
+ context: h.context,
+ on_hover: h.on_hover,
+ bold: h.bold,
+ italic: h.italic,
+ underline: h.underline,
+ };
+ } else {
+ slot.push((
+ h.pattern,
+ CompiledHighlight {
+ regex,
+ fg,
+ bg,
+ context: h.context,
+ on_hover: h.on_hover,
+ bold: h.bold,
+ italic: h.italic,
+ underline: h.underline,
+ },
+ ));
+ }
+ } else {
+ log::warn!(
+ "Plugin {} supplied invalid regex: {:?}",
+ plugin_id,
+ h.pattern
+ );
+ }
+ }
+ self.output_buffer.update_all_lines();
+ }
+
+ /// Called by the server-side handler for ClearPaneHighlights.
+ pub fn clear_plugin_highlights(&mut self, plugin_id: u32) {
+ if self.plugin_highlights.remove(&plugin_id).is_some() {
+ self.output_buffer.update_all_lines();
+ }
+ }
+
+ /// Returns (plugin_id, pattern, matched_string, context) if the given
+ /// pane-relative position falls inside any plugin highlight match, or None.
+ /// Uses logical-line grouping so multi-line (wrapped) matches are detected.
+ pub fn plugin_highlight_at(
+ &self,
+ position: &Position,
+ ) -> Option<(u32, String, String, BTreeMap<String, String>)> {
+ let click_row = position.line.0 as usize;
+ let click_col = position.column.0 as usize;
+
+ let (_canonical, _group_len, logical_text, boundaries) =
+ collect_and_build_logical_line(&self.viewport, click_row)?;
+
+ for (plugin_id, pattern_map) in &self.plugin_highlights {
+ for (pattern, compiled) in pattern_map {
+ for mat in compiled.regex.find_iter(&logical_text) {
+ if let Some((_sel, start_row, start_col, end_row, end_col)) =
+ match_to_selection(&mat, &boundaries, &self.viewport)
+ {
+ if position_in_span(
+ click_row, click_col, start_row, start_col, end_row, end_col,
+ ) {
+ return Some((
+ *plugin_id,
+ pattern.clone(),
+ mat.as_str().to_string(),
+ compiled.context.clone(),
+ ));
+ }
+ }
+ }
+ }
+ }
+ None
+ }
+
+ pub fn set_hover_position(&mut self, new_pos: Option<Position>) {
+ if self.hover_position == new_pos {
+ return;
+ }
+
+ // Mark the canonical group containing the old hover row dirty.
+ if let Some(old_pos) = self.hover_position {
+ self.mark_logical_line_dirty(old_pos.line.0 as usize);
+ }
+ // Mark the canonical group containing the new hover row dirty.
+ if let Some(new_pos_inner) = new_pos {
+ self.mark_logical_line_dirty(new_pos_inner.line.0 as usize);
+ }
+
+ self.hover_position = new_pos;
+ }
+
+ /// Mark all physical rows belonging to the logical line group that contains
+ /// `row_idx` as dirty in the output buffer.
+ fn mark_logical_line_dirty(&mut self, row_idx: usize) {
+ if let Some((canonical, group_len, _, _)) =
+ collect_and_build_logical_line(&self.viewport, row_idx)
+ {
+ for r in canonical..canonical + group_len {
+ self.output_buffer.update_line(r);
+ }
+ }
+ }
+
+ /// Pre-compute plugin highlight selections across all logical line groups in
+ /// the viewport. Hover highlights are emitted first so they take priority
+ /// when the cursor overlaps a match; non-hover highlights follow.
+ fn compute_plugin_highlight_selections(&self) -> Vec<HighlightSelection> {
+ if self.plugin_highlights.is_empty() {
+ return vec![];
+ }
+ let mut selections = Vec::new();
+ let viewport_len = self.viewport.len();
+ let mut ridx = 0;
+ while ridx < viewport_len {
+ let (_canonical, group_len, logical_text, boundaries) =
+ match collect_and_build_logical_line(&self.viewport, ridx) {
+ Some(v) => v,
+ None => break,
+ };
+
+ // Hover highlights are pushed first so that `.find()` in
+ // `adjust_styles_for_possible_selection` returns the hover
+ // style when the cursor overlaps the match. They are suppressed
+ // when mouse tracking is active (events pass through to the app)
+ // and on unfocused panes (hover_position is not set for those).
+ if self.mouse_tracking == MouseTracking::Off {
+ if let Some(hover_pos) = self.hover_position {
+ let hover_row = hover_pos.line.0 as usize;
+ let group_end_row = ridx + group_len - 1;
+ if hover_row >= ridx && hover_row <= group_end_row {
+ let hover_col = hover_pos.column.0;
+ for (_plugin_id, pattern_map) in &self.plugin_highlights {
+ for (_pattern, compiled) in pattern_map {
+ if !compiled.on_hover || !compiled.has_visual_effect() {
+ continue;
+ }
+ for mat in compiled.regex.find_iter(&logical_text) {
+ if let Some((sel, start_row, start_col, end_row, end_col)) =
+ match_to_selection(&mat, &boundaries, &self.viewport)
+ {
+ if position_in_span(
+ hover_row, hover_col, start_row, start_col, end_row,
+ end_col,
+ ) {
+ selections.push(HighlightSelection {
+ selection: sel,
+ bg: compiled.bg,
+ fg: compiled.fg,
+ bold: compiled.bold,
+ italic: compiled.italic,
+ underline: compiled.underline,
+ });
+ break; // only one match per pattern per hover
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Non-hover highlights are pushed after hover so that hover
+ // takes precedence for overlapping regions.
+ for (_plugin_id, pattern_map) in &self.plugin_highlights {
+ for (_pattern, compiled) in pattern_map {
+ if compiled.on_hover || !compiled.has_visual_effect() {
+ continue;
+ }
+ for mat in compiled.regex.find_iter(&logical_text) {
+ if let Some((sel, _, _, _, _)) =
+ match_to_selection(&mat, &boundaries, &self.viewport)
+ {
+ selections.push(HighlightSelection {
+ selection: sel,
+ bg: compiled.bg,
+ fg: compiled.fg,
+ bold: compiled.bold,
+ italic: compiled.italic,
+ underline: compiled.underline,
+ });
+ }
+ }
+ }
+ }
+
+ ridx += group_len; // advance past this logical line group
+ }
+ selections
+ }
+
pub fn start_selection(&mut self, start: &Position) {
let old_selection = self.selection;
self.click.record_click(*start);
diff --git a/zellij-server/src/panes/terminal_pane.rs b/zellij-server/src/panes/terminal_pane.rs
index a3f73a72..f0fd3949 100644
--- a/zellij-server/src/panes/terminal_pane.rs
+++ b/zellij-server/src/panes/terminal_pane.rs
@@ -22,7 +22,7 @@ use zellij_utils::pane_size::Offset;
use zellij_utils::{
data::{
BareKey, InputMode, KeyWithModifier, Palette, PaletteColor, PaneId as ZellijUtilsPaneId,
- Style, Styling,
+ RegexHighlight, Style, Styling,
},
errors::prelude::*,
input::layout::Run,
@@ -958,6 +958,38 @@ impl Pane for TerminalPane {
}
}
}
+ fn set_plugin_regex_highlights(
+ &mut self,
+ plugin_id: u32,
+ highlights: Vec<RegexHighlight>,
+ style: &Style,
+ ) {
+ self.grid
+ .set_plugin_regex_highlights(plugin_id, highlights, style);
+ self.set_should_render(true);
+ }
+ fn clear_plugin_highlights(&mut self, plugin_id: u32) {
+ self.grid.clear_plugin_highlights(plugin_id);
+ self.set_should_render(true);
+ }
+ fn set_hover_position(&mut self, position: Option<Position>) {
+ self.grid.set_hover_position(position);
+ self.set_should_render(true);
+ }
+ fn plugin_highlight_at(
+ &self,
+ position: &Position,
+ ) -> Option<(
+ u32,
+ String,
+ String,
+ std::collections::BTreeMap<String, String>,
+ )> {
+ self.grid.plugin_highlight_at(position)
+ }
+ fn terminal_emulator_wants_mouse(&self) -> bool {
+ self.grid.mouse_tracking != crate::panes::grid::MouseTracking::Off
+ }
}
impl TerminalPane {
diff --git a/zellij-server/src/panes/unit/grid_tests.rs b/zellij-server/src/panes/unit/grid_tests.rs
index e942caa8..7010cefb 100644
--- a/zellij-server/src/panes/unit/grid_tests.rs
+++ b/zellij-server/src/panes/unit/grid_tests.rs
@@ -4556,3 +4556,302 @@ fn osc_11_set_bg_produces_ansi_in_render_output() {
);
}
}
+
+// =====================================================================
+// Plugin Highlight Engine Tests
+// =====================================================================
+
+use crate::panes::grid::MouseTracking;
+use crate::panes::terminal_character::AnsiCode;
+use std::collections::BTreeMap;
+use zellij_utils::data::{HighlightStyle, RegexHighlight};
+
+fn create_highlight(
+ pattern: &str,
+ on_hover: bool,
+ bold: bool,
+ italic: bool,
+ underline: bool,
+) -> RegexHighlight {
+ RegexHighlight {
+ pattern: pattern.to_string(),
+ style: HighlightStyle::Emphasis0,
+ context: BTreeMap::new(),
+ on_hover,
+ bold,
+ italic,
+ underline,
+ }
+}
+
+#[test]
+fn set_plugin_regex_highlights_basic_match() {
+ let mut grid = create_grid_with_content("hello foo bar\n");
+ let highlights = vec![RegexHighlight {
+ pattern: "foo".into(),
+ style: HighlightStyle::Emphasis0,
+ context: BTreeMap::new(),
+ on_hover: false,
+ bold: false,
+ italic: false,
+ underline: true,
+ }];
+ grid.set_plugin_regex_highlights(1, highlights, &Style::default());
+ let slot = grid.plugin_highlights.get(&1);
+ assert!(slot.is_some());
+ let entries = slot.unwrap();
+ assert_eq!(entries.len(), 1);
+ assert!(entries[0].1.regex.is_match("foo"));
+}
+
+#[test]
+fn set_plugin_regex_highlights_no_match() {
+ let mut grid = create_grid_with_content("hello world bar\n");
+ let highlights = vec![create_highlight("xyz123", false, false, false, false)];
+ grid.set_plugin_regex_highlights(1, highlights, &Style::default());
+
+ // No position in the viewport should match
+ for col in 0..15 {
+ assert!(grid.plugin_highlight_at(&Position::new(0, col)).is_none());
+ }
+}
+
+#[test]
+fn clear_plugin_highlights_removes_highlights() {
+ let mut grid = create_grid_with_content("hello foo bar\n");
+ let highlights = vec![create_highlight("foo", false, false, false, true)];
+ grid.set_plugin_regex_highlights(1, highlights, &Style::default());
+ assert!(grid.plugin_highlights.get(&1).is_some());
+
+ grid.clear_plugin_highlights(1);
+ assert!(grid.plugin_highlights.get(&1).is_none());
+}
+
+#[test]
+fn multiple_plugins_highlights_independent() {
+ let mut grid = create_grid_with_content("aaa bbb ccc\n");
+ let h1 = vec![create_highlight("aaa", false, false, false, false)];
+ let h2 = vec![create_highlight("bbb", false, false, false, false)];
+ grid.set_plugin_regex_highlights(1, h1, &Style::default());
+ grid.set_plugin_regex_highlights(2, h2, &Style::default());
+
+ assert!(grid.plugin_highlights.get(&1).is_some());
+ assert!(grid.plugin_highlights.get(&2).is_some());
+
+ grid.clear_plugin_highlights(1);
+ assert!(grid.plugin_highlights.get(&1).is_none());
+ assert!(grid.plugin_highlights.get(&2).is_some());
+}
+
+#[test]
+fn upsert_replaces_same_pattern() {
+ let mut grid = create_grid_with_content("foo bar\n");
+ let h1 = vec![RegexHighlight {
+ pattern: "foo".into(),
+ style: HighlightStyle::Emphasis0,
+ context: BTreeMap::new(),
+ on_hover: false,
+ bold: false,
+ italic: false,
+ underline: true,
+ }];
+ grid.set_plugin_regex_highlights(1, h1, &Style::default());
+
+ let h2 = vec![RegexHighlight {
+ pattern: "foo".into(),
+ style: HighlightStyle::Emphasis0,
+ context: BTreeMap::new(),
+ on_hover: false,
+ bold: false,
+ italic: false,
+ underline: false,
+ }];
+ grid.set_plugin_regex_highlights(1, h2, &Style::default());
+
+ let entries = grid.plugin_highlights.get(&1).unwrap();
+ assert_eq!(entries.len(), 1);
+ assert!(!entries[0].1.underline);
+}
+
+#[test]
+fn invalid_regex_does_not_crash() {
+ let mut grid = create_grid_with_content("hello\n");
+ let highlights = vec![create_highlight("[invalid", false, false, false, false)];
+ grid.set_plugin_regex_highlights(1, highlights, &Style::default());
+ // Invalid regex should be skipped
+ let slot = grid.plugin_highlights.get(&1);
+ match slot {
+ None => {}, // acceptable
+ Some(entries) => assert_eq!(entries.len(), 0),
+ }
+}
+
+#[test]
+fn plugin_highlight_at_returns_match() {
+ let mut grid = create_grid_with_content("hello foo bar\n");
+ let mut context = BTreeMap::new();
+ context.insert("key".to_string(), "value".to_string());
+ let highlights = vec![RegexHighlight {
+ pattern: "foo".into(),
+ style: HighlightStyle::Emphasis0,
+ context: context.clone(),
+ on_hover: false,
+ bold: false,
+ italic: false,
+ underline: true,
+ }];
+ grid.set_plugin_regex_highlights(1, highlights, &Style::default());
+
+ // "foo" starts at column 6 in "hello foo bar"
+ let result = grid.plugin_highlight_at(&Position::new(0, 6));
+ assert!(result.is_some());
+ let (plugin_id, pattern, matched_string, ctx) = result.unwrap();
+ assert_eq!(plugin_id, 1);
+ assert_eq!(pattern, "foo");
+ assert_eq!(matched_string, "foo");
+ assert_eq!(ctx.get("key").unwrap(), "value");
+}
+
+#[test]
+fn plugin_highlight_at_returns_none_on_miss() {
+ let mut grid = create_grid_with_content("hello foo bar\n");
+ let highlights = vec![create_highlight("foo", false, false, false, true)];
+ grid.set_plugin_regex_highlights(1, highlights, &Style::default());
+
+ // Position 0 is in "hello", not "foo"
+ let result = grid.plugin_highlight_at(&Position::new(0, 0));
+ assert!(result.is_none());
+}
+
+#[test]
+fn plugin_highlight_at_wrapped_line() {
+ // Create a narrow grid (10 cols) so that a long string wraps
+ let sixel_image_store = Rc::new(RefCell::new(SixelImageStore::default()));
+ let terminal_emulator_color_codes = Rc::new(RefCell::new(HashMap::new()));
+ let mut grid = Grid::new(
+ 5,
+ 10,
+ Rc::new(RefCell::new(Palette::default())),
+ terminal_emulator_color_codes,
+ Rc::new(RefCell::new(LinkHandler::new())),
+ Rc::new(RefCell::new(None)),
+ sixel_image_store,
+ Style::default(),
+ false,
+ true,
+ true,
+ true,
+ false,
+ );
+ // Feed a long string that wraps: "abcdefghij" fills row 0, "klmnopqrst" fills row 1
+ let content = "abcdefghijklmnopqrst";
+ let mut vte_parser = vte::Parser::new();
+ for &byte in content.as_bytes() {
+ vte_parser.advance(&mut grid, byte);
+ }
+
+ // Set a highlight for "jklm" which spans the wrap boundary
+ let highlights = vec![create_highlight("jklm", false, false, false, true)];
+ grid.set_plugin_regex_highlights(1, highlights, &Style::default());
+
+ // "jklm" spans row 0 col 9 through row 1 col 3
+ // Position in the wrapped portion (row 1, col 1 = 'k')
+ let result = grid.plugin_highlight_at(&Position::new(1, 1));
+ assert!(result.is_some());
+ let (plugin_id, _pattern, matched_string, _ctx) = result.unwrap();
+ assert_eq!(plugin_id, 1);
+ assert_eq!(matched_string, "jklm");
+}
+
+#[test]
+fn hover_position_triggers_on_hover_highlight() {
+ let mut grid = create_grid_with_content("hello link_text bar\n");
+ let highlights = vec![create_highlight("link_text", true, false, false, true)];
+ grid.set_plugin_regex_highlights(1, highlights, &Style::default());
+
+ // Set hover position inside "link_text" (starts at col 6)
+ grid.set_hover_position(Some(Position::new(0, 8)));
+ assert!(grid.hover_position.is_some());
+ assert_eq!(grid.hover_position.unwrap(), Position::new(0, 8));
+
+ // The on_hover entry should exist in plugin_highlights
+ let entries = grid.plugin_highlights.get(&1).unwrap();
+ assert_eq!(entries.len(), 1);
+ assert!(entries[0].1.on_hover);
+}
+
+#[test]
+fn hover_suppressed_when_mouse_tracking_on() {
+ let mut grid = create_grid_with_content("hello link_text bar\n");
+ let highlights = vec![create_highlight("link_text", true, false, false, true)];
+ grid.set_plugin_regex_highlights(1, highlights, &Style::default());
+
+ // Enable mouse tracking — the render path should skip hover highlights
+ grid.mouse_tracking = MouseTracking::Normal;
+ grid.set_hover_position(Some(Position::new(0, 8)));
+
+ // The hover position is set regardless (the guard is in the render path),
+ // but we verify that mouse_tracking is non-Off
+ assert!(grid.hover_position.is_some());
+ assert_ne!(grid.mouse_tracking, MouseTracking::Off);
+}
+
+#[test]
+fn wide_char_display_column_mapping() {
+ // CJK characters: "你好" = 2 chars, each 2 display cols wide, so "world" starts at display col 4
+ let mut grid = create_grid_with_content("你好world\n");
+ let highlights = vec![create_highlight("world", false, false, false, true)];
+ grid.set_plugin_regex_highlights(1, highlights, &Style::default());
+
+ // "你好" occupies display cols 0-3, "world" starts at display col 4
+ let result = grid.plugin_highlight_at(&Position::new(0, 4));
+ assert!(result.is_some());
+ let (plugin_id, _pattern, matched_string, _ctx) = result.unwrap();
+ assert_eq!(plugin_id, 1);
+ assert_eq!(matched_string, "world");
+
+ // Position 2 should be inside "你好", not "world"
+ let result_miss = grid.plugin_highlight_at(&Position::new(0, 2));
+ assert!(result_miss.is_none());
+}
+
+#[test]
+fn highlight_style_variants_resolve_colors() {
+ use super::resolve_highlight_colors;
+
+ let style = Style::default();
+
+ // HighlightStyle::None returns (None, None)
+ let (fg, bg): (Option<AnsiCode>, Option<AnsiCode>) =
+ resolve_highlight_colors(&HighlightStyle::None, &style);
+ assert!(fg.is_none());
+ assert!(bg.is_none());
+
+ // HighlightStyle::CustomRgb with fg only
+ let (fg, bg): (Option<AnsiCode>, Option<AnsiCode>) = resolve_highlight_colors(
+ &HighlightStyle::CustomRgb {
+ fg: Some((255, 0, 0)),
+ bg: None,
+ },
+ &style,
+ );
+ assert_eq!(fg, Some(AnsiCode::RgbCode((255, 0, 0))));
+ assert!(bg.is_none());
+
+ // HighlightStyle::CustomIndex with bg only
+ let (fg, bg): (Option<AnsiCode>, Option<AnsiCode>) = resolve_highlight_colors(
+ &HighlightStyle::CustomIndex {
+ fg: None,
+ bg: Some(42),
+ },
+ &style,
+ );
+ assert!(fg.is_none());
+ assert_eq!(bg, Some(AnsiCode::ColorIndex(42)));
+
+ // HighlightStyle::Emphasis0 should return a foreground color from the palette
+ let (fg, bg): (Option<AnsiCode>, Option<AnsiCode>) =
+ resolve_highlight_colors(&HighlightStyle::Emphasis0, &style);
+ assert!(fg.is_some());
+ assert!(bg.is_none());
+}
diff --git a/zellij-server/src/plugins/mod.rs b/zellij-server/src/plugins/mod.rs
index d26fb7d2..4bd4a82e 100644
--- a/zellij-server/src/plugins/mod.rs
+++ b/zellij-server/src/plugins/mod.rs
@@ -212,6 +212,14 @@ pub enum PluginInstruction {
response_channel: crossbeam::channel::Sender<Option<u64>>,
},
DetectPluginConfigChanges(PluginAliases),
+ HighlightClicked {
+ plugin_id: u32,
+ client_id: ClientId,
+ pane_id: PaneId,
+ pattern: String,
+ matched_string: String,
+ context: BTreeMap<String, String>,
+ },
Exit,
}
@@ -275,6 +283,7 @@ impl From<&PluginInstruction> for PluginContext {
PluginInstruction::DetectPluginConfigChanges(..) => {
PluginContext::DetectPluginConfigChanges
},
+ PluginInstruction::HighlightClicked { .. } => PluginContext::HighlightClicked,
}
}
}
@@ -1232,6 +1241,23 @@ pub(crate) fn plugin_thread_main(
wasm_bridge
.detect_and_notify_plugin_config_changes(&new_plugins, shutdown_send.clone())?;
},
+ PluginInstruction::HighlightClicked {
+ plugin_id,
+ client_id,
+ pane_id,
+ pattern,
+ matched_string,
+ context,
+ } => {
+ let event = Event::HighlightClicked {
+ pane_id: pane_id.into(),
+ pattern,
+ matched_string,
+ context,
+ };
+ let updates = vec![(Some(plugin_id), Some(client_id), event)];
+ wasm_bridge.update_plugins(updates, shutdown_send.clone())?;
+ },
PluginInstruction::Exit => {
break;
},
diff --git a/zellij-server/src/plugins/unit/plugin_tests.rs b/zellij-server/src/plugins/unit/plugin_tests.rs
index a5f2e420..4e2ad047 100644
--- a/zellij-server/src/plugins/unit/plugin_tests.rs
+++ b/zellij-server/src/plugins/unit/plugin_tests.rs
@@ -12323,3 +12323,274 @@ pub fn get_session_environment_variables_plugin_command() {
});
assert_snapshot!(format!("{:#?}", plugin_bytes_event));
}
+
+// =====================================================================
+// Plugin Highlight API Integration Tests
+// =====================================================================
+
+use crate::panes::PaneId;
+use zellij_utils::data::{HighlightStyle, RegexHighlight};
+
+#[test]
+#[ignore]
+pub fn set_pane_regex_highlights_via_plugin() {
+ let temp_folder = tempdir().unwrap();
+ let plugin_host_folder = PathBuf::from(temp_folder.path());
+ let cache_path = plugin_host_folder.join("permissions_test.kdl");
+ let (plugin_thread_sender, screen_receiver, teardown) = create_plugin_thread(None, None);
+ let plugin_should_float = Some(false);
+ let plugin_title = Some("test_plugin".to_owned());
+ let run_plugin = RunPluginOrAlias::RunPlugin(RunPlugin {
+ _allow_exec_host_cmd: false,
+ location: RunPluginLocation::File(PathBuf::from(&*PLUGIN_FIXTURE)),
+ configuration: Default::default(),
+ ..Default::default()
+ });
+ let tab_index = 1;
+ let client_id = 1;
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let received_screen_instructions = Arc::new(Mutex::new(vec![]));
+ let screen_thread = grant_permissions_and_log_actions_in_thread_struct_variant!(
+ received_screen_instructions,
+ ScreenInstruction::SetPluginRegexHighlights,
+ screen_receiver,
+ 1,
+ &PermissionType::ChangeApplicationState,
+ cache_path,
+ plugin_thread_sender,
+ client_id
+ );
+
+ let _ = plugin_thread_sender.send(PluginInstruction::AddClient(client_id));
+ let _ = plugin_thread_sender.send(PluginInstruction::Load(
+ plugin_should_float,
+ false,
+ false, // close_replaced_pane
+ plugin_title,
+ run_plugin,
+ Some(tab_index),
+ None,
+ client_id,
+ size,
+ None,
+ None,
+ false,
+ None,
+ None,
+ None,
+ ));
+ std::thread::sleep(std::time::Duration::from_millis(500));
+
+ // Send Super+g key event to trigger set_pane_regex_highlights in the fixture plugin
+ let _ = plugin_thread_sender.send(PluginInstruction::Update(vec![(
+ None,
+ Some(client_id),
+ Event::Key(KeyWithModifier::new(BareKey::Char('g')).with_super_modifier()),
+ )]));
+
+ screen_thread.join().unwrap();
+ teardown();
+
+ let highlight_instruction = received_screen_instructions
+ .lock()
+ .unwrap()
+ .iter()
+ .find_map(|i| {
+ if let ScreenInstruction::SetPluginRegexHighlights {
+ pane_id,
+ plugin_id,
+ highlights,
+ } = i
+ {
+ Some((*pane_id, *plugin_id, highlights.clone()))
+ } else {
+ None
+ }
+ });
+
+ assert!(
+ highlight_instruction.is_some(),
+ "Expected SetPluginRegexHighlights instruction"
+ );
+ let (pane_id, _plugin_id, highlights) = highlight_instruction.unwrap();
+ assert_eq!(pane_id, PaneId::Terminal(1));
+ assert_eq!(highlights.len(), 1);
+ assert_eq!(highlights[0].pattern, "test_pattern");
+ assert!(highlights[0].italic);
+ assert!(highlights[0].underline);
+}
+
+#[test]
+#[ignore]
+pub fn clear_pane_highlights_via_plugin() {
+ let temp_folder = tempdir().unwrap();
+ let plugin_host_folder = PathBuf::from(temp_folder.path());
+ let cache_path = plugin_host_folder.join("permissions_test.kdl");
+ let (plugin_thread_sender, screen_receiver, teardown) = create_plugin_thread(None, None);
+ let plugin_should_float = Some(false);
+ let plugin_title = Some("test_plugin".to_owned());
+ let run_plugin = RunPluginOrAlias::RunPlugin(RunPlugin {
+ _allow_exec_host_cmd: false,
+ location: RunPluginLocation::File(PathBuf::from(&*PLUGIN_FIXTURE)),
+ configuration: Default::default(),
+ ..Default::default()
+ });
+ let tab_index = 1;
+ let client_id = 1;
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let received_screen_instructions = Arc::new(Mutex::new(vec![]));
+ let screen_thread = grant_permissions_and_log_actions_in_thread_struct_variant!(
+ received_screen_instructions,
+ ScreenInstruction::ClearPluginHighlights,
+ screen_receiver,
+ 1,
+ &PermissionType::ChangeApplicationState,
+ cache_path,
+ plugin_thread_sender,
+ client_id
+ );
+
+ let _ = plugin_thread_sender.send(PluginInstruction::AddClient(client_id));
+ let _ = plugin_thread_sender.send(PluginInstruction::Load(
+ plugin_should_float,
+ false,
+ false, // close_replaced_pane
+ plugin_title,
+ run_plugin,
+ Some(tab_index),
+ None,
+ client_id,
+ size,
+ None,
+ None,
+ false,
+ None,
+ None,
+ None,
+ ));
+ std::thread::sleep(std::time::Duration::from_millis(500));
+
+ // Send Super+h key event to trigger clear_pane_highlights in the fixture plugin
+ let _ = plugin_thread_sender.send(PluginInstruction::Update(vec![(
+ None,
+ Some(client_id),
+ Event::Key(KeyWithModifier::new(BareKey::Char('h')).with_super_modifier()),
+ )]));
+
+ screen_thread.join().unwrap();
+ teardown();
+
+ let clear_instruction = received_screen_instructions
+ .lock()
+ .unwrap()
+ .iter()
+ .find_map(|i| {
+ if let ScreenInstruction::ClearPluginHighlights { pane_id, plugin_id } = i {
+ Some((*pane_id, *plugin_id))
+ } else {
+ None
+ }
+ });
+
+ assert!(
+ clear_instruction.is_some(),
+ "Expected ClearPluginHighlights instruction"
+ );
+ let (pane_id, _plugin_id) = clear_instruction.unwrap();
+ assert_eq!(pane_id, PaneId::Terminal(1));
+}
+
+#[test]
+#[ignore]
+pub fn highlight_clicked_event_delivered_to_plugin() {
+ let temp_folder = tempdir().unwrap();
+ let plugin_host_folder = PathBuf::from(temp_folder.path());
+ let cache_path = plugin_host_folder.join("permissions_test.kdl");
+ let (plugin_thread_sender, screen_receiver, teardown) = create_plugin_thread(None, None);
+ let plugin_should_float = Some(false);
+ let plugin_title = Some("test_plugin".to_owned());
+ let run_plugin = RunPluginOrAlias::RunPlugin(RunPlugin {
+ _allow_exec_host_cmd: false,
+ location: RunPluginLocation::File(PathBuf::from(&*PLUGIN_FIXTURE)),
+ configuration: Default::default(),
+ ..Default::default()
+ });
+ let tab_index = 1;
+ let client_id = 1;
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let received_screen_instructions = Arc::new(Mutex::new(vec![]));
+ let screen_thread = grant_permissions_and_log_actions_in_thread!(
+ received_screen_instructions,
+ ScreenInstruction::PluginBytes,
+ screen_receiver,
+ 2,
+ &PermissionType::ChangeApplicationState,
+ cache_path,
+ plugin_thread_sender,
+ client_id
+ );
+
+ let _ = plugin_thread_sender.send(PluginInstruction::AddClient(client_id));
+ let _ = plugin_thread_sender.send(PluginInstruction::Load(
+ plugin_should_float,
+ false,
+ false, // close_replaced_pane
+ plugin_title,
+ run_plugin,
+ Some(tab_index),
+ None,
+ client_id,
+ size,
+ None,
+ None,
+ false,
+ None,
+ None,
+ None,
+ ));
+ std::thread::sleep(std::time::Duration::from_millis(500));
+
+ // Send a HighlightClicked event directly to the plugin
+ let _ = plugin_thread_sender.send(PluginInstruction::HighlightClicked {
+ plugin_id: 0,
+ client_id,
+ pane_id: PaneId::Terminal(1),
+ pattern: "test".into(),
+ matched_string: "test_match".into(),
+ context: BTreeMap::new(),
+ });
+
+ screen_thread.join().unwrap();
+ teardown();
+
+ let plugin_bytes_event = received_screen_instructions
+ .lock()
+ .unwrap()
+ .iter()
+ .find_map(|i| {
+ if let ScreenInstruction::PluginBytes(plugin_render_assets) = i {
+ for plugin_render_asset in plugin_render_assets {
+ let plugin_bytes = plugin_render_asset.bytes.clone();
+ let plugin_bytes = String::from_utf8_lossy(plugin_bytes.as_slice()).to_string();
+ if plugin_bytes.contains("HighlightClicked") {
+ return Some(plugin_bytes);
+ }
+ }
+ }
+ None
+ });
+ assert!(
+ plugin_bytes_event.is_some(),
+ "Expected PluginBytes containing 'HighlightClicked'"
+ );
+ assert_snapshot!(format!("{:#?}", plugin_bytes_event));
+}
diff --git a/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__highlight_clicked_event_delivered_to_plugin.snap b/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__highlight_clicked_event_delivered_to_plugin.snap
new file mode 100644
index 00000000..f331b82a
--- /dev/null
+++ b/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__highlight_clicked_event_delivered_to_plugin.snap
@@ -0,0 +1,7 @@
+---
+source: zellij-server/src/plugins/./unit/plugin_tests.rs
+expression: "format!(\"{:#?}\", plugin_bytes_event)"
+---
+Some(
+ "Rows: 20, Cols: 121, Received events: [HighlightClicked { pane_id: Terminal(1), pattern: \"test\", matched_string: \"test_match\", context: {} }]\n\r",
+)
diff --git a/zellij-server/src/plugins/wasm_bridge.rs b/zellij-server/src/plugins/wasm_bridge.rs
index d82705ef..1c928eb1 100644
--- a/zellij-server/src/plugins/wasm_bridge.rs
+++ b/zellij-server/src/plugins/wasm_bridge.rs
@@ -514,6 +514,10 @@ impl WasmBridge {
.senders
.send_to_screen(ScreenInstruction::ClearKeyPressesIntercepts(client_id));
}
+ // Clear any regex highlights this plugin registered across all panes
+ let _ = self
+ .senders
+ .send_to_screen(ScreenInstruction::ClearAllPluginHighlights(plugin_id));
// Send worker exit messages
for (_worker_name, worker_sender) in workers {
@@ -2010,6 +2014,7 @@ fn check_event_permission(
| Event::CwdChanged(..)
| Event::AvailableLayoutInfo(..)
| Event::PluginConfigurationChanged(..)
+ | Event::HighlightClicked { .. }
| Event::InputReceived => PermissionType::ReadApplicationState,
Event::WebServerStatus(..) => PermissionType::StartWebServer,
Event::PaneRenderReport(..) => PermissionType::ReadPaneContents,
diff --git a/zellij-server/src/plugins/zellij_exports.rs b/zellij-server/src/plugins/zellij_exports.rs
index dd323977..d48a80b9 100644
--- a/zellij-server/src/plugins/zellij_exports.rs
+++ b/zellij-server/src/plugins/zellij_exports.rs
@@ -37,7 +37,7 @@ use zellij_utils::data::{
OpenTerminalInPlaceOfPluginResponse, OpenTerminalInPlaceResponse,
OpenTerminalNearPluginResponse, OpenTerminalPaneInPlaceOfPaneIdResponse, OpenTerminalResponse,
OriginatingPlugin, PaneScrollbackResponse, PermissionStatus, PermissionType, PluginPermission,
- RenameLayoutResponse, SaveLayoutResponse, TabMetadata,
+ RegexHighlight, RenameLayoutResponse, SaveLayoutResponse, TabMetadata,
};
use zellij_utils::home::default_layout_dir;
use zellij_utils::input::permission::PermissionCache;
@@ -739,6 +739,12 @@ fn host_run_plugin_command(mut caller: Caller<'_, PluginEnv>) {
),
PluginCommand::ShowFloatingPanes { tab_id } => show_floating_panes(env, tab_id),
PluginCommand::HideFloatingPanes { tab_id } => hide_floating_panes(env, tab_id),
+ PluginCommand::SetPaneRegexHighlights(pane_id, highlights) => {
+ set_pane_regex_highlights(env, pane_id, highlights)
+ },
+ PluginCommand::ClearPaneHighlights(pane_id) => {
+ clear_pane_highlights(env, pane_id)
+ },
},
(PermissionStatus::Denied, permission) => {
log::error!(
@@ -3387,6 +3393,35 @@ fn hide_floating_panes(env: &PluginEnv, tab_id: Option<usize>) {
let _ = wasi_write_object(env, &response.encode_to_vec());
}
+fn set_pane_regex_highlights(
+ env: &PluginEnv,
+ pane_id: zellij_utils::data::PaneId,
+ highlights: Vec<RegexHighlight>,
+) {
+ let err_context = || "failed to set pane regex highlights".to_string();
+ let pane_id: PaneId = pane_id.into();
+ env.senders
+ .send_to_screen(ScreenInstruction::SetPluginRegexHighlights {
+ pane_id,
+ plugin_id: env.plugin_id,
+ highlights,
+ })
+ .with_context(err_context)
+ .non_fatal();
+}
+
+fn clear_pane_highlights(env: &PluginEnv, pane_id: zellij_utils::data::PaneId) {
+ let err_context = || "failed to clear pane highlights".to_string();
+ let pane_id: PaneId = pane_id.into();
+ env.senders
+ .send_to_screen(ScreenInstruction::ClearPluginHighlights {
+ pane_id,
+ plugin_id: env.plugin_id,
+ })
+ .with_context(err_context)
+ .non_fatal();
+}
+
fn current_session_last_saved_time(env: &PluginEnv) {
use zellij_utils::plugin_api::plugin_command::ProtobufCurrentSessionLastSavedTimeResponse;
@@ -5086,7 +5121,9 @@ fn check_command_permission(
| PluginCommand::RenameLayout { .. }
| PluginCommand::EditLayout { .. }
| PluginCommand::ShowFloatingPanes { .. }
- | PluginCommand::HideFloatingPanes { .. } => PermissionType::ChangeApplicationState,
+ | PluginCommand::HideFloatingPanes { .. }
+ | PluginCommand::SetPaneRegexHighlights(..)
+ | PluginCommand::ClearPaneHighlights(..) => PermissionType::ChangeApplicationState,
PluginCommand::UnblockCliPipeInput(..)
| PluginCommand::BlockCliPipeInput(..)
| PluginCommand::CliPipeOutput(..) => PermissionType::ReadCliPipes,
diff --git a/zellij-server/src/screen.rs b/zellij-server/src/screen.rs
index 6a18042d..b84c2058 100644
--- a/zellij-server/src/screen.rs
+++ b/zellij-server/src/screen.rs
@@ -43,7 +43,8 @@ use zellij_utils::data::{
CommandOrPlugin, Direction, FloatingPaneCoordinates, GetFocusedPaneInfoResponse,
KeyWithModifier, LayoutInfo, LayoutWithError, ListPanesResponse, ListTabsResponse,
NewPanePlacement, PaneContents, PaneInfo, PaneListEntry, PaneManifest, PaneScrollbackResponse,
- PluginPermission, Resize, ResizeStrategy, SessionInfo, Styling, TabInfo, WebSharing,
+ PluginPermission, RegexHighlight, Resize, ResizeStrategy, SessionInfo, Styling, TabInfo,
+ WebSharing,
};
use zellij_utils::errors::prelude::*;
use zellij_utils::input::command::RunCommand;
@@ -670,6 +671,16 @@ pub enum ScreenInstruction {
WatcherTerminalResize(ClientId, Size),
ClearMouseHelpText(ClientId),
UpdateAvailableLayouts(Vec<LayoutInfo>, Vec<LayoutWithError>),
+ SetPluginRegexHighlights {
+ pane_id: PaneId,
+ plugin_id: u32,
+ highlights: Vec<RegexHighlight>,
+ },
+ ClearPluginHighlights {
+ pane_id: PaneId,
+ plugin_id: u32,
+ },
+ ClearAllPluginHighlights(u32), // plugin_id — clears across all panes
}
impl From<&ScreenInstruction> for ScreenContext {
@@ -933,6 +944,11 @@ impl From<&ScreenInstruction> for ScreenContext {
ScreenInstruction::WatcherTerminalResize(..) => ScreenContext::WatcherTerminalResize,
ScreenInstruction::ClearMouseHelpText(..) => ScreenContext::ClearMouseHelpText,
ScreenInstruction::UpdateAvailableLayouts(..) => ScreenContext::UpdateAvailableLayouts,
+ ScreenInstruction::SetPluginRegexHighlights { .. } => {
+ ScreenContext::SetPluginRegexHighlights
+ },
+ ScreenInstruction::ClearPluginHighlights { .. } => ScreenContext::ClearPluginHighlights,
+ ScreenInstruction::ClearAllPluginHighlights(..) => ScreenContext::ClearPluginHighlights,
}
}
}
@@ -7645,6 +7661,40 @@ pub(crate) fn screen_thread_main(
screen.render(None)?;
}
},
+ ScreenInstruction::SetPluginRegexHighlights {
+ pane_id,
+ plugin_id,
+ highlights,
+ } => {
+ let style = screen.style.clone();
+ let all_tabs = screen.get_tabs_mut();
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.set_plugin_regex_highlights_for_pane(
+ pane_id, plugin_id, highlights, &style,
+ );
+ break;
+ }
+ }
+ screen.render(None)?;
+ },
+ ScreenInstruction::ClearPluginHighlights { pane_id, plugin_id } => {
+ let all_tabs = screen.get_tabs_mut();
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.clear_plugin_highlights_for_pane(pane_id, plugin_id);
+ break;
+ }
+ }
+ screen.render(None)?;
+ },
+ ScreenInstruction::ClearAllPluginHighlights(plugin_id) => {
+ let all_tabs = screen.get_tabs_mut();
+ for tab in all_tabs.values_mut() {
+ tab.clear_all_plugin_highlights(plugin_id);
+ }
+ screen.render(None)?;
+ },
}
}
Ok(())
diff --git a/zellij-server/src/tab/mod.rs b/zellij-server/src/tab/mod.rs
index 49b232a3..861a3103 100644
--- a/zellij-server/src/tab/mod.rs
+++ b/zellij-server/src/tab/mod.rs
@@ -16,7 +16,7 @@ use uuid::Uuid;
use zellij_utils::data::PaneContents;
use zellij_utils::data::{
Direction, KeyWithModifier, NewPanePlacement, PaneInfo, PermissionStatus, PermissionType,
- PluginPermission, ResizeStrategy, WebSharing,
+ PluginPermission, RegexHighlight, ResizeStrategy, Style, WebSharing,
};
use zellij_utils::errors::prelude::*;
use zellij_utils::input::command::RunCommand;
@@ -55,9 +55,7 @@ use std::{
str,
};
use zellij_utils::{
- data::{
- Event, FloatingPaneCoordinates, InputMode, ModeInfo, Palette, PaletteColor, Style, Styling,
- },
+ data::{Event, FloatingPaneCoordinates, InputMode, ModeInfo, Palette, PaletteColor, Styling},
input::{
command::TerminalAction,
layout::{
@@ -608,6 +606,24 @@ pub trait Pane {
_get_full_scrollback: bool,
) -> PaneContents;
fn update_exit_status(&mut self, _exit_status: i32) {}
+ fn set_plugin_regex_highlights(
+ &mut self,
+ _plugin_id: u32,
+ _highlights: Vec<RegexHighlight>,
+ _style: &Style,
+ ) {
+ }
+ fn clear_plugin_highlights(&mut self, _plugin_id: u32) {}
+ fn set_hover_position(&mut self, _position: Option<Position>) {}
+ fn plugin_highlight_at(
+ &self,
+ _position: &Position,
+ ) -> Option<(u32, String, String, BTreeMap<String, String>)> {
+ None
+ }
+ fn terminal_emulator_wants_mouse(&self) -> bool {
+ false
+ }
}
#[derive(Clone, Debug)]
@@ -4726,6 +4742,32 @@ impl Tab {
pane.clear_pane_frame_color_override(client_id);
}
}
+ pub fn set_plugin_regex_highlights_for_pane(
+ &mut self,
+ pane_id: PaneId,
+ plugin_id: u32,
+ highlights: Vec<RegexHighlight>,
+ style: &Style,
+ ) {
+ if let Some(pane) = self.get_pane_with_id_mut(pane_id) {
+ pane.set_plugin_regex_highlights(plugin_id, highlights, style);
+ }
+ }
+
+ pub fn clear_all_plugin_highlights(&mut self, plugin_id: u32) {
+ for pane_id in self.get_all_pane_ids() {
+ if let Some(pane) = self.get_pane_with_id_mut(pane_id) {
+ pane.clear_plugin_highlights(plugin_id);
+ }
+ }
+ }
+
+ pub fn clear_plugin_highlights_for_pane(&mut self, pane_id: PaneId, plugin_id: u32) {
+ if let Some(pane) = self.get_pane_with_id_mut(pane_id) {
+ pane.clear_plugin_highlights(plugin_id);
+ }
+ }
+
pub fn update_plugin_loading_stage(&mut self, pid: u32, loading_indication: LoadingIndication) {
if let Some(plugin_pane) = self
.tiled_panes
diff --git a/zellij-server/src/tab/mouse_handler.rs b/zellij-server/src/tab/mouse_handler.rs
index 037c5acd..f98dc3f4 100644
--- a/zellij-server/src/tab/mouse_handler.rs
+++ b/zellij-server/src/tab/mouse_handler.rs
@@ -7,10 +7,25 @@ use zellij_utils::position::Position;
use crate::background_jobs::BackgroundJob;
use crate::panes::PaneId;
+use crate::plugins::PluginInstruction;
use crate::ClientId;
use super::{Pane, Tab};
+/// Remove the hover pane tracking for `client_id` and clear the hover position
+/// on the previously hovered pane (if any). Returns `true` if a pane was
+/// cleared.
+fn clear_hover_for_client(tab: &mut Tab, client_id: ClientId) -> bool {
+ if let Some(prev_pid) = tab.mouse_hover_pane_id.remove(&client_id) {
+ if let Some(pane) = tab.get_pane_with_id_mut(prev_pid) {
+ pane.set_hover_position(None);
+ }
+ true
+ } else {
+ false
+ }
+}
+
#[derive(Debug, Default, Copy, Clone)]
pub struct MouseEffect {
pub state_changed: bool,
@@ -136,6 +151,7 @@ enum MouseAction {
},
UpdateHover {
pane_id: Option<PaneId>,
+ position: Option<Position>,
},
SendToTerminal {
pane_id: PaneId,
@@ -636,7 +652,7 @@ impl MouseHandler {
is_floating: _,
position,
} => {
- tab.mouse_hover_pane_id.remove(&client_id);
+ clear_hover_for_client(tab, client_id);
Self::start_pane_resize_with_mouse(tab, pane_id, edge, position, client_id)
.with_context(err_context)?;
Ok(MouseEffect::state_changed())
@@ -667,6 +683,24 @@ impl MouseHandler {
.get_pane_with_id_mut(pane_id)
.ok_or_else(|| anyhow!("Failed to find pane {pane_id:?}"))?;
let relative_position = pane.relative_position(&position);
+
+ // Check if click lands on a plugin highlight
+ if let Some((hit_plugin_id, pattern, matched_string, context)) =
+ pane.plugin_highlight_at(&relative_position)
+ {
+ let _ = tab
+ .senders
+ .send_to_plugin(PluginInstruction::HighlightClicked {
+ plugin_id: hit_plugin_id,
+ client_id,
+ pane_id,
+ pattern,
+ matched_string,
+ context,
+ });
+ return Ok(MouseEffect::state_changed());
+ }
+
let mut leave_clipboard_message = false;
pane.start_selection(&relative_position, client_id);
if pane.get_selected_text(client_id).is_some() {
@@ -718,8 +752,8 @@ impl MouseHandler {
MouseAction::ResizeScrollDown { pane_id } => {
Self::handle_resize_scroll_down(tab, pane_id, client_id).with_context(err_context)
},
- MouseAction::UpdateHover { pane_id } => {
- Self::execute_update_hover(tab, pane_id, client_id)
+ MouseAction::UpdateHover { pane_id, position } => {
+ Self::execute_update_hover(tab, pane_id, position, client_id)
},
MouseAction::SendToTerminal { pane_id, event } => {
Self::execute_send_to_terminal(tab, pane_id, event, client_id)
@@ -762,7 +796,7 @@ impl MouseHandler {
client_id: ClientId,
) -> Result<MouseEffect> {
let err_context = || "failed to focus pane";
- tab.mouse_hover_pane_id.remove(&client_id);
+ clear_hover_for_client(tab, client_id);
let active_pane_id_before = tab
.get_active_pane_id(client_id)
.ok_or_else(|| anyhow!("Failed to find active pane"))?;
@@ -894,9 +928,11 @@ impl MouseHandler {
fn execute_update_hover(
tab: &mut Tab,
pane_id: Option<PaneId>,
+ _position: Option<Position>,
client_id: ClientId,
) -> Result<MouseEffect> {
let mut should_render = false;
+ let previous_hover_pane_id = tab.mouse_hover_pane_id.get(&client_id).copied();
match pane_id {
Some(pid) => {
if let Some(pane) = tab.get_pane_with_id(pid) {
@@ -917,6 +953,18 @@ impl MouseHandler {
},
}
+ // Clear hover position on previously hovered pane when the hovered
+ // pane has changed (or cursor left all panes). Hover position is
+ // intentionally not set on unfocused panes so that hover-only plugin
+ // highlights only activate on the focused pane.
+ if let Some(prev_pane_id) = previous_hover_pane_id {
+ if Some(prev_pane_id) != pane_id {
+ if let Some(pane) = tab.get_pane_with_id_mut(prev_pane_id) {
+ pane.set_hover_position(None);
+ }
+ }
+ }
+
if tab.mouse_help_text_visible.remove(&client_id).is_some() {
should_render = true;
}
@@ -954,10 +1002,23 @@ impl MouseHandler {
.with_context(err_context)?;
}
}
- let removed_hover = tab.mouse_hover_pane_id.remove(&client_id);
- if removed_hover.is_some() {
+ if clear_hover_for_client(tab, client_id) {
should_render = true;
}
+ // Update hover position on the active pane during motion events
+ // so that on_hover highlights work for the focused pane too.
+ // Skip when the terminal application has mouse tracking enabled,
+ // because hover highlights are suppressed in that case and the
+ // update would only trigger unnecessary re-renders.
+ if event.event_type == MouseEventType::Motion {
+ if let Some(pane) = tab.get_pane_with_id_mut(pane_id) {
+ if !pane.terminal_emulator_wants_mouse() {
+ let relative = pane.relative_position(&event.position);
+ pane.set_hover_position(Some(relative));
+ should_render = true;
+ }
+ }
+ }
if event.event_type == MouseEventType::Motion && tab.mouse_hover_effects {
tab.last_mouse_activity_time
@@ -1203,7 +1264,10 @@ impl MouseHandler {
&& !event.middle;
if is_buttonless_motion {
let Some(pane_id) = ctx.pane_id_at_position else {
- return Ok(MouseAction::UpdateHover { pane_id: None });
+ return Ok(MouseAction::UpdateHover {
+ pane_id: None,
+ position: None,
+ });
};
let is_active_pane = Some(pane_id) == ctx.active_pane_id;
if is_active_pane {
@@ -1214,6 +1278,7 @@ impl MouseHandler {
}
return Ok(MouseAction::UpdateHover {
pane_id: Some(pane_id),
+ position: Some(event.position),
});
}
diff --git a/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__hover_on_unfocused_pane_no_highlight.snap b/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__hover_on_unfocused_pane_no_highlight.snap
new file mode 100644
index 00000000..8070d41a
--- /dev/null
+++ b/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__hover_on_unfocused_pane_no_highlight.snap
@@ -0,0 +1,24 @@
+---
+source: zellij-server/src/tab/./unit/tab_integration_tests.rs
+expression: snapshot
+---
+00 (C): ┌ Pane #1 ──────────────────────────────────────────────────┐┌ Pane #2 ─────────────────────────────────────────────────┐
+01 (C): │link_text ││right pane │
+02 (C): │ ││ │
+03 (C): │ ││ │
+04 (C): │ ││ │
+05 (C): │ ││ │
+06 (C): │ ││ │
+07 (C): │ ││ │
+08 (C): │ ││ │
+09 (C): │ ││ │
+10 (C): │ ││ │
+11 (C): │ ││ │
+12 (C): │ ││ │
+13 (C): │ ││ │
+14 (C): │ ││ │
+15 (C): │ ││ │
+16 (C): │ ││ │
+17 (C): │ ││ │
+18 (C): │ ││ │
+19 (C): └ Alt <Click> - group, Alt <Right-Click> - ungroup all ─────┘└──────────────────────────────────────────────────────────┘
diff --git a/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__hover_over_highlight_shows_styling-2.snap b/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__hover_over_highlight_shows_styling-2.snap
new file mode 100644
index 00000000..2c23c59d
--- /dev/null
+++ b/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__hover_over_highlight_shows_styling-2.snap
@@ -0,0 +1,24 @@
+---
+source: zellij-server/src/tab/./unit/tab_integration_tests.rs
+expression: snapshot_without_hover
+---
+00 (C):
+01 (C): hello link_text bar
+02 (C):
+03 (C):
+04 (C):
+05 (C):
+06 (C):
+07 (C):
+08 (C):
+09 (C):
+10 (C):
+11 (C):
+12 (C):
+13 (C):
+14 (C):
+15 (C):
+16 (C):
+17 (C):
+18 (C):
+19 (C):
diff --git a/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__hover_over_highlight_shows_styling.snap b/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__hover_over_highlight_shows_styling.snap
new file mode 100644
index 00000000..d101a254
--- /dev/null
+++ b/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__hover_over_highlight_shows_styling.snap
@@ -0,0 +1,24 @@
+---
+source: zellij-server/src/tab/./unit/tab_integration_tests.rs
+expression: snapshot_with_hover
+---
+00 (C): ┌ Pane #1 ──────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
+01 (C): │hello link_text bar │
+02 (C): │ │
+03 (C): │ │
+04 (C): │ │
+05 (C): │ │
+06 (C): │ │
+07 (C): │ │
+08 (C): │ │
+09 (C): │ │
+10 (C): │ │
+11 (C): │ │
+12 (C): │ │
+13 (C): │ │
+14 (C): │ │
+15 (C): │ │
+16 (C): │ │
+17 (C): │ │
+18 (C): │ │
+19 (C): └───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
diff --git a/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__set_and_clear_highlights_across_tiled_and_floating.snap b/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__set_and_clear_highlights_across_tiled_and_floating.snap
new file mode 100644
index 00000000..8730fd6c
--- /dev/null
+++ b/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__set_and_clear_highlights_across_tiled_and_floating.snap
@@ -0,0 +1,24 @@
+---
+source: zellij-server/src/tab/./unit/tab_integration_tests.rs
+expression: snapshot
+---
+00 (C): ┌ Pane #1 ──────────────────────────────────────────────────┐┌ Pane #2 ─────────────────────────────────────────────────┐
+01 (C): │aaa bbb ││aaa bbb │
+02 (C): │ ││ │
+03 (C): │ ││ │
+04 (C): │ ││ │
+05 (C): │ ┌ Pane #3 ──────────────────────────────────────── PIN [ ] ┐ │
+06 (C): │ │aaa bbb │ │
+07 (C): │ │ │ │
+08 (C): │ │ │ │
+09 (C): │ │ │ │
+10 (C): │ │ │ │
+11 (C): │ │ │ │
+12 (C): │ │ │ │
+13 (C): │ │ │ │
+14 (C): │ └──────────────────────────────────────────────────────────┘ │
+15 (C): │ ││ │
+16 (C): │ ││ │
+17 (C): │ ││ │
+18 (C): │ ││ │
+19 (C): └───────────────────────────────────────────────────────────┘└──────────────────────────────────────────────────────────┘
diff --git a/zellij-server/src/tab/unit/tab_integration_tests.rs b/zellij-server/src/tab/unit/tab_integration_tests.rs
index bcd83279..ca68538c 100644
--- a/zellij-server/src/tab/unit/tab_integration_tests.rs
+++ b/zellij-server/src/tab/unit/tab_integration_tests.rs
@@ -12460,3 +12460,470 @@ fn in_place_pane_with_close_replaced_pane_true_closes_original() {
// Snapshot shows only pane 1 (pane 2 was permanently closed, not restored)
assert_snapshot!(snapshot);
}
+
+// =====================================================================
+// Plugin Highlight Mouse Integration Tests
+// =====================================================================
+
+use zellij_utils::data::{HighlightStyle, RegexHighlight};
+
+fn create_new_tab_with_plugin_receiver(
+ size: Size,
+ default_mode: ModeInfo,
+) -> (Tab, Receiver<(PluginInstruction, ErrorContext)>) {
+ set_session_name("test".into());
+ let index = 0;
+ let position = 0;
+ let name = String::new();
+ let os_api = Box::new(FakeInputOutput::default());
+ let mut senders = ThreadSenders::default().silently_fail_on_send();
+ let (mock_plugin_sender, mock_plugin_receiver): ChannelWithContext<PluginInstruction> =
+ channels::unbounded();
+ senders.replace_to_plugin(SenderWithContext::new(mock_plugin_sender));
+ let max_panes = None;
+ let mode_info = default_mode;
+ let style = Style::default();
+ let draw_pane_frames = true;
+ let auto_layout = true;
+ let client_id = 1;
+ let session_is_mirrored = true;
+ let mut connected_clients = HashMap::new();
+ connected_clients.insert(client_id, false);
+ let connected_clients = Rc::new(RefCell::new(connected_clients));
+ let character_cell_info = Rc::new(RefCell::new(None));
+ let stacked_resize = Rc::new(RefCell::new(true));
+ let terminal_emulator_colors = Rc::new(RefCell::new(Palette::default()));
+ let copy_options = CopyOptions::default();
+ let terminal_emulator_color_codes = Rc::new(RefCell::new(HashMap::new()));
+ let sixel_image_store = Rc::new(RefCell::new(SixelImageStore::default()));
+ let current_group = Rc::new(RefCell::new(PaneGroups::new(ThreadSenders::default())));
+ let currently_marking_pane_group = Rc::new(RefCell::new(HashMap::new()));
+ let debug = false;
+ let arrow_fonts = true;
+ let styled_underlines = true;
+ let osc8_hyperlinks = true;
+ let explicitly_disable_kitty_keyboard_protocol = false;
+ let advanced_mouse_actions = true;
+ let web_sharing = WebSharing::Off;
+ let web_server_ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
+ let web_server_port = 8080;
+ let mut tab = Tab::new(
+ index,
+ position,
+ name,
+ size,
+ character_cell_info,
+ stacked_resize,
+ sixel_image_store,
+ os_api,
+ senders,
+ max_panes,
+ style,
+ mode_info,
+ draw_pane_frames,
+ auto_layout,
+ connected_clients,
+ session_is_mirrored,
+ Some(client_id),
+ copy_options,
+ terminal_emulator_colors,
+ terminal_emulator_color_codes,
+ (vec![], vec![]),
+ PathBuf::from("my_default_shell"),
+ debug,
+ arrow_fonts,
+ styled_underlines,
+ osc8_hyperlinks,
+ explicitly_disable_kitty_keyboard_protocol,
+ None,
+ false,
+ web_sharing,
+ current_group,
+ currently_marking_pane_group,
+ advanced_mouse_actions,
+ true, // mouse_hover_effects
+ web_server_ip,
+ web_server_port,
+ );
+ tab.apply_layout(
+ TiledPaneLayout::default(),
+ vec![],
+ vec![(1, None)],
+ vec![],
+ HashMap::new(),
+ client_id,
+ None,
+ )
+ .unwrap();
+ (tab, mock_plugin_receiver)
+}
+
+#[test]
+fn click_on_plugin_highlight_sends_highlight_clicked() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let client_id = 1;
+ let (mut tab, mock_plugin_receiver) =
+ create_new_tab_with_plugin_receiver(size, ModeInfo::default());
+
+ // Feed PTY bytes to pane 1
+ tab.handle_pty_bytes(1, Vec::from("click here foo bar".as_bytes()))
+ .unwrap();
+
+ // Set plugin highlight on pane 1 for pattern "foo"
+ let highlights = vec![RegexHighlight {
+ pattern: "foo".into(),
+ style: HighlightStyle::Emphasis0,
+ context: BTreeMap::new(),
+ on_hover: false,
+ bold: false,
+ italic: false,
+ underline: true,
+ }];
+ tab.set_plugin_regex_highlights_for_pane(
+ PaneId::Terminal(1),
+ 42,
+ highlights,
+ &Style::default(),
+ );
+
+ // Left-click at position where "foo" appears
+ // With draw_pane_frames=true, content starts at row 1, col 1
+ // "click here foo bar" -> "foo" starts at offset 11, so col = 1 + 11 = 12
+ let click_position = Position::new(1, 12);
+ let effect = tab
+ .handle_mouse_event(&MouseEvent::new_left_press_event(click_position), client_id)
+ .unwrap();
+
+ assert!(effect.state_changed);
+
+ // Check that HighlightClicked was sent to the plugin channel
+ let mut found_highlight_clicked = false;
+ while let Ok((instruction, _ctx)) = mock_plugin_receiver.try_recv() {
+ if let PluginInstruction::HighlightClicked {
+ plugin_id,
+ pane_id,
+ pattern,
+ matched_string,
+ ..
+ } = instruction
+ {
+ assert_eq!(plugin_id, 42);
+ assert_eq!(pane_id, PaneId::Terminal(1));
+ assert_eq!(pattern, "foo");
+ assert_eq!(matched_string, "foo");
+ found_highlight_clicked = true;
+ break;
+ }
+ }
+ assert!(
+ found_highlight_clicked,
+ "Expected HighlightClicked instruction to be sent"
+ );
+}
+
+#[test]
+fn click_outside_highlight_starts_normal_selection() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let client_id = 1;
+ let (mut tab, mock_plugin_receiver) =
+ create_new_tab_with_plugin_receiver(size, ModeInfo::default());
+
+ tab.handle_pty_bytes(1, Vec::from("click here foo bar".as_bytes()))
+ .unwrap();
+
+ let highlights = vec![RegexHighlight {
+ pattern: "foo".into(),
+ style: HighlightStyle::Emphasis0,
+ context: BTreeMap::new(),
+ on_hover: false,
+ bold: false,
+ italic: false,
+ underline: true,
+ }];
+ tab.set_plugin_regex_highlights_for_pane(
+ PaneId::Terminal(1),
+ 42,
+ highlights,
+ &Style::default(),
+ );
+
+ // Click on "click" (col 1), not on "foo"
+ let click_position = Position::new(1, 1);
+ let _effect = tab
+ .handle_mouse_event(&MouseEvent::new_left_press_event(click_position), client_id)
+ .unwrap();
+
+ // No HighlightClicked should be sent
+ let mut found_highlight_clicked = false;
+ while let Ok((instruction, _ctx)) = mock_plugin_receiver.try_recv() {
+ if matches!(instruction, PluginInstruction::HighlightClicked { .. }) {
+ found_highlight_clicked = true;
+ }
+ }
+ assert!(
+ !found_highlight_clicked,
+ "HighlightClicked should NOT be sent for miss"
+ );
+
+ // Selection should have started
+ assert!(tab.selecting_with_mouse_in_pane.is_some());
+}
+
+#[test]
+fn hover_over_highlight_shows_styling() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let client_id = 1;
+ let mut tab = create_new_tab(size, ModeInfo::default());
+ let mut output = Output::default();
+
+ tab.handle_pty_bytes(1, Vec::from("hello link_text bar".as_bytes()))
+ .unwrap();
+
+ let highlights = vec![RegexHighlight {
+ pattern: "link_text".into(),
+ style: HighlightStyle::None,
+ context: BTreeMap::new(),
+ on_hover: true,
+ bold: false,
+ italic: true,
+ underline: true,
+ }];
+ tab.set_plugin_regex_highlights_for_pane(PaneId::Terminal(1), 1, highlights, &Style::default());
+
+ // Hover over the match (link_text starts at offset 6, with frame offset col = 7)
+ let hover_position = Position::new(1, 9);
+ let _effect = tab
+ .handle_mouse_event(
+ &MouseEvent::new_buttonless_motion(hover_position),
+ client_id,
+ )
+ .unwrap();
+
+ tab.render(&mut output, None).unwrap();
+ let snapshot_with_hover = take_snapshot(
+ output.serialize().unwrap().get(&client_id).unwrap(),
+ size.rows,
+ size.cols,
+ Palette::default(),
+ );
+ assert_snapshot!(snapshot_with_hover);
+
+ // Move hover away from the match
+ let mut output2 = Output::default();
+ let far_position = Position::new(1, 1);
+ let _effect2 = tab
+ .handle_mouse_event(&MouseEvent::new_buttonless_motion(far_position), client_id)
+ .unwrap();
+
+ tab.render(&mut output2, None).unwrap();
+ let snapshot_without_hover = take_snapshot(
+ output2.serialize().unwrap().get(&client_id).unwrap(),
+ size.rows,
+ size.cols,
+ Palette::default(),
+ );
+ assert_snapshot!(snapshot_without_hover);
+}
+
+#[test]
+fn hover_on_unfocused_pane_no_highlight() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let client_id = 1;
+ let mut tab = create_new_tab(size, ModeInfo::default());
+ let new_pane_id = PaneId::Terminal(2);
+ let mut output = Output::default();
+
+ // Create a vertical split
+ tab.vertical_split(new_pane_id, None, client_id, None, None)
+ .unwrap();
+
+ tab.handle_pty_bytes(1, Vec::from("link_text".as_bytes()))
+ .unwrap();
+ tab.handle_pty_bytes(2, Vec::from("right pane".as_bytes()))
+ .unwrap();
+
+ // Active pane is pane 2 (right)
+ assert_eq!(tab.get_active_pane_id(client_id), Some(PaneId::Terminal(2)));
+
+ // Set on_hover highlight on pane 1 (left, unfocused)
+ let highlights = vec![RegexHighlight {
+ pattern: "link_text".into(),
+ style: HighlightStyle::None,
+ context: BTreeMap::new(),
+ on_hover: true,
+ bold: false,
+ italic: true,
+ underline: true,
+ }];
+ tab.set_plugin_regex_highlights_for_pane(PaneId::Terminal(1), 1, highlights, &Style::default());
+
+ // Hover over left pane (unfocused)
+ let hover_position = Position::new(1, 5);
+ let _effect = tab
+ .handle_mouse_event(
+ &MouseEvent::new_buttonless_motion(hover_position),
+ client_id,
+ )
+ .unwrap();
+
+ tab.render(&mut output, None).unwrap();
+ let snapshot = take_snapshot(
+ output.serialize().unwrap().get(&client_id).unwrap(),
+ size.rows,
+ size.cols,
+ Palette::default(),
+ );
+ // Hover highlight should NOT be visible on the unfocused pane
+ assert_snapshot!(snapshot);
+}
+
+#[test]
+fn highlight_click_in_mouse_mode_pane_suppressed() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let client_id = 1;
+ let (mut tab, mock_plugin_receiver) =
+ create_new_tab_with_plugin_receiver(size, ModeInfo::default());
+
+ // Feed PTY bytes that enable mouse tracking (SGR mode + any-event tracking)
+ tab.handle_pty_bytes(1, Vec::from("\x1b[?1006h\x1b[?1003h".as_bytes()))
+ .unwrap();
+ // Then feed text with matchable content
+ tab.handle_pty_bytes(1, Vec::from("foo bar baz".as_bytes()))
+ .unwrap();
+
+ // Set plugin highlight
+ let highlights = vec![RegexHighlight {
+ pattern: "foo".into(),
+ style: HighlightStyle::Emphasis0,
+ context: BTreeMap::new(),
+ on_hover: false,
+ bold: false,
+ italic: false,
+ underline: true,
+ }];
+ tab.set_plugin_regex_highlights_for_pane(
+ PaneId::Terminal(1),
+ 42,
+ highlights,
+ &Style::default(),
+ );
+
+ // Left-click on the highlight position
+ // With mouse tracking, the click goes through SendToTerminal, not StartSelection
+ let click_position = Position::new(1, 1);
+ let _effect = tab
+ .handle_mouse_event(&MouseEvent::new_left_press_event(click_position), client_id)
+ .unwrap();
+
+ // No HighlightClicked should be sent (the click is forwarded to the terminal)
+ let mut found_highlight_clicked = false;
+ while let Ok((instruction, _ctx)) = mock_plugin_receiver.try_recv() {
+ if matches!(instruction, PluginInstruction::HighlightClicked { .. }) {
+ found_highlight_clicked = true;
+ }
+ }
+ assert!(
+ !found_highlight_clicked,
+ "HighlightClicked should NOT be sent when terminal has mouse tracking"
+ );
+}
+
+#[test]
+fn set_and_clear_highlights_across_tiled_and_floating() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let client_id = 1;
+ let mut tab = create_new_tab(size, ModeInfo::default());
+ let pane2 = PaneId::Terminal(2);
+ let pane3 = PaneId::Terminal(3);
+
+ // Create a second tiled pane
+ tab.vertical_split(pane2, None, client_id, None, None)
+ .unwrap();
+
+ // Create a floating pane
+ tab.toggle_floating_panes(Some(client_id), None, None)
+ .unwrap();
+ tab.new_pane(
+ pane3,
+ None,
+ None,
+ false,
+ true,
+ NewPanePlacement::default(),
+ Some(client_id),
+ None,
+ )
+ .unwrap();
+
+ // Feed content to all panes
+ tab.handle_pty_bytes(1, Vec::from("aaa bbb".as_bytes()))
+ .unwrap();
+ tab.handle_pty_bytes(2, Vec::from("aaa bbb".as_bytes()))
+ .unwrap();
+ tab.handle_pty_bytes(3, Vec::from("aaa bbb".as_bytes()))
+ .unwrap();
+
+ // Set highlights from plugin 1 (pattern "aaa") on all panes
+ let h1 = vec![RegexHighlight {
+ pattern: "aaa".into(),
+ style: HighlightStyle::Emphasis0,
+ context: BTreeMap::new(),
+ on_hover: false,
+ bold: false,
+ italic: false,
+ underline: true,
+ }];
+ tab.set_plugin_regex_highlights_for_pane(PaneId::Terminal(1), 1, h1.clone(), &Style::default());
+ tab.set_plugin_regex_highlights_for_pane(pane2, 1, h1.clone(), &Style::default());
+ tab.set_plugin_regex_highlights_for_pane(pane3, 1, h1.clone(), &Style::default());
+
+ // Set highlights from plugin 2 (pattern "bbb") on all panes
+ let h2 = vec![RegexHighlight {
+ pattern: "bbb".into(),
+ style: HighlightStyle::Emphasis1,
+ context: BTreeMap::new(),
+ on_hover: false,
+ bold: false,
+ italic: false,
+ underline: false,
+ }];
+ tab.set_plugin_regex_highlights_for_pane(PaneId::Terminal(1), 2, h2.clone(), &Style::default());
+ tab.set_plugin_regex_highlights_for_pane(pane2, 2, h2.clone(), &Style::default());
+ tab.set_plugin_regex_highlights_for_pane(pane3, 2, h2.clone(), &Style::default());
+
+ // Clear plugin 1 highlights across all panes
+ tab.clear_all_plugin_highlights(1);
+
+ // Verify plugin 2 highlights remain — we can use the tab render to snapshot,
+ // or directly check that plugin 2 pattern still matches on pane 1
+ // Since clear_all_plugin_highlights iterates all panes, we test via
+ // the set_plugin_regex_highlights_for_pane / clear flow:
+ // Plugin 2 "bbb" highlights should still be active.
+ let mut output = Output::default();
+ tab.render(&mut output, None).unwrap();
+ let snapshot = take_snapshot(
+ output.serialize().unwrap().get(&client_id).unwrap(),
+ size.rows,
+ size.cols,
+ Palette::default(),
+ );
+ assert_snapshot!(snapshot);
+}
diff --git a/zellij-tile/src/shim.rs b/zellij-tile/src/shim.rs
index 2837d08e..8c40b28a 100644
--- a/zellij-tile/src/shim.rs
+++ b/zellij-tile/src/shim.rs
@@ -2749,6 +2749,37 @@ pub fn hide_floating_panes(tab_id: Option<usize>) -> Result<bool, String> {
}
}
+/// Set or update regex-based content highlights for a pane.
+///
+/// Each entry in `highlights` is keyed by its `pattern` string. Calling this
+/// function again with the same pattern updates its style; new patterns are
+/// added; patterns not present in this call are kept. To remove all highlights
+/// for this plugin on the pane, use `clear_pane_highlights`.
+///
+/// Pattern matching is performed by the server against the current viewport at
+/// render time. The plugin never handles coordinates, so there is no race
+/// condition between content changes and highlight application.
+///
+/// Requires `ChangeApplicationState` permission.
+pub fn set_pane_regex_highlights(pane_id: PaneId, highlights: Vec<RegexHighlight>) {
+ let plugin_command = PluginCommand::SetPaneRegexHighlights(pane_id, highlights);
+ let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
+ object_to_stdout(&protobuf_plugin_command.encode_to_vec());
+ unsafe { host_run_plugin_command() };
+}
+
+/// Remove all regex highlights this plugin has set on the given pane.
+///
+/// Other plugins' highlights on the same pane are not affected.
+///
+/// Requires `ChangeApplicationState` permission.
+pub fn clear_pane_highlights(pane_id: PaneId) {
+ let plugin_command = PluginCommand::ClearPaneHighlights(pane_id);
+ let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
+ object_to_stdout(&protobuf_plugin_command.encode_to_vec());
+ unsafe { host_run_plugin_command() };
+}
+
#[link(wasm_import_module = "zellij")]
extern "C" {
fn host_run_plugin_command();
diff --git a/zellij-utils/assets/config/default.kdl b/zellij-utils/assets/config/default.kdl
index 8c6cb627..3ea42850 100644
--- a/zellij-utils/assets/config/default.kdl
+++ b/zellij-utils/assets/config/default.kdl
@@ -246,8 +246,9 @@ plugins {
// Plugins to load in the background when a new session starts
load_plugins {
- // "file:/path/to/my-plugin.wasm"
- // "https://example.com/my-plugin.wasm"
+ "zellij:link"
+ // "file:/path/to/my-plugin.wasm"
+ // "https://example.com/my-plugin.wasm"
}
// Choose what to do when zellij receives SIGTERM, SIGINT, SIGQUIT or SIGHUP
diff --git a/zellij-utils/assets/plugins/about.wasm b/zellij-utils/assets/plugins/about.wasm
index aab775a5..393ff56e 100755
--- a/zellij-utils/assets/plugins/about.wasm
+++ b/zellij-utils/assets/plugins/about.wasm
Binary files differ
diff --git a/zellij-utils/assets/plugins/compact-bar.wasm b/zellij-utils/assets/plugins/compact-bar.wasm
index 2719eaa4..a5ae98c5 100755
--- a/zellij-utils/assets/plugins/compact-bar.wasm
+++ b/zellij-utils/assets/plugins/compact-bar.wasm
Binary files differ
diff --git a/zellij-utils/assets/plugins/configuration.wasm b/zellij-utils/assets/plugins/configuration.wasm
index d5111f1a..bc47d8ce 100755
--- a/zellij-utils/assets/plugins/configuration.wasm
+++ b/zellij-utils/assets/plugins/configuration.wasm
Binary files differ
diff --git a/zellij-utils/assets/plugins/fixture-plugin-for-tests.wasm b/zellij-utils/assets/plugins/fixture-plugin-for-tests.wasm
index 1db435ba..d84e92dd 100755
--- a/zellij-utils/assets/plugins/fixture-plugin-for-tests.wasm
+++ b/zellij-utils/assets/plugins/fixture-plugin-for-tests.wasm
Binary files differ
diff --git a/zellij-utils/assets/plugins/layout-manager.wasm b/zellij-utils/assets/plugins/layout-manager.wasm
index e147d179..a7f0164f 100755
--- a/zellij-utils/assets/plugins/layout-manager.wasm
+++ b/zellij-utils/assets/plugins/layout-manager.wasm
Binary files differ
diff --git a/zellij-utils/assets/plugins/link.wasm b/zellij-utils/assets/plugins/link.wasm
new file mode 100755
index 00000000..0b8be5c3
--- /dev/null
+++ b/zellij-utils/assets/plugins/link.wasm
Binary files differ
diff --git a/zellij-utils/assets/plugins/multiple-select.wasm b/zellij-utils/assets/plugins/multiple-select.wasm
index 43676c22..45bb9a2a 100755
--- a/zellij-utils/assets/plugins/multiple-select.wasm
+++ b/zellij-utils/assets/plugins/multiple-select.wasm
Binary files differ
diff --git a/zellij-utils/assets/plugins/plugin-manager.wasm b/zellij-utils/assets/plugins/plugin-manager.wasm
index ef666220..706563c1 100755
--- a/zellij-utils/assets/plugins/plugin-manager.wasm
+++ b/zellij-utils/assets/plugins/plugin-manager.wasm
Binary files differ
diff --git a/zellij-utils/assets/plugins/session-manager.wasm b/zellij-utils/assets/plugins/session-manager.wasm
index 6f556f01..04494e13 100755
--- a/zellij-utils/assets/plugins/session-manager.wasm
+++ b/zellij-utils/assets/plugins/session-manager.wasm
Binary files differ
diff --git a/zellij-utils/assets/plugins/share.wasm b/zellij-utils/assets/plugins/share.wasm
index 99606d71..9ee67571 100755
--- a/zellij-utils/assets/plugins/share.wasm
+++ b/zellij-utils/assets/plugins/share.wasm
Binary files differ
diff --git a/zellij-utils/assets/plugins/status-bar.wasm b/zellij-utils/assets/plugins/status-bar.wasm
index f5db526c..69036d5c 100755
--- a/zellij-utils/assets/plugins/status-bar.wasm
+++ b/zellij-utils/assets/plugins/status-bar.wasm
Binary files differ
diff --git a/zellij-utils/assets/plugins/strider.wasm b/zellij-utils/assets/plugins/strider.wasm
index 735f74a9..5e457f65 100755
--- a/zellij-utils/assets/plugins/strider.wasm
+++ b/zellij-utils/assets/plugins/strider.wasm
Binary files differ
diff --git a/zellij-utils/assets/plugins/tab-bar.wasm b/zellij-utils/assets/plugins/tab-bar.wasm
index fc2d6f1c..6e111f04 100755
--- a/zellij-utils/assets/plugins/tab-bar.wasm
+++ b/zellij-utils/assets/plugins/tab-bar.wasm
Binary files differ
diff --git a/zellij-utils/assets/prost/api.event.rs b/zellij-utils/assets/prost/api.event.rs
index 04ccac58..37702467 100644
--- a/zellij-utils/assets/prost/api.event.rs
+++ b/zellij-utils/assets/prost/api.event.rs
@@ -9,7 +9,7 @@ pub struct EventNameList {
pub struct Event {
#[prost(enumeration="EventType", tag="1")]
pub name: i32,
- #[prost(oneof="event::Payload", tags="2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35")]
+ #[prost(oneof="event::Payload", tags="2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36")]
pub payload: ::core::option::Option<event::Payload>,
}
/// Nested message and enum types in `Event`.
@@ -85,6 +85,8 @@ pub mod event {
AvailableLayoutInfoPayload(super::AvailableLayoutInfoPayload),
#[prost(message, tag="35")]
PluginConfigurationChangedPayload(super::PluginConfigurationChangedPayload),
+ #[prost(message, tag="36")]
+ HighlightClickedPayload(super::HighlightClickedPayload),
}
}
#[allow(clippy::derive_partial_eq_without_eq)]
@@ -727,6 +729,18 @@ pub struct PluginConfigurationChangedPayload {
#[prost(message, repeated, tag="1")]
pub configuration: ::prost::alloc::vec::Vec<ContextItem>,
}
+#[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct HighlightClickedPayload {
+ #[prost(message, optional, tag="1")]
+ pub pane_id: ::core::option::Option<PaneId>,
+ #[prost(string, tag="2")]
+ pub pattern: ::prost::alloc::string::String,
+ #[prost(string, tag="3")]
+ pub matched_string: ::prost::alloc::string::String,
+ #[prost(message, repeated, tag="4")]
+ pub context: ::prost::alloc::vec::Vec<ContextItem>,
+}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)]
#[repr(i32)]
pub enum EventType {
@@ -786,6 +800,7 @@ pub enum EventType {
CwdChanged = 39,
AvailableLayoutInfo = 40,
PluginConfigurationChanged = 41,
+ HighlightClicked = 42,
}
impl EventType {
/// String value of the enum field names used in the ProtoBuf definition.
@@ -835,6 +850,7 @@ impl EventType {
EventType::CwdChanged => "CwdChanged",
EventType::AvailableLayoutInfo => "AvailableLayoutInfo",
EventType::PluginConfigurationChanged => "PluginConfigurationChanged",
+ EventType::HighlightClicked => "HighlightClicked",
}
}
/// Creates an enum from field names used in the ProtoBuf definition.
@@ -881,6 +897,7 @@ impl EventType {
"CwdChanged" => Some(Self::CwdChanged),
"AvailableLayoutInfo" => Some(Self::AvailableLayoutInfo),
"PluginConfigurationChanged" => Some(Self::PluginConfigurationChanged),
+ "HighlightClicked" => Some(Self::HighlightClicked),
_ => None,
}
}
diff --git a/zellij-utils/assets/prost/api.plugin_command.rs b/zellij-utils/assets/prost/api.plugin_command.rs
index 887e2e30..2296b2da 100644
--- a/zellij-utils/assets/prost/api.plugin_command.rs
+++ b/zellij-utils/assets/prost/api.plugin_command.rs
@@ -3,7 +3,7 @@
pub struct PluginCommand {
#[prost(enumeration="CommandName", tag="1")]
pub name: i32,
- #[prost(oneof="plugin_command::Payload", tags="2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157")]
+ #[prost(oneof="plugin_command::Payload", tags="2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159")]
pub payload: ::core::option::Option<plugin_command::Payload>,
}
/// Nested message and enum types in `PluginCommand`.
@@ -297,6 +297,10 @@ pub mod plugin_command {
ShowFloatingPanesPayload(super::ShowFloatingPanesPayload),
#[prost(message, tag="157")]
SetPaneColorPayload(super::SetPaneColorPayload),
+ #[prost(message, tag="158")]
+ SetPaneRegexHighlightsPayload(super::SetPaneRegexHighlightsPayload),
+ #[prost(message, tag="159")]
+ ClearPaneHighlightsPayload(super::ClearPaneHighlightsPayload),
}
}
#[allow(clippy::derive_partial_eq_without_eq)]
@@ -1780,6 +1784,97 @@ pub struct SetPaneColorPayload {
#[prost(string, optional, tag="3")]
pub bg: ::core::option::Option<::prost::alloc::string::String>,
}
+#[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct HighlightStyle {
+ #[prost(oneof="highlight_style::Style", tags="1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11")]
+ pub style: ::core::option::Option<highlight_style::Style>,
+}
+/// Nested message and enum types in `HighlightStyle`.
+pub mod highlight_style {
+ #[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, PartialEq, ::prost::Oneof)]
+ pub enum Style {
+ #[prost(bool, tag="1")]
+ Emphasis0(bool),
+ #[prost(bool, tag="2")]
+ Emphasis1(bool),
+ #[prost(bool, tag="3")]
+ Emphasis2(bool),
+ #[prost(bool, tag="4")]
+ Emphasis3(bool),
+ #[prost(message, tag="5")]
+ CustomRgb(super::CustomRgbHighlight),
+ #[prost(message, tag="6")]
+ CustomIndex(super::CustomIndexHighlight),
+ #[prost(bool, tag="7")]
+ BackgroundEmphasis0(bool),
+ #[prost(bool, tag="8")]
+ BackgroundEmphasis1(bool),
+ #[prost(bool, tag="9")]
+ BackgroundEmphasis2(bool),
+ #[prost(bool, tag="10")]
+ BackgroundEmphasis3(bool),
+ #[prost(bool, tag="11")]
+ None(bool),
+ }
+}
+#[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct CustomRgbHighlight {
+ #[prost(uint32, optional, tag="1")]
+ pub fg_r: ::core::option::Option<u32>,
+ #[prost(uint32, optional, tag="2")]
+ pub fg_g: ::core::option::Option<u32>,
+ #[prost(uint32, optional, tag="3")]
+ pub fg_b: ::core::option::Option<u32>,
+ #[prost(uint32, optional, tag="4")]
+ pub bg_r: ::core::option::Option<u32>,
+ #[prost(uint32, optional, tag="5")]
+ pub bg_g: ::core::option::Option<u32>,
+ #[prost(uint32, optional, tag="6")]
+ pub bg_b: ::core::option::Option<u32>,
+}
+#[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct CustomIndexHighlight {
+ #[prost(uint32, optional, tag="1")]
+ pub fg: ::core::option::Option<u32>,
+ #[prost(uint32, optional, tag="2")]
+ pub bg: ::core::option::Option<u32>,
+}
+#[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct RegexHighlight {
+ #[prost(string, tag="1")]
+ pub pattern: ::prost::alloc::string::String,
+ #[prost(message, optional, tag="2")]
+ pub style: ::core::option::Option<HighlightStyle>,
+ #[prost(message, repeated, tag="3")]
+ pub context: ::prost::alloc::vec::Vec<ContextItem>,
+ #[prost(bool, tag="4")]
+ pub on_hover: bool,
+ #[prost(bool, tag="5")]
+ pub bold: bool,
+ #[prost(bool, tag="6")]
+ pub italic: bool,
+ #[prost(bool, tag="7")]
+ pub underline: bool,
+}
+#[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct SetPaneRegexHighlightsPayload {
+ #[prost(message, optional, tag="1")]
+ pub pane_id: ::core::option::Option<PaneId>,
+ #[prost(message, repeated, tag="2")]
+ pub highlights: ::prost::alloc::vec::Vec<RegexHighlight>,
+}
+#[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct ClearPaneHighlightsPayload {
+ #[prost(message, optional, tag="1")]
+ pub pane_id: ::core::option::Option<PaneId>,
+}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)]
#[repr(i32)]
pub enum CommandName {
@@ -1969,6 +2064,8 @@ pub enum CommandName {
HideFloatingPanes = 204,
ShowFloatingPanes = 205,
SetPaneColor = 206,
+ SetPaneRegexHighlights = 207,
+ ClearPaneHighlights = 208,
}
impl CommandName {
/// String value of the enum field names used in the ProtoBuf definition.
@@ -2163,6 +2260,8 @@ impl CommandName {
CommandName::HideFloatingPanes => "HideFloatingPanes",
CommandName::ShowFloatingPanes => "ShowFloatingPanes",
CommandName::SetPaneColor => "SetPaneColor",
+ CommandName::SetPaneRegexHighlights => "SetPaneRegexHighlights",
+ CommandName::ClearPaneHighlights => "ClearPaneHighlights",
}
}
/// Creates an enum from field names used in the ProtoBuf definition.
@@ -2354,6 +2453,8 @@ impl CommandName {
"HideFloatingPanes" => Some(Self::HideFloatingPanes),
"ShowFloatingPanes" => Some(Self::ShowFloatingPanes),
"SetPaneColor" => Some(Self::SetPaneColor),
+ "SetPaneRegexHighlights" => Some(Self::SetPaneRegexHighlights),
+ "ClearPaneHighlights" => Some(Self::ClearPaneHighlights),
_ => None,
}
}
diff --git a/zellij-utils/src/consts.rs b/zellij-utils/src/consts.rs
index 1f039c72..beecb2ad 100644
--- a/zellij-utils/src/consts.rs
+++ b/zellij-utils/src/consts.rs
@@ -139,6 +139,7 @@ mod not_wasm {
add_plugin!(assets, "share.wasm");
add_plugin!(assets, "multiple-select.wasm");
add_plugin!(assets, "layout-manager.wasm");
+ add_plugin!(assets, "link.wasm");
assets
};
}
diff --git a/zellij-utils/src/data.rs b/zellij-utils/src/data.rs
index 4a7088c0..64f1d33c 100644
--- a/zellij-utils/src/data.rs
+++ b/zellij-utils/src/data.rs
@@ -1013,6 +1013,12 @@ pub enum Event {
CwdChanged(PaneId, PathBuf, Vec<ClientId>), // pane_id, cwd, focused_client_ids
AvailableLayoutInfo(Vec<LayoutInfo>, Vec<LayoutWithError>),
PluginConfigurationChanged(BTreeMap<String, String>),
+ HighlightClicked {
+ pane_id: PaneId,
+ pattern: String,
+ matched_string: String,
+ context: BTreeMap<String, String>,
+ },
}
#[derive(Debug, Clone, PartialEq, Eq, EnumDiscriminants, Display, Serialize, Deserialize)]
@@ -1197,6 +1203,41 @@ impl Default for PaletteColor {
}
}
+/// Style for a plugin-supplied regex highlight.
+/// Theme-based variants reference `style.colors.text_unselected.*`.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum HighlightStyle {
+ None, // no color override — use with bold/italic/underline for style-only highlights
+ Emphasis0, // fg = emphasis_0, no bg override
+ Emphasis1, // fg = emphasis_1, no bg override
+ Emphasis2, // fg = emphasis_2, no bg override
+ Emphasis3, // fg = emphasis_3, no bg override
+ BackgroundEmphasis0, // bg = emphasis_0, fg = background
+ BackgroundEmphasis1, // bg = emphasis_1, fg = background
+ BackgroundEmphasis2, // bg = emphasis_2, fg = background
+ BackgroundEmphasis3, // bg = emphasis_3, fg = background
+ CustomRgb {
+ fg: Option<(u8, u8, u8)>,
+ bg: Option<(u8, u8, u8)>,
+ },
+ CustomIndex {
+ fg: Option<u8>,
+ bg: Option<u8>,
+ },
+}
+
+/// One pattern + style pair sent by a plugin.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct RegexHighlight {
+ pub pattern: String, // key for upsert; also the regex source
+ pub style: HighlightStyle,
+ pub context: BTreeMap<String, String>, // arbitrary data echoed back verbatim on click
+ pub on_hover: bool, // if true, only rendered when the cursor overlaps this match
+ pub bold: bool,
+ pub italic: bool,
+ pub underline: bool,
+}
+
// these are used for the web client
impl PaletteColor {
pub fn as_rgb_str(&self) -> String {
@@ -3476,6 +3517,8 @@ pub enum PluginCommand {
tab_id: Option<usize>,
},
SetPaneColor(PaneId, Option<String>, Option<String>), // (pane_id, fg, bg)
+ SetPaneRegexHighlights(PaneId, Vec<RegexHighlight>),
+ ClearPaneHighlights(PaneId),
}
// Response type for plugin API methods that open a pane in a new tab
diff --git a/zellij-utils/src/errors.rs b/zellij-utils/src/errors.rs
index 928c8901..aec1d0f0 100644
--- a/zellij-utils/src/errors.rs
+++ b/zellij-utils/src/errors.rs
@@ -413,6 +413,8 @@ pub enum ScreenContext {
SetFollowedClient,
WatcherTerminalResize,
ClearMouseHelpText,
+ SetPluginRegexHighlights,
+ ClearPluginHighlights,
}
/// Stack call representations corresponding to the different types of [`PtyInstruction`]s.
@@ -495,6 +497,7 @@ pub enum PluginContext {
UpdateSessionSaveTime,
GetLastSessionSaveTime,
DetectPluginConfigChanges,
+ HighlightClicked,
}
/// Stack call representations corresponding to the different types of [`ClientInstruction`]s.
diff --git a/zellij-utils/src/input/plugins.rs b/zellij-utils/src/input/plugins.rs
index 69af52f9..31e48b43 100644
--- a/zellij-utils/src/input/plugins.rs
+++ b/zellij-utils/src/input/plugins.rs
@@ -68,6 +68,7 @@ impl PluginConfig {
|| tag == "share"
|| tag == "multiple-select"
|| tag == "layout-manager"
+ || tag == "link"
{
Some(PluginConfig {
path: PathBuf::from(&tag),
diff --git a/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string.snap b/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string.snap
index bc5feeff..6eda59b9 100644
--- a/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string.snap
+++ b/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string.snap
@@ -271,6 +271,7 @@ plugins {
}
}
load_plugins {
+ zellij:link
}
web_client {
font "monospace"
diff --git a/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string_with_comments.snap b/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string_with_comments.snap
index da967bcb..3dbaf47d 100644
--- a/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string_with_comments.snap
+++ b/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string_with_comments.snap
@@ -278,6 +278,7 @@ plugins {
// eg. "file:/path/to/my-plugin.wasm"
// eg. "https://example.com/my-plugin.wasm"
load_plugins {
+ zellij:link
}
web_client {
font "monospace"
diff --git a/zellij-utils/src/plugin_api/event.proto b/zellij-utils/src/plugin_api/event.proto
index bbee50a3..ce018a7f 100644
--- a/zellij-utils/src/plugin_api/event.proto
+++ b/zellij-utils/src/plugin_api/event.proto
@@ -64,6 +64,7 @@ enum EventType {
CwdChanged = 39;
AvailableLayoutInfo = 40;
PluginConfigurationChanged = 41;
+ HighlightClicked = 42;
}
message EventNameList {
@@ -107,6 +108,7 @@ message Event {
CwdChangedPayload cwd_changed_payload = 33;
AvailableLayoutInfoPayload available_layout_info_payload = 34;
PluginConfigurationChangedPayload plugin_configuration_changed_payload = 35;
+ HighlightClickedPayload highlight_clicked_payload = 36;
}
}
@@ -516,3 +518,10 @@ message PluginConfigurationChangedPayload {
repeated ContextItem configuration = 1;
}
+message HighlightClickedPayload {
+ PaneId pane_id = 1;
+ string pattern = 2;
+ string matched_string = 3;
+ repeated ContextItem context = 4;
+}
+
diff --git a/zellij-utils/src/plugin_api/event.rs b/zellij-utils/src/plugin_api/event.rs
index 631d5069..267e1357 100644
--- a/zellij-utils/src/plugin_api/event.rs
+++ b/zellij-utils/src/plugin_api/event.rs
@@ -500,6 +500,26 @@ impl TryFrom<ProtobufEvent> for Event {
},
_ => Err("Malformed payload for PluginConfigurationChanged Event"),
},
+ Some(ProtobufEventType::HighlightClicked) => match protobuf_event.payload {
+ Some(ProtobufEventPayload::HighlightClickedPayload(p)) => {
+ let pane_id = p
+ .pane_id
+ .ok_or("Missing pane_id in HighlightClicked")?
+ .try_into()?;
+ let context = p
+ .context
+ .into_iter()
+ .map(|item| (item.name, item.value))
+ .collect();
+ Ok(Event::HighlightClicked {
+ pane_id,
+ pattern: p.pattern,
+ matched_string: p.matched_string,
+ context,
+ })
+ },
+ _ => Err("Malformed payload for HighlightClicked Event"),
+ },
None => Err("Unknown Protobuf Event"),
}
}
@@ -991,6 +1011,25 @@ impl TryFrom<Event> for ProtobufEvent {
payload: Some(event::Payload::PluginConfigurationChangedPayload(payload)),
})
},
+ Event::HighlightClicked {
+ pane_id,
+ pattern,
+ matched_string,
+ context,
+ } => Ok(ProtobufEvent {
+ name: ProtobufEventType::HighlightClicked as i32,
+ payload: Some(event::Payload::HighlightClickedPayload(
+ HighlightClickedPayload {
+ pane_id: pane_id.try_into().ok(),
+ pattern,
+ matched_string,
+ context: context
+ .into_iter()
+ .map(|(name, value)| ProtobufContextItem { name, value })
+ .collect(),
+ },
+ )),
+ }),
}
}
}
@@ -1901,6 +1940,7 @@ impl TryFrom<ProtobufEventType> for EventType {
ProtobufEventType::CwdChanged => EventType::CwdChanged,
ProtobufEventType::AvailableLayoutInfo => EventType::AvailableLayoutInfo,
ProtobufEventType::PluginConfigurationChanged => EventType::PluginConfigurationChanged,
+ ProtobufEventType::HighlightClicked => EventType::HighlightClicked,
})
}
}
@@ -1950,6 +1990,7 @@ impl TryFrom<EventType> for ProtobufEventType {
EventType::CwdChanged => ProtobufEventType::CwdChanged,
EventType::AvailableLayoutInfo => ProtobufEventType::AvailableLayoutInfo,
EventType::PluginConfigurationChanged => ProtobufEventType::PluginConfigurationChanged,
+ EventType::HighlightClicked => ProtobufEventType::HighlightClicked,
})
}
}
diff --git a/zellij-utils/src/plugin_api/plugin_command.proto b/zellij-utils/src/plugin_api/plugin_command.proto
index 53e9d729..c4fa949f 100644
--- a/zellij-utils/src/plugin_api/plugin_command.proto
+++ b/zellij-utils/src/plugin_api/plugin_command.proto
@@ -199,6 +199,8 @@ enum CommandName {
HideFloatingPanes = 204;
ShowFloatingPanes = 205;
SetPaneColor = 206;
+ SetPaneRegexHighlights = 207;
+ ClearPaneHighlights = 208;
}
message PluginCommand {
@@ -347,6 +349,8 @@ message PluginCommand {
HideFloatingPanesPayload hide_floating_panes_payload = 155;
ShowFloatingPanesPayload show_floating_panes_payload = 156;
SetPaneColorPayload set_pane_color_payload = 157;
+ SetPaneRegexHighlightsPayload set_pane_regex_highlights_payload = 158;
+ ClearPaneHighlightsPayload clear_pane_highlights_payload = 159;
}
}
@@ -1220,3 +1224,52 @@ message SetPaneColorPayload {
optional string fg = 2;
optional string bg = 3;
}
+
+message HighlightStyle {
+ oneof style {
+ bool emphasis_0 = 1;
+ bool emphasis_1 = 2;
+ bool emphasis_2 = 3;
+ bool emphasis_3 = 4;
+ CustomRgbHighlight custom_rgb = 5;
+ CustomIndexHighlight custom_index = 6;
+ bool background_emphasis_0 = 7;
+ bool background_emphasis_1 = 8;
+ bool background_emphasis_2 = 9;
+ bool background_emphasis_3 = 10;
+ bool none = 11;
+ }
+}
+
+message CustomRgbHighlight {
+ optional uint32 fg_r = 1;
+ optional uint32 fg_g = 2;
+ optional uint32 fg_b = 3;
+ optional uint32 bg_r = 4;
+ optional uint32 bg_g = 5;
+ optional uint32 bg_b = 6;
+}
+
+message CustomIndexHighlight {
+ optional uint32 fg = 1;
+ optional uint32 bg = 2;
+}
+
+message RegexHighlight {
+ string pattern = 1;
+ HighlightStyle style = 2;
+ repeated ContextItem context = 3;
+ bool on_hover = 4;
+ bool bold = 5;
+ bool italic = 6;
+ bool underline = 7;
+}
+
+message SetPaneRegexHighlightsPayload {
+ PaneId pane_id = 1;
+ repeated RegexHighlight highlights = 2;
+}
+
+message ClearPaneHighlightsPayload {
+ PaneId pane_id = 1;
+}
diff --git a/zellij-utils/src/plugin_api/plugin_command.rs b/zellij-utils/src/plugin_api/plugin_command.rs
index abd9a994..b535e14a 100644
--- a/zellij-utils/src/plugin_api/plugin_command.rs
+++ b/zellij-utils/src/plugin_api/plugin_command.rs
@@ -7,7 +7,8 @@ pub use super::generated_api::api::{
break_panes_to_tab_with_index_response, delete_layout_response, dump_layout_response,
dump_session_layout_response, edit_layout_response, focus_or_create_tab_response,
get_focused_pane_info_response, get_pane_cwd_response, get_pane_pid_response,
- get_pane_running_command_response, hide_floating_panes_response, new_tab_response,
+ get_pane_running_command_response, hide_floating_panes_response,
+ highlight_style::Style as ProtobufHighlightStyleVariant, new_tab_response,
parse_layout_response, plugin_command::Payload, rename_layout_response,
save_layout_response, save_session_response, show_floating_panes_response,
BreakPanesToNewTabPayload,
@@ -16,17 +17,19 @@ pub use super::generated_api::api::{
BreakPanesToTabWithIdResponse as ProtobufBreakPanesToTabWithIdResponse,
BreakPanesToTabWithIndexPayload,
BreakPanesToTabWithIndexResponse as ProtobufBreakPanesToTabWithIndexResponse,
- ChangeFloatingPanesCoordinatesPayload, ChangeHostFolderPayload,
+ ChangeFloatingPanesCoordinatesPayload, ChangeHostFolderPayload, ClearPaneHighlightsPayload,
ClearScreenForPaneIdPayload, CliPipeOutputPayload, CloseMultiplePanesPayload,
CloseTabWithIdPayload, CloseTabWithIndexPayload, CommandName, ContextItem,
CopyToClipboardPayload, CreateTokenResponse as ProtobufCreateTokenResponse,
CreateTokenResponse, CurrentSessionLastSavedTimePayload,
CurrentSessionLastSavedTimeResponse as ProtobufCurrentSessionLastSavedTimeResponse,
- CursorPosition, DeleteLayoutPayload, DeleteLayoutResponse as ProtobufDeleteLayoutResponse,
- DumpLayoutPayload, DumpLayoutResponse as ProtobufDumpLayoutResponse,
- DumpSessionLayoutPayload, DumpSessionLayoutResponse as ProtobufDumpSessionLayoutResponse,
- EditLayoutPayload, EditLayoutResponse as ProtobufEditLayoutResponse,
- EditScrollbackForPaneWithIdPayload, EmbedMultiplePanesPayload, EnvVariable, ExecCmdPayload,
+ CursorPosition, CustomIndexHighlight as ProtobufCustomIndexHighlight,
+ CustomRgbHighlight as ProtobufCustomRgbHighlight, DeleteLayoutPayload,
+ DeleteLayoutResponse as ProtobufDeleteLayoutResponse, DumpLayoutPayload,
+ DumpLayoutResponse as ProtobufDumpLayoutResponse, DumpSessionLayoutPayload,
+ DumpSessionLayoutResponse as ProtobufDumpSessionLayoutResponse, EditLayoutPayload,
+ EditLayoutResponse as ProtobufEditLayoutResponse, EditScrollbackForPaneWithIdPayload,
+ EmbedMultiplePanesPayload, EnvVariable, ExecCmdPayload,
FixedOrPercent as ProtobufFixedOrPercent,
FixedOrPercentValue as ProtobufFixedOrPercentValue, FloatMultiplePanesPayload,
FloatingPaneCoordinates as ProtobufFloatingPaneCoordinates,
@@ -48,10 +51,11 @@ pub use super::generated_api::api::{
GetTabInfoPayload, GetTabInfoResponse as ProtobufGetTabInfoResponse, GoToTabWithIdPayload,
GroupAndUngroupPanesPayload, HideFloatingPanesPayload as ProtobufHideFloatingPanesPayload,
HideFloatingPanesResponse as ProtobufHideFloatingPanesResponse, HidePaneWithIdPayload,
- HighlightAndUnhighlightPanesPayload, HttpVerb as ProtobufHttpVerb, IdAndNewName,
- KeyToRebind, KeyToUnbind, KillSessionsPayload, ListTokensResponse, LoadNewPluginPayload,
- MessageToPluginPayload, MovePaneWithPaneIdInDirectionPayload, MovePaneWithPaneIdPayload,
- MovePayload, NewPluginArgs as ProtobufNewPluginArgs, NewTabPayload,
+ HighlightAndUnhighlightPanesPayload, HighlightStyle as ProtobufHighlightStyle,
+ HttpVerb as ProtobufHttpVerb, IdAndNewName, KeyToRebind, KeyToUnbind, KillSessionsPayload,
+ ListTokensResponse, LoadNewPluginPayload, MessageToPluginPayload,
+ MovePaneWithPaneIdInDirectionPayload, MovePaneWithPaneIdPayload, MovePayload,
+ NewPluginArgs as ProtobufNewPluginArgs, NewTabPayload,
NewTabResponse as ProtobufNewTabResponse, NewTabsResponse as ProtobufNewTabsResponse,
NewTabsWithLayoutInfoPayload,
OpenCommandPaneBackgroundResponse as ProtobufOpenCommandPaneBackgroundResponse,
@@ -92,19 +96,19 @@ pub use super::generated_api::api::{
PageScrollDownInPaneIdPayload, PageScrollUpInPaneIdPayload, PaneId as ProtobufPaneId,
PaneIdAndFloatingPaneCoordinates, PaneType as ProtobufPaneType, ParseLayoutPayload,
ParseLayoutResponse as ProtobufParseLayoutResponse, PluginCommand as ProtobufPluginCommand,
- PluginMessagePayload, RebindKeysPayload, ReconfigurePayload, ReloadPluginPayload,
- RenameLayoutPayload, RenameLayoutResponse as ProtobufRenameLayoutResponse,
- RenameTabWithIdPayload, RenameWebLoginTokenPayload, RenameWebTokenResponse,
- ReplacePaneWithExistingPanePayload, RequestPluginPermissionPayload,
- RerunCommandPanePayload, ResizePaneIdWithDirectionPayload, ResizePayload,
- RevokeAllWebTokensResponse, RevokeTokenResponse, RevokeWebLoginTokenPayload,
+ PluginMessagePayload, RebindKeysPayload, ReconfigurePayload,
+ RegexHighlight as ProtobufRegexHighlight, ReloadPluginPayload, RenameLayoutPayload,
+ RenameLayoutResponse as ProtobufRenameLayoutResponse, RenameTabWithIdPayload,
+ RenameWebLoginTokenPayload, RenameWebTokenResponse, ReplacePaneWithExistingPanePayload,
+ RequestPluginPermissionPayload, RerunCommandPanePayload, ResizePaneIdWithDirectionPayload,
+ ResizePayload, RevokeAllWebTokensResponse, RevokeTokenResponse, RevokeWebLoginTokenPayload,
RunActionPayload, RunCommandPayload, RunningCommand as ProtobufRunningCommand,
SaveLayoutPayload, SaveLayoutResponse as ProtobufSaveLayoutResponse, SaveSessionPayload,
SaveSessionResponse as ProtobufSaveSessionResponse, ScrollDownInPaneIdPayload,
ScrollToBottomInPaneIdPayload, ScrollToTopInPaneIdPayload, ScrollUpInPaneIdPayload,
SetFloatingPanePinnedPayload, SetPaneBorderlessPayload, SetPaneColorPayload,
- SetSelfMouseSelectionSupportPayload, SetTimeoutPayload, ShowCursorPayload,
- ShowFloatingPanesPayload as ProtobufShowFloatingPanesPayload,
+ SetPaneRegexHighlightsPayload, SetSelfMouseSelectionSupportPayload, SetTimeoutPayload,
+ ShowCursorPayload, ShowFloatingPanesPayload as ProtobufShowFloatingPanesPayload,
ShowFloatingPanesResponse as ProtobufShowFloatingPanesResponse, ShowPaneWithIdPayload,
StackPanesPayload, SubscribePayload, SwitchSessionPayload, SwitchTabToIdPayload,
SwitchTabToPayload, TogglePaneBorderlessPayload, TogglePaneEmbedOrEjectForPaneIdPayload,
@@ -118,8 +122,9 @@ pub use super::generated_api::api::{
use crate::data::{
ConnectToSession, DeleteLayoutResponse, EditLayoutResponse, FloatingPaneCoordinates,
GetFocusedPaneInfoResponse, GetPaneCwdResponse, GetPanePidResponse,
- GetPaneRunningCommandResponse, HttpVerb, InputMode, KeyWithModifier, MessageToPlugin,
- NewPluginArgs, PaneId, PermissionType, PluginCommand, RenameLayoutResponse, SaveLayoutResponse,
+ GetPaneRunningCommandResponse, HighlightStyle, HttpVerb, InputMode, KeyWithModifier,
+ MessageToPlugin, NewPluginArgs, PaneId, PermissionType, PluginCommand, RegexHighlight,
+ RenameLayoutResponse, SaveLayoutResponse,
};
use crate::input::actions::Action;
use crate::input::layout::PercentOrFixed;
@@ -2434,6 +2439,100 @@ impl TryFrom<ProtobufPluginCommand> for PluginCommand {
},
_ => Err("Mismatched payload for SetPaneColor"),
},
+ Some(CommandName::SetPaneRegexHighlights) => {
+ match protobuf_plugin_command.payload {
+ Some(Payload::SetPaneRegexHighlightsPayload(p)) => {
+ let pane_id: PaneId = p
+ .pane_id
+ .ok_or("Missing pane_id in SetPaneRegexHighlights")?
+ .try_into()?;
+ let highlights = p
+ .highlights
+ .into_iter()
+ .map(|h| {
+ let style = match h.style.and_then(|s| s.style) {
+ Some(ProtobufHighlightStyleVariant::Emphasis0(_)) => {
+ HighlightStyle::Emphasis0
+ },
+ Some(ProtobufHighlightStyleVariant::Emphasis1(_)) => {
+ HighlightStyle::Emphasis1
+ },
+ Some(ProtobufHighlightStyleVariant::Emphasis2(_)) => {
+ HighlightStyle::Emphasis2
+ },
+ Some(ProtobufHighlightStyleVariant::Emphasis3(_)) => {
+ HighlightStyle::Emphasis3
+ },
+ Some(ProtobufHighlightStyleVariant::CustomRgb(c)) => {
+ HighlightStyle::CustomRgb {
+ fg: match (c.fg_r, c.fg_g, c.fg_b) {
+ (Some(r), Some(g), Some(b)) => {
+ Some((r as u8, g as u8, b as u8))
+ },
+ _ => None,
+ },
+ bg: match (c.bg_r, c.bg_g, c.bg_b) {
+ (Some(r), Some(g), Some(b)) => {
+ Some((r as u8, g as u8, b as u8))
+ },
+ _ => None,
+ },
+ }
+ },
+ Some(ProtobufHighlightStyleVariant::CustomIndex(c)) => {
+ HighlightStyle::CustomIndex {
+ fg: c.fg.map(|v| v as u8),
+ bg: c.bg.map(|v| v as u8),
+ }
+ },
+ Some(ProtobufHighlightStyleVariant::BackgroundEmphasis0(_)) => {
+ HighlightStyle::BackgroundEmphasis0
+ },
+ Some(ProtobufHighlightStyleVariant::BackgroundEmphasis1(_)) => {
+ HighlightStyle::BackgroundEmphasis1
+ },
+ Some(ProtobufHighlightStyleVariant::BackgroundEmphasis2(_)) => {
+ HighlightStyle::BackgroundEmphasis2
+ },
+ Some(ProtobufHighlightStyleVariant::BackgroundEmphasis3(_)) => {
+ HighlightStyle::BackgroundEmphasis3
+ },
+ Some(ProtobufHighlightStyleVariant::None(_)) => {
+ HighlightStyle::None
+ },
+ None => HighlightStyle::Emphasis0, // fallback
+ };
+ let context = h
+ .context
+ .into_iter()
+ .map(|item| (item.name, item.value))
+ .collect();
+ RegexHighlight {
+ pattern: h.pattern,
+ style,
+ context,
+ on_hover: h.on_hover,
+ bold: h.bold,
+ italic: h.italic,
+ underline: h.underline,
+ }
+ })
+ .collect();
+ Ok(PluginCommand::SetPaneRegexHighlights(pane_id, highlights))
+ },
+ _ => Err("Mismatched payload for SetPaneRegexHighlights"),
+ }
+ },
+ Some(CommandName::ClearPaneHighlights) => match protobuf_plugin_command.payload {
+ Some(Payload::ClearPaneHighlightsPayload(p)) => {
+ let pane_id: PaneId = p
+ .pane_id
+ .ok_or("Missing pane_id in ClearPaneHighlights")?
+ .try_into()?;
+ Ok(PluginCommand::ClearPaneHighlights(pane_id))
+ },
+ _ => Err("Mismatched payload for ClearPaneHighlights"),
+ },
None => Err("Unrecognized plugin command"),
}
}
@@ -3994,6 +4093,93 @@ impl TryFrom<PluginCommand> for ProtobufPluginCommand {
bg,
})),
}),
+ PluginCommand::SetPaneRegexHighlights(pane_id, highlights) => {
+ Ok(ProtobufPluginCommand {
+ name: CommandName::SetPaneRegexHighlights as i32,
+ payload: Some(Payload::SetPaneRegexHighlightsPayload(
+ SetPaneRegexHighlightsPayload {
+ pane_id: pane_id.try_into().ok(),
+ highlights: highlights
+ .into_iter()
+ .map(|h| {
+ let style_variant = match h.style {
+ HighlightStyle::Emphasis0 => {
+ ProtobufHighlightStyleVariant::Emphasis0(true)
+ },
+ HighlightStyle::Emphasis1 => {
+ ProtobufHighlightStyleVariant::Emphasis1(true)
+ },
+ HighlightStyle::Emphasis2 => {
+ ProtobufHighlightStyleVariant::Emphasis2(true)
+ },
+ HighlightStyle::Emphasis3 => {
+ ProtobufHighlightStyleVariant::Emphasis3(true)
+ },
+ HighlightStyle::CustomRgb { fg, bg } => {
+ ProtobufHighlightStyleVariant::CustomRgb(
+ ProtobufCustomRgbHighlight {
+ fg_r: fg.map(|(r, _, _)| r as u32),
+ fg_g: fg.map(|(_, g, _)| g as u32),
+ fg_b: fg.map(|(_, _, b)| b as u32),
+ bg_r: bg.map(|(r, _, _)| r as u32),
+ bg_g: bg.map(|(_, g, _)| g as u32),
+ bg_b: bg.map(|(_, _, b)| b as u32),
+ },
+ )
+ },
+ HighlightStyle::CustomIndex { fg, bg } => {
+ ProtobufHighlightStyleVariant::CustomIndex(
+ ProtobufCustomIndexHighlight {
+ fg: fg.map(|v| v as u32),
+ bg: bg.map(|v| v as u32),
+ },
+ )
+ },
+ HighlightStyle::BackgroundEmphasis0 => {
+ ProtobufHighlightStyleVariant::BackgroundEmphasis0(true)
+ },
+ HighlightStyle::BackgroundEmphasis1 => {
+ ProtobufHighlightStyleVariant::BackgroundEmphasis1(true)
+ },
+ HighlightStyle::BackgroundEmphasis2 => {
+ ProtobufHighlightStyleVariant::BackgroundEmphasis2(true)
+ },
+ HighlightStyle::BackgroundEmphasis3 => {
+ ProtobufHighlightStyleVariant::BackgroundEmphasis3(true)
+ },
+ HighlightStyle::None => {
+ ProtobufHighlightStyleVariant::None(true)
+ },
+ };
+ ProtobufRegexHighlight {
+ pattern: h.pattern,
+ style: Some(ProtobufHighlightStyle {
+ style: Some(style_variant),
+ }),
+ context: h
+ .context
+ .into_iter()
+ .map(|(name, value)| ContextItem { name, value })
+ .collect(),
+ on_hover: h.on_hover,
+ bold: h.bold,
+ italic: h.italic,
+ underline: h.underline,
+ }
+ })
+ .collect(),
+ },
+ )),
+ })
+ },
+ PluginCommand::ClearPaneHighlights(pane_id) => Ok(ProtobufPluginCommand {
+ name: CommandName::ClearPaneHighlights as i32,
+ payload: Some(Payload::ClearPaneHighlightsPayload(
+ ClearPaneHighlightsPayload {
+ pane_id: pane_id.try_into().ok(),
+ },
+ )),
+ }),
}
}
}
diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments.snap
index 1684ff37..cffcf18a 100644
--- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments.snap
+++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments.snap
@@ -6152,7 +6152,22 @@ Config {
},
},
env: {},
- background_plugins: {},
+ background_plugins: {
+ RunPlugin(
+ RunPlugin {
+ _allow_exec_host_cmd: false,
+ location: Zellij(
+ PluginTag(
+ "link",
+ ),
+ ),
+ configuration: PluginUserConfiguration(
+ {},
+ ),
+ initial_cwd: None,
+ },
+ ),
+ },
web_client: WebClientConfig {
font: "monospace",
theme: None,
diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_env_vars_override_config_env_vars.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_env_vars_override_config_env_vars.snap
index 01603ece..cb241bca 100644
--- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_env_vars_override_config_env_vars.snap
+++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_env_vars_override_config_env_vars.snap
@@ -6156,7 +6156,22 @@ Config {
"LAYOUT_ENV_VAR": "make sure I'm also here",
"MY_ENV_VAR": "from layout",
},
- background_plugins: {},
+ background_plugins: {
+ RunPlugin(
+ RunPlugin {
+ _allow_exec_host_cmd: false,
+ location: Zellij(
+ PluginTag(
+ "link",
+ ),
+ ),
+ configuration: PluginUserConfiguration(
+ {},
+ ),
+ initial_cwd: None,
+ },
+ ),
+ },
web_client: WebClientConfig {
font: "monospace",
theme: None,
diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_keybinds_override_config_keybinds.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_keybinds_override_config_keybinds.snap
index 3ced7620..993a67ed 100644
--- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_keybinds_override_config_keybinds.snap
+++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_keybinds_override_config_keybinds.snap
@@ -270,7 +270,22 @@ Config {
},
},
env: {},
- background_plugins: {},
+ background_plugins: {
+ RunPlugin(
+ RunPlugin {
+ _allow_exec_host_cmd: false,
+ location: Zellij(
+ PluginTag(
+ "link",
+ ),
+ ),
+ configuration: PluginUserConfiguration(
+ {},
+ ),
+ initial_cwd: None,
+ },
+ ),
+ },
web_client: WebClientConfig {
font: "monospace",
theme: None,
diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_themes_override_config_themes.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_themes_override_config_themes.snap
index ac52b53f..14e0b053 100644
--- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_themes_override_config_themes.snap
+++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_themes_override_config_themes.snap
@@ -7923,7 +7923,22 @@ Config {
},
},
env: {},
- background_plugins: {},
+ background_plugins: {
+ RunPlugin(
+ RunPlugin {
+ _allow_exec_host_cmd: false,
+ location: Zellij(
+ PluginTag(
+ "link",
+ ),
+ ),
+ configuration: PluginUserConfiguration(
+ {},
+ ),
+ initial_cwd: None,
+ },
+ ),
+ },
web_client: WebClientConfig {
font: "monospace",
theme: None,
diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_ui_config_overrides_config_ui_config.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_ui_config_overrides_config_ui_config.snap
index 927928e0..57125f2d 100644
--- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_ui_config_overrides_config_ui_config.snap
+++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_ui_config_overrides_config_ui_config.snap
@@ -6152,7 +6152,22 @@ Config {
},
},
env: {},
- background_plugins: {},
+ background_plugins: {
+ RunPlugin(
+ RunPlugin {
+ _allow_exec_host_cmd: false,
+ location: Zellij(
+ PluginTag(
+ "link",
+ ),
+ ),
+ configuration: PluginUserConfiguration(
+ {},
+ ),
+ initial_cwd: None,
+ },
+ ),
+ },
web_client: WebClientConfig {
font: "monospace",
theme: None,