From 5521a08f19ad7cef696c5a34fa2966a5231b47cb Mon Sep 17 00:00:00 2001 From: Josh Lane <79638+lanej@users.noreply.github.com> Date: Fri, 6 Nov 2026 21:22:19 -0800 Subject: [PATCH 01/18] feat(zellij): add command completion and live target tables --- .github/workflows/zellij.yml | 13 +- docs/zellij.md | 12 +- scripts/test-zellij-command-prompt.py | 88 +++- zellij/command-prompt/Cargo.lock | 2 + zellij/command-prompt/Cargo.toml | 2 + zellij/command-prompt/README.md | 112 +++-- zellij/command-prompt/src/commands.rs | 45 +- zellij/command-prompt/src/completion.rs | 448 +++++++++++++++++++ zellij/command-prompt/src/main.rs | 288 +++++++----- zellij/command-prompt/src/palette.rs | 563 ++++++++++++++++++++++++ 10 files changed, 1366 insertions(+), 207 deletions(-) create mode 100644 zellij/command-prompt/src/completion.rs create mode 100644 zellij/command-prompt/src/palette.rs diff --git a/.github/workflows/zellij.yml b/.github/workflows/zellij.yml index 74d79a5c..580d0da7 100644 --- a/.github/workflows/zellij.yml +++ b/.github/workflows/zellij.yml @@ -160,6 +160,17 @@ jobs: ~/.cargo/git ~/.cache/dotfiles/zellij key: ${{ steps.zellij_cache.outputs.cache-primary-key }} + - name: Exercise command palette + if: ${{ !cancelled() && steps.zellij_install.outcome == 'success' }} + run: make test-zellij-command-prompt + - name: Preserve command palette terminal captures + if: always() + uses: actions/upload-artifact@v4 + with: + name: command-palette-${{ matrix.os }} + path: ${{ github.workspace }}/.zellij-diagnostics/command-palette + if-no-files-found: ignore + retention-days: 7 - name: Install Treehouse 3.1.6 shell: bash run: | @@ -193,8 +204,6 @@ jobs: if: ${{ !cancelled() && steps.zellij_install.outcome == 'success' }} - run: make test-zellij-tab-labels if: ${{ !cancelled() && steps.zellij_install.outcome == 'success' }} - - run: make test-zellij-command-prompt - if: ${{ !cancelled() && steps.zellij_install.outcome == 'success' }} - run: make test-zellij-codex-titles if: ${{ !cancelled() && steps.zellij_install.outcome == 'success' }} - run: make test-zellij-claude-titles diff --git a/docs/zellij.md b/docs/zellij.md index 6c21af30..91dd0c43 100644 --- a/docs/zellij.md +++ b/docs/zellij.md @@ -101,7 +101,7 @@ Actual isolated PTY captures: [before](images/zellij-prefix/before.png), [waitin | `g`, `G` | Golden-ratio left-pane width; `G` forces resizing past the width guard | | `Space`, `Ctrl-o` | Next/previous columns, rows, stacked, or equalize layout | | `P` | Open the shared persistent popup; the same key inside it hides that view | -| `:` | Open a centered Zellij command prompt; `help` lists actions and `history` recalls this client's commands | +| `:` | Open the command palette with completion, live target tables, help and history | | `F` | Show/hide the current tab's other floating panes | | `Ctrl-q` | Confirm shutdown of the current Zellij session | | `u`, `i` | Return to a containing Zellij session / enter the focused nested session | @@ -124,15 +124,7 @@ Automatic ownership requires an exact match with the last name written by the ho The configuration-only/0.45.1 fallback still uses Zellij's built-in bar and one-based keys (`0` also selects the first tab). That bar has no base-index option; this change does not replace it or alter the runtime. To use the zero-based bar, install the pinned 0.46 runtime and build the status plugin. Avoid manual names matching Zellij's generated forms (`Tab #` and `Pane #`): its metadata does not expose their origin, so the custom UI can treat them as fallbacks. Other manual names are retained verbatim. -**Command prompt:** On the pinned runtime, `Ctrl-f :` accepts Zellij action syntax -without `zellij action`, such as `rename-tab "work"`, `new-pane --direction right`, -or `move-focus left`. Enter runs it and Esc cancels. `help` opens the action picker; -`history` recalls the client's in-memory command history for review. This is a -Zellij command line, not a shell or tmux command interpreter. It uses the native -popup layer, leaves hidden floating panes hidden, and targets the invoking client. -For an existing pinned installation, run `make zellij-command-prompt`, apply -`make zellij`, and restart. See the [command-prompt guide](../zellij/command-prompt/README.md) -for supported scope, quoting, permissions and verification. +**Command palette:** On the pinned runtime, `Ctrl-f :` accepts Zellij action syntax such as `rename-tab "work"`, with inline suggestions and argument help. Tab completes the selected action, flag or live target; Enter runs the entered command; Esc closes it. Ctrl-p/Ctrl-n recall this client's history and restore its draft. `help` and `history` open searchable lists. `list-panes`, `list-tabs`, and `list-sessions` open live tables; Enter jumps to the selected target. Parse errors remain editable. The native popup preserves hidden floating panes and dispatches actions for the invoking client. Rebuild with `make zellij-command-prompt`, apply `make zellij`, and restart. See the [command-palette guide](../zellij/command-prompt/README.md) for syntax, permissions and limits. `Ctrl-f =`, `g`, and `G` use Bash and jq with the pinned runtime’s background commands. They resize existing panes without changing focus or recreating shells. Equalize balances one contiguous aligned row or column containing the invoking pane; ambiguous grids, stacks, zoom, floating panes, and embedded plugin panes are rejected. The golden controls require one full-height leftmost pane: `g` skips when its content width is already at least 120 columns; otherwise the target is floor(tab width × 0.382), capped at 180 content columns. `G` ignores the 120-column guard. diff --git a/scripts/test-zellij-command-prompt.py b/scripts/test-zellij-command-prompt.py index b8b72ecd..9591fc86 100644 --- a/scripts/test-zellij-command-prompt.py +++ b/scripts/test-zellij-command-prompt.py @@ -8,6 +8,7 @@ import codecs import importlib +import json import os from pathlib import Path import re @@ -118,6 +119,21 @@ def text(terminal): return '\n'.join(screen(terminal)) +def capture(terminal, name): + """Retain the actual decoded terminal grid for visual review.""" + destination = os.environ.get('ZELLIJ_TEST_ARTIFACTS') + if not destination: + return + screen(terminal) + display = terminal.command_display.display + cells = [[{'text': display.buffer[y][x].data, 'fg': display.buffer[y][x].fg, + 'bg': display.buffer[y][x].bg, 'bold': display.buffer[y][x].bold} + for x in range(display.columns)] for y in range(display.lines)] + directory = Path(destination) / 'command-palette' + directory.mkdir(parents=True, exist_ok=True) + (directory / (name + '.json')).write_text(json.dumps(cells, ensure_ascii=False)) + + def open_prompt(terminal): # Esc and accepted actions close asynchronously. A stale title must not # satisfy the next open and let its keystrokes enter the previous dialog. @@ -130,7 +146,8 @@ def open_prompt(terminal): print("Decoded prompt screen:\n" + text(terminal), flush=True) raise title_rows = [row for row, line in enumerate(screen(terminal)) if "Zellij command" in line] - assert title_rows and all(7 <= row <= 23 for row in title_rows), 'Prompt is not vertically centered' + footer_rows = [row for row, line in enumerate(screen(terminal)) if 'Esc close' in line] + assert title_rows and footer_rows and 27 <= title_rows[0] + footer_rows[0] <= 36, 'Palette is not vertically centered' assert all(10 <= screen(terminal)[row].index('Zellij command') <= 90 for row in title_rows), 'Prompt is not horizontally centered' # A native prompt renders over the existing panes; it must not create a @@ -233,7 +250,7 @@ def unchanged(): first.until(command + " is displayed", lambda: result in text(first)) unchanged() first.send(b'\r') - guidance = 'Review the command' if command == 'history' else 'Add arguments' + guidance = 'Tab complete' first.until(command + ' selection returns to editable input', lambda: guidance in text(first)) unchanged() first.send(b'\x1b') @@ -242,7 +259,7 @@ def unchanged(): unchanged() # CLI-only variants must return an error instead of panicking in run_action. for command, message in ( - ('rename-pane --pane-id 9999 blocked', 'CLI-only actions'), + ('rename-pane --pane-id 9999 blocked', 'unavailable through the plugin API'), ('new-pane --near-current-pane', 'terminal-pane origin'), ('new-tab --initial-plugin file:/not-a-plugin.wasm', 'external plugins'), ('definitely-not-a-command', 'Unknown or unsupported command'), @@ -254,13 +271,76 @@ def unchanged(): submit(first, 'rename-pane --help', second) first.until('native action help is displayed', lambda: 'Usage:' in text(first)) first.send(b'\x1b') + # Help returns to the editable command so it can be corrected in place. + first.until('help returns to command', lambda: 'Tab complete' in text(first)) + first.send(b'\x1b') open_prompt(first) first.send(b'\r') first.until('empty input closes', lambda: 'Zellij command' not in text(first)) unchanged() + open_prompt(first) + first.send(b'rename-ta') + first.until('inline command suggestions', lambda: 'rename-tab' in text(first) and 'COMMAND / VALUE' in text(first)) + first.send(b'\t') + first.until('Tab completes the action', lambda: 'rename-tab ' in text(first)) + first.send(b'\t') + first.until('rename offers the current name', lambda: 'reopened successfully' in text(first)) + first.send(b'\x1b') + + open_prompt(first) + first.send(b'new-pane --dir\t ') + first.until('native flag completion', lambda: '--direction' in text(first) and 'right' in text(first)) + capture(first, 'completion') + first.send(b'\x1b') + unchanged() + + submit(first, 'list-panes', second) + first.until('pane query is a searchable table', lambda: 'PANE / TITLE' in text(first) and 'other client' in text(first)) + capture(first, 'pane-query') + first.send(b'other client') + first.until('pane table filters by title', lambda: '1 suggestions' in text(first)) + first.send(b'\r') + submit(first, 'rename-pane "selected through query"', second) + first.until('query jumps by stable pane ID', lambda: pane(other_id)['title'] == 'selected through query') + submit(first, 'go-to-tab 1', second) + + open_prompt(first) + first.send(b'go-to-tab-name "second\t') + first.until('quoted live tab-name completion', lambda: 'second named tab' in text(first)) + first.send(b'\r') + submit(first, 'rename-pane "completed tab target"', second) + first.until('quoted completion executes against selected tab', lambda: any( + p['tab_id'] == other_tab and p['title'] == 'completed tab target' for p in first.terminals())) + submit(first, 'go-to-tab 1', second) + + submit(first, 'list-tabs', second) + first.until('tab query opens', lambda: 'TAB / NAME' in text(first)) + submit(second, 'rename-tab "live renamed target"', first) + first.until('open query updates from live state', lambda: 'live renamed target' in text(first)) + capture(first, 'tab-query') + first.send(b'live renamed\r') + submit(first, 'rename-pane "live tab selected"', second) + first.until('updated tab selection uses its stable ID', lambda: any( + p['tab_id'] == other_tab and p['title'] == 'live tab selected' for p in first.terminals())) + submit(first, 'go-to-tab 1', second) + + open_prompt(first) + first.send(b'\x10') + first.until('Ctrl-p recalls a command without running it', lambda: 'go-to-tab 1' in text(first)) + first.send(b'\x0e') + first.until('Ctrl-n restores the draft', lambda: 'go-to-tab 1' not in text(first).split('COMMAND / VALUE')[0]) + first.send(b'\x1b') + + # A parse failure keeps the original line, allowing a real correction. + submit(first, 'move-focus sideways', second) + first.until('invalid argument stays editable', lambda: 'sideways' in text(first)) + capture(first, 'validation') + first.send(b'\x15rename-tab "corrected inline"\r') + first.until('inline correction executes', lambda: tabs()[initial_tab]['name'] == 'corrected inline') + submit(second, 'history', first) - second.until('history belongs to second client', lambda: 'second named tab' in text(second)) + second.until('history belongs to second client', lambda: 'live renamed target' in text(second)) assert 'toggle-floating-panes' not in text(second), 'History leaked from first client' second.send(b'\x1b') second.until('history dialog closes before reload', lambda: 'Zellij command' not in text(second)) diff --git a/zellij/command-prompt/Cargo.lock b/zellij/command-prompt/Cargo.lock index 7452346f..e71b262b 100644 --- a/zellij/command-prompt/Cargo.lock +++ b/zellij/command-prompt/Cargo.lock @@ -551,6 +551,8 @@ name = "dotfiles-command-prompt" version = "0.1.0" dependencies = [ "clap", + "serde", + "serde_json", "shlex", "zellij-tile", "zellij-utils", diff --git a/zellij/command-prompt/Cargo.toml b/zellij/command-prompt/Cargo.toml index ca275353..78afc16b 100644 --- a/zellij/command-prompt/Cargo.toml +++ b/zellij/command-prompt/Cargo.toml @@ -7,6 +7,8 @@ publish = false [dependencies] clap = { version = "4.6", default-features = false, features = ["std", "derive", "help", "usage", "error-context"] } shlex = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" zellij-tile = { git = "https://github.com/zellij-org/zellij", rev = "bf517077c848d91f75091f66d1996cfeb84f4732" } zellij-utils = { git = "https://github.com/zellij-org/zellij", rev = "bf517077c848d91f75091f66d1996cfeb84f4732" } diff --git a/zellij/command-prompt/README.md b/zellij/command-prompt/README.md index ecfe652e..dff5d5ff 100644 --- a/zellij/command-prompt/README.md +++ b/zellij/command-prompt/README.md @@ -1,61 +1,51 @@ -# Zellij command prompt - -`Ctrl-f :` opens the pinned runtime's native centered text-entry popup. Type a -Zellij action without the `zellij action` prefix, for example: - -- `rename-tab "work notes"` -- `new-pane --direction right` -- `new-pane --floating -- htop` -- `move-focus left` -- `resize increase right` -- `rename-pane "build"` - -Enter runs the command; Esc or clicking outside cancels. An empty command also -closes the prompt. `help` opens a searchable list of supported actions, selecting -one fills the input for editing. Add `--help` to an action to see its native -options. `history` picks from this client's last 32 submitted commands and asks -you to review the selected command before running it. History is in memory only, -and goes away when the plugin or session restarts. There is no inline completion -or tmux command compatibility layer. - -Arguments use shell-style quoting but are not evaluated by a shell. `$HOME`, -`$(...)`, wildcards, pipes and semicolons have no shell expansion. To start a -program, use the explicit `new-pane -- PROGRAM ARG...` action. Names and action -options come from the pinned Zellij CLI parser. `go-to-tab` uses Zellij's -one-based position, even though the configured bar labels tabs starting at zero. - -The prompt handles pane/tab/session actions, not stdout-producing queries such -as `list-panes`, layout-file loading or external plugin installation. Use a -normal terminal for those. Command parse errors keep the input available to -correct. Zellij's action API does not return the CLI's stdout or all runtime -errors; a closed/changed original pane or tab is rejected before dispatch. -Some explicit pane/tab-ID variants are CLI-only in the pinned plugin protocol; -the prompt reports those as unsupported before dispatch. -Pane-relative creation flags (`--near-current-pane`, `--stacked`, `--in-place` -and `--no-focus` on `new-pane`/`edit`) also stay in the terminal: the plugin API -otherwise treats the hidden plugin itself as their source pane. - -The implementation calls the built-in `zellij:prompt` plugin, not an ordinary -floating terminal. Opening it does not reveal other hidden floating panes and -does not change the persistent `Ctrl-f P` popup. Each attached client has its own -private binding, prompt, action dispatcher and history. No action is sent -through the CLI's last-active-client fallback. - -## Install and verify - -The selected patched/main runtime installation includes this plugin. For an -existing pinned installation, run `make zellij-command-prompt` then `make zellij` -and restart the session. Default configuration never invokes Cargo. The legacy -0.45.1 configuration does not enable this feature. - -On first use of a fresh session configuration, Zellij asks for permission to -read pane state, open the built-in popup, run Zellij actions, and install the -client-specific runtime binding. The plugin does not request full disk access, -environment reads, network access or a separate host-command permission. -The action permission can still start programs through an explicitly entered -`new-pane -- PROGRAM` command. - -`make test-zellij-command-prompt` installs into a temporary home and exercises -the production prefix binding with two real attached clients. It requires the -pinned runtime, Python with pyte 0.8.2, and the Rust 1.95.0 WASM toolchain. Sessions, -permission grants and configuration are isolated from the user's running sessions. +# Zellij command palette + +`Ctrl-f :` opens a centered native popup. Type a Zellij action directly, or search the suggestions by name or description. Tab accepts the highlighted suggestion; Enter runs the command you entered. Suggestions never execute a command. Esc or clicking outside closes the popup. + +| Input | Suggestions | +| --- | --- | +| `rename-ta` | Matching actions and their descriptions | +| `new-pane --dir` | Native flags, including `--direction` | +| `new-pane --direction ` | Accepted directions | +| `rename-tab ` | The current name, quoted for editing | +| `go-to-tab-name ` | Live tab names, including names containing spaces | +| `focus-pane-id ` | Live pane IDs, titles, and tabs | +| `switch-session ` | Running session names and client counts | + +Arrow keys select suggestions. Up from an empty command recalls history; Ctrl-p/Ctrl-n recall older/newer commands and restore the unsubmitted draft. Ctrl-r or `history` opens searchable history. It retains the last 32 distinct submitted commands in memory for each attached client, until the plugin/session restarts. Recent actions rank ahead of other equally matching commands. + +`help` opens the command list. An action's `--help` displays its native documentation; Enter or Esc returns to the editable command. Parse errors retain the command and display the error inline. The completion engine uses metadata from the pinned Zellij CLI parser; unsupported variants are excluded from flag suggestions. Program arguments after `--` are left to that program. + +## Search and jump + +| Command | Result | +| --- | --- | +| `list-panes` | Searchable table of selectable panes, titles, commands, and tab positions | +| `list-tabs` | Searchable table of tab names, display positions, and stable IDs | +| `list-sessions` | Searchable table of running sessions and counts | + +Type to filter the table; arrows select a row; Enter jumps to it. Esc returns to the original command. Tables refresh when the live state changes. Pane and tab selections carry stable IDs, which are checked again at dispatch. Session selection switches only to an existing running session. Query commands take no flags; use the terminal for CLI JSON output and the wider CLI query interface. + +The header shows the original session, tab, and pane. Tab labels match the bar's zero-based positions. The native `go-to-tab` command retains its one-based syntax, with that index shown in its completion details. `go-to-tab-by-id` selects a stable tab ID. + +## Command scope + +Arguments use shell-style quoting without shell evaluation. `$HOME`, `$(...)`, wildcards, pipes and semicolons have no shell expansion. To start a program, use an explicit action such as `new-pane -- PROGRAM ARG...`. + +The existing supported pane/tab actions retain Zellij's syntax. `focus-pane-id`, `go-to-tab-by-id` and `switch-session` accept one live target. Layout-file loading and external plugin installation remain terminal operations. Some explicit pane/tab-ID variants are CLI-only in the pinned plugin protocol; the palette reports those as unsupported. Pane-relative creation flags (`--near-current-pane`, `--stacked`, `--in-place`, `--no-focus` on `new-pane`/`edit`) require a terminal-pane origin and remain terminal operations. + +The native action API does not return CLI stdout or all runtime errors. Submitting an action is not a guarantee of its eventual runtime result. Parse failures are shown inline; an origin that changed or closed is rejected before dispatch. + +## Installation and permissions + +For an existing pinned installation, run `make zellij-command-prompt`, then `make zellij` and restart the session. Default configuration never invokes Cargo. The legacy 0.45.1 configuration does not enable the palette. No additional runtime patch or shell dependency is required. + +The background dispatcher opens the same WASM as a client-local native popup. The popup edits and renders suggestions; the dispatcher retains history, checks the original pane/tab, and applies accepted commands for that client. Opening the palette leaves hidden floating panes and the persistent `Ctrl-f P` popup hidden. + +The plugin requests application-state reads, native popups and actions, client-specific runtime binding updates, target/session changes, and private messages between its popup and dispatcher. Existing installations may need to accept the expanded permissions. It requests no filesystem, environment, network or host-command access. An explicit `new-pane -- PROGRAM` action can still start a program. + +## Verification + +`make test-zellij-command-prompt` installs into a temporary home and exercises the production prefix binding with two real attached clients. It checks completion, editable errors, target queries, live updates, private history, hidden floats, reloads and origin lifecycle. It requires the pinned runtime, Python with pyte 0.8.2, and the Rust 1.95.0 WASM toolchain. Sessions, grants and sockets are isolated from existing sessions. CI retains decoded terminal grids for visual review. + +For focused native model checks, run `RUST_MIN_STACK=16777216 cargo test --locked --manifest-path zellij/command-prompt/Cargo.toml`. The generated upstream CLI parser requires a larger stack in unoptimized Rust test threads. diff --git a/zellij/command-prompt/src/commands.rs b/zellij/command-prompt/src/commands.rs index c3e2be20..9004650b 100644 --- a/zellij/command-prompt/src/commands.rs +++ b/zellij/command-prompt/src/commands.rs @@ -1,6 +1,6 @@ use clap::{CommandFactory, Parser}; use std::path::PathBuf; -use zellij_tile::prelude::{actions::Action, ChoiceItem}; +use zellij_tile::prelude::actions::Action; use zellij_utils::cli::CliAction; use zellij_utils::plugin_api::action::ProtobufAction; @@ -43,6 +43,32 @@ const COMMANDS: &[&str] = &[ "undo-rename-tab", ]; +pub fn metadata() -> Vec { + let mut command = CommandLine::command(); + command.build(); + command + .get_subcommands() + .filter(|command| COMMANDS.contains(&command.get_name())) + .cloned() + .collect() +} + +pub fn argument_supported(name: &str) -> bool { + !matches!( + name, + "near-current-pane" + | "no-focus" + | "stacked" + | "in-place" + | "layout" + | "layout-string" + | "initial-plugin" + | "plugin" + | "pane-id" + | "tab-id" + ) +} + #[derive(Parser)] #[command(name = ":", disable_help_subcommand = true, color = clap::ColorChoice::Never)] struct CommandLine { @@ -50,23 +76,6 @@ struct CommandLine { action: CliAction, } -pub fn choices() -> Vec { - CommandLine::command() - .get_subcommands() - .filter(|command| COMMANDS.contains(&command.get_name())) - .map(|command| { - let name = command.get_name(); - ChoiceItem::labeled( - name, - format!( - "{name}: {}", - command.get_about().cloned().unwrap_or_default() - ), - ) - }) - .collect() -} - pub enum Parsed { Actions(Vec), Help(String), diff --git a/zellij/command-prompt/src/completion.rs b/zellij/command-prompt/src/completion.rs new file mode 100644 index 00000000..973faca5 --- /dev/null +++ b/zellij/command-prompt/src/completion.rs @@ -0,0 +1,448 @@ +use crate::commands; +use std::ops::Range; +use zellij_tile::prelude::{PaneInfo, SessionInfo, TabInfo}; +use zellij_tile::ui_components::fuzzy_match_indices; + +pub const LOCAL_COMMANDS: &[(&str, &str)] = &[ + ("help", "Search supported commands and their descriptions"), + ("history", "Search this client's recent commands"), + ( + "list-panes", + "Search panes; Enter jumps to the selected pane", + ), + ("list-tabs", "Search tabs; Enter jumps to the selected tab"), + ( + "list-sessions", + "Search running sessions; Enter switches session", + ), + ("focus-pane-id", "Focus a live pane by its stable ID"), + ("go-to-tab-by-id", "Focus a live tab by its stable ID"), + ("switch-session", "Switch to an existing session by name"), +]; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Suggestion { + pub value: String, + pub label: String, + pub detail: String, +} + +impl Suggestion { + pub fn new( + value: impl Into, + label: impl Into, + detail: impl Into, + ) -> Self { + Self { + value: value.into(), + label: label.into(), + detail: detail.into(), + } + } +} + +#[derive(Default, Clone)] +pub struct Context { + pub tabs: Vec, + pub panes: Vec<(usize, PaneInfo)>, + pub sessions: Vec, + pub pane_name: String, + pub tab_name: String, + pub session_name: String, +} + +#[derive(Debug)] +pub struct Completions { + pub replace: Range, + pub items: Vec, + pub help: String, + pub command: bool, +} + +pub fn quote(value: &str) -> String { + shlex::try_quote(value) + .map(|v| v.into_owned()) + .unwrap_or_default() +} + +// Completion must work before a quote is closed and in the middle of a line. +// These spans only locate tokens. Execution still uses shlex and Zellij's parser. +fn tokens(line: &str) -> Vec> { + let mut spans = vec![]; + let (mut start, mut quote, mut escaped) = (None, None, false); + for (i, c) in line.char_indices() { + if start.is_none() && !c.is_whitespace() { + start = Some(i); + } + if escaped { + escaped = false; + continue; + } + if c == '\\' && quote != Some('\'') { + escaped = true; + continue; + } + if Some(c) == quote { + quote = None; + } else if quote.is_none() && matches!(c, '\'' | '"') { + quote = Some(c); + } else if quote.is_none() && c.is_whitespace() { + if let Some(begin) = start.take() { + spans.push(begin..i); + } + } + } + if let Some(begin) = start { + spans.push(begin..line.len()); + } + spans +} + +fn unquote_prefix(raw: &str) -> String { + let (mut result, mut quote, mut escaped) = (String::new(), None, false); + for c in raw.chars() { + if escaped { + result.push(c); + escaped = false; + } else if c == '\\' && quote != Some('\'') { + escaped = true; + } else if Some(c) == quote { + quote = None; + } else if quote.is_none() && matches!(c, '\'' | '"') { + quote = Some(c); + } else { + result.push(c); + } + } + result +} + +pub fn matches(query: &str, candidate: &str) -> bool { + query.is_empty() || fuzzy_match_indices(query, candidate).is_some() +} + +pub fn complete(line: &str, cursor: usize, context: &Context, history: &[String]) -> Completions { + let cursor = line + .char_indices() + .nth(cursor) + .map(|(i, _)| i) + .unwrap_or(line.len()); + let spans = tokens(line); + let current = spans + .iter() + .find(|span| span.start <= cursor && cursor <= span.end) + .cloned() + .unwrap_or(cursor..cursor); + let before: Vec = spans + .iter() + .filter(|s| s.end < current.start) + .map(|s| unquote_prefix(&line[s.clone()])) + .collect(); + let prefix = unquote_prefix(&line[current.start..cursor]); + let mut result = Completions { + replace: current, + items: vec![], + help: String::new(), + command: before.is_empty(), + }; + let mut metadata = commands::metadata(); + if before.is_empty() { + result.items = metadata + .iter() + .map(|command| { + Suggestion::new( + command.get_name(), + command.get_name(), + command + .get_about() + .map(ToString::to_string) + .unwrap_or_default(), + ) + }) + .chain( + LOCAL_COMMANDS + .iter() + .map(|(name, help)| Suggestion::new(*name, *name, *help)), + ) + .collect(); + result.items.retain(|item| { + matches( + prefix.trim_start_matches(':'), + &format!("{} {}", item.label, item.detail), + ) + }); + result.items.sort_by_key(|item| { + ( + !item.value.starts_with(prefix.trim_start_matches(':')), + history + .iter() + .position(|line| line.split_whitespace().next() == Some(item.value.as_str())) + .unwrap_or(usize::MAX), + item.value.clone(), + ) + }); + result.help = "Type an action, or search commands by name or description".into(); + return result; + } + let name = before[0].trim_start_matches(':'); + if let Some((_, help)) = LOCAL_COMMANDS.iter().find(|(n, _)| *n == name) { + result.help = (*help).into(); + result.items = match name { + "focus-pane-id" if before.len() == 1 => pane_targets(context), + "go-to-tab-by-id" if before.len() == 1 => tab_targets(context, true), + "switch-session" if before.len() == 1 => session_targets(context), + _ => vec![], + }; + } else if let Some(command) = metadata + .iter_mut() + .find(|command| command.get_name() == name) + { + result.help = command + .get_about() + .map(ToString::to_string) + .unwrap_or_default(); + let mut position = 1; + let mut pending = None; + for word in before.iter().skip(1) { + if pending.take().is_some() { + continue; + } + if word == "--" { + return result; + } // Program arguments belong to that program. + if word.starts_with('-') { + if let Some(arg) = command.get_arguments().find(|arg| flag_matches(arg, word)) { + if arg.get_num_args().is_some_and(|n| n.takes_values()) && !word.contains('=') { + pending = Some(arg.clone()); + } + } + } else { + position += 1; + } + } + if prefix.starts_with('-') && pending.is_none() { + result.items = command + .get_arguments() + .filter(|arg| commands::argument_supported(arg.get_id().as_str())) + .filter_map(|arg| arg.get_long().map(|flag| (arg, format!("--{flag}")))) + .filter(|(_, flag)| !before.contains(flag)) + .map(|(arg, flag)| { + Suggestion::new( + &flag, + &flag, + arg.get_help().map(ToString::to_string).unwrap_or_default(), + ) + }) + .collect(); + } else { + let arg = pending.or_else(|| { + command + .get_positionals() + .find(|arg| arg.get_index() == Some(position)) + .cloned() + }); + if let Some(arg) = arg { + result.help = format!( + "{}: {}", + arg.get_id(), + arg.get_help().map(ToString::to_string).unwrap_or_default() + ); + result.items = match (name, arg.get_id().as_str()) { + ("go-to-tab-name", "name") => context + .tabs + .iter() + .map(|t| { + Suggestion::new( + &t.name, + format!("{} {}", t.position, t.name), + "tab name", + ) + }) + .collect(), + ("go-to-tab", "index") => tab_targets(context, false), + ("rename-pane", "name") => vec![Suggestion::new( + &context.pane_name, + &context.pane_name, + "current pane name", + )], + ("rename-tab", "name") => vec![Suggestion::new( + &context.tab_name, + &context.tab_name, + "current tab name", + )], + ("rename-session", "name") => vec![Suggestion::new( + &context.session_name, + &context.session_name, + "current session name", + )], + (_, "direction") => ["left", "right", "up", "down"] + .into_iter() + .map(|v| Suggestion::new(v, v, "direction")) + .collect(), + (_, "resize") => ["increase", "decrease"] + .into_iter() + .map(|v| Suggestion::new(v, v, "resize amount")) + .collect(), + _ => arg + .get_value_parser() + .possible_values() + .into_iter() + .flatten() + .map(|v| { + Suggestion::new( + v.get_name(), + v.get_name(), + v.get_help().map(ToString::to_string).unwrap_or_default(), + ) + }) + .collect(), + }; + } + } + } + result + .items + .retain(|item| matches(&prefix, &format!("{} {}", item.label, item.detail))); + result +} + +fn flag_matches(arg: &clap::Arg, word: &str) -> bool { + let word = word.split('=').next().unwrap_or(word); + arg.get_long() + .is_some_and(|long| word == format!("--{long}")) + || arg + .get_short() + .is_some_and(|short| word == format!("-{short}")) +} + +pub fn pane_targets(context: &Context) -> Vec { + context + .panes + .iter() + .filter(|(_, p)| p.is_selectable && !p.is_suppressed) + .map(|(tab, pane)| { + let id = format!( + "{}_{}", + if pane.is_plugin { "plugin" } else { "terminal" }, + pane.id + ); + Suggestion::new( + &id, + format!("{id} {}", pane.title), + format!( + "tab {tab} {}", + pane.terminal_command.as_deref().unwrap_or("") + ), + ) + }) + .collect() +} + +pub fn tab_targets(context: &Context, stable: bool) -> Vec { + context + .tabs + .iter() + .map(|tab| { + Suggestion::new( + if stable { + tab.tab_id.to_string() + } else { + (tab.position + 1).to_string() + }, + format!("{} {}", tab.position, tab.name), + if stable { + format!("tab ID {}", tab.tab_id) + } else { + format!("native index {}", tab.position + 1) + }, + ) + }) + .collect() +} + +pub fn session_targets(context: &Context) -> Vec { + context + .sessions + .iter() + .map(|session| { + Suggestion::new( + &session.name, + &session.name, + format!( + "{} tabs {} clients{}", + session.tabs.len(), + session.connected_clients, + if session.is_current_session { + " current" + } else { + "" + } + ), + ) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn completion_edits_only_the_token_at_the_unicode_cursor() { + let ctx = Context { + tab_name: "déploy 'green'".into(), + ..Default::default() + }; + let line = "rename-tab \"dé\" --help"; + let result = complete(line, 14, &ctx, &[]); + assert_eq!(result.items[0].value, ctx.tab_name); + let edited = format!( + "{}{}{}", + &line[..result.replace.start], + quote(&result.items[0].value), + &line[result.replace.end..] + ); + assert_eq!( + shlex::split(&edited).unwrap(), + ["rename-tab", "déploy 'green'", "--help"] + ); + } + + #[test] + fn live_tabs_complete_names_with_spaces_and_native_positions() { + let ctx = Context { + tabs: vec![TabInfo { + name: "build logs".into(), + position: 0, + tab_id: 42, + ..Default::default() + }], + ..Default::default() + }; + assert_eq!( + complete("go-to-tab-name \"bu", 18, &ctx, &[]).items[0].value, + "build logs" + ); + assert_eq!(complete("go-to-tab ", 10, &ctx, &[]).items[0].value, "1"); + assert_eq!( + complete("go-to-tab-by-id ", 16, &ctx, &[]).items[0].value, + "42" + ); + } + + #[test] + fn native_flags_and_values_stop_at_program_arguments() { + let ctx = Context::default(); + assert!(complete("new-pane --dir", 14, &ctx, &[]) + .items + .iter() + .any(|i| i.value == "--direction")); + assert!(complete("new-pane --direction r", 22, &ctx, &[]) + .items + .iter() + .any(|i| i.value == "right")); + assert!(complete("new-pane -- htop --", 18, &ctx, &[]) + .items + .is_empty()); + } +} diff --git a/zellij/command-prompt/src/main.rs b/zellij/command-prompt/src/main.rs index ac305bd1..55a7bd57 100644 --- a/zellij/command-prompt/src/main.rs +++ b/zellij/command-prompt/src/main.rs @@ -1,17 +1,62 @@ mod commands; +mod completion; +mod palette; -use std::collections::{BTreeMap, VecDeque}; -use zellij_tile::prelude::actions::Action; -use zellij_tile::prelude::*; +use std::{ + collections::{BTreeMap, VecDeque}, + path::PathBuf, +}; +use zellij_tile::prelude::{actions::Action, *}; const OPEN: &str = "command-prompt"; -#[derive(Clone, Copy)] -enum Input { - Command, - Commands, - History, - Help, +enum Parsed { + Native(commands::Parsed), + Pane(PaneId), + Tab(usize), + Session(String), +} + +fn parse(line: &str, cwd: PathBuf) -> Result { + let words = shlex::split(line.trim().trim_start_matches(':')) + .ok_or("Unclosed quote or trailing escape. Use quotes around names with spaces.")?; + let name = words.first().map(String::as_str).unwrap_or(""); + if matches!(name, "focus-pane-id" | "go-to-tab-by-id" | "switch-session") { + if words.len() != 2 || words[1].starts_with('-') { + return Err(format!( + "Usage: {name} <{}>", + if name == "switch-session" { + "session-name" + } else { + "id" + } + )); + } + let value = &words[1]; + return match name { + "focus-pane-id" => { + let (plugin, id) = if let Some(id) = value.strip_prefix("plugin_") { + (true, id) + } else { + (false, value.strip_prefix("terminal_").unwrap_or(value)) + }; + let id = id + .parse::() + .map_err(|_| "Expected a pane ID such as terminal_3 or plugin_2".to_owned())?; + Ok(Parsed::Pane(if plugin { + PaneId::Plugin(id) + } else { + PaneId::Terminal(id) + })) + } + "go-to-tab-by-id" => value + .parse() + .map(Parsed::Tab) + .map_err(|_| "Expected a numeric tab ID".into()), + _ => Ok(Parsed::Session(value.clone())), + }; + } + commands::parse(line, cwd).map(Parsed::Native) } #[derive(Default)] @@ -19,36 +64,52 @@ struct CommandPrompt { allowed: bool, plugin_id: u32, client_id: ClientId, - request: Option<(u64, Input)>, + request: u64, origin: Option<(usize, PaneId)>, line: String, history: VecDeque, actions: VecDeque, action_pending: bool, + palette: Option, } register_plugin!(CommandPrompt); impl ZellijPlugin for CommandPrompt { - fn load(&mut self, _configuration: BTreeMap) { + fn load(&mut self, configuration: BTreeMap) { let ids = get_plugin_ids(); self.plugin_id = ids.plugin_id; self.client_id = ids.client_id; - subscribe(&[ - EventType::PermissionRequestResult, - EventType::InitialKeybinds, - EventType::PromptResult, - EventType::ActionComplete, - ]); + if let Some(open) = configuration + .get("palette") + .and_then(|s| serde_json::from_str(s).ok()) + { + self.palette = Some(palette::Palette::new(open)); + subscribe(&[ + EventType::Key, + EventType::PaneUpdate, + EventType::TabUpdate, + EventType::SessionUpdate, + EventType::PastedText, + ]); + } else { + subscribe(&[EventType::InitialKeybinds, EventType::ActionComplete]); + } + subscribe(&[EventType::PermissionRequestResult]); request_permission(&[ PermissionType::ReadApplicationState, PermissionType::OpenTerminalsOrPlugins, PermissionType::RunActionsAsUser, PermissionType::Reconfigure, + PermissionType::ChangeApplicationState, + PermissionType::MessageAndLaunchOtherPlugins, ]); } fn update(&mut self, event: Event) -> bool { + if let Some(palette) = self.palette.as_mut() { + return palette.update(event); + } match event { Event::PermissionRequestResult(PermissionStatus::Granted) => { self.allowed = true; @@ -56,19 +117,7 @@ impl ZellijPlugin for CommandPrompt { self.bind(); } Event::PermissionRequestResult(PermissionStatus::Denied) => self.allowed = false, - Event::InitialKeybinds(_) if self.allowed => { - // The event omits pipe targets, so reapply the exact private - // binding. Native config equality suppresses no-op updates. - self.bind(); - } - Event::PromptResult(id, result) if self.allowed => { - if let Some((expected, input)) = self.request { - if id == expected { - self.request = None; - self.answer(input, result); - } - } - } + Event::InitialKeybinds(_) if self.allowed => self.bind(), Event::ActionComplete(_, _, context) if self.action_pending && context.get("owner").map(String::as_str) == Some(OPEN) => @@ -82,25 +131,48 @@ impl ZellijPlugin for CommandPrompt { } fn pipe(&mut self, message: PipeMessage) -> bool { - if self.allowed - && message.is_private - && message.source == PipeSource::Keybind + if !self.allowed || self.palette.is_some() || !message.is_private || self.action_pending { + return false; + } + if message.source == PipeSource::Keybind && message.name == OPEN && message.payload.as_deref() == Some(self.client_id.to_string().as_str()) - && self.request.is_none() - && !self.action_pending { match get_focused_pane_info() { Ok(origin) => { self.origin = Some(origin); self.line.clear(); - self.input("Zellij action. Enter help or history for a picker."); + self.input(String::new()); + } + Err(error) => { + prompt(PromptRequest::notify(error)); + } + } + } else if matches!(message.source, PipeSource::Plugin(_)) && message.name == palette::REPLY + { + if let Some(reply) = message + .payload + .and_then(|s| serde_json::from_str::(&s).ok()) + { + if reply.request == self.request + && reply.client == self.client_id + && self.origin.is_some() + { + // Consume once. A late reply from a replaced popup cannot run. + self.request += 1; + self.line = reply.line; + self.execute(); } - Err(error) => self.notice(error), } } false } + + fn render(&mut self, rows: usize, cols: usize) { + if let Some(palette) = self.palette.as_mut() { + palette.render(rows, cols); + } + } } impl CommandPrompt { @@ -111,70 +183,20 @@ impl CommandPrompt { ), false); } - fn ask(&mut self, request: PromptRequest, input: Input) { - let request = request - .title("Zellij command :") - .placement(PromptPlacement::Center) - .capture_all_keys(true) - .outside_click(OutsideClick::Close); - self.request = Some((prompt(request), input)); - } - - fn input(&mut self, message: &str) { - self.ask( - PromptRequest::form(vec![FormField::input("command", ":") - .placeholder("rename-tab \"work\"") - .default_value(self.line.clone())]) - .message(message) - .buttons("Run", "Cancel"), - Input::Command, - ); - } - - fn notice(&mut self, message: String) { - self.ask(PromptRequest::info("Zellij command", message), Input::Help); - } - - fn answer(&mut self, input: Input, result: PromptResult) { - match (input, result) { - (Input::Command, PromptResult::Answered(PromptValue::Form(mut fields))) => { - let Some(PromptValue::Text(line)) = fields.remove("command") else { - self.origin = None; - return; - }; - self.line = line; - match self.line.trim() { - "help" => self.ask(PromptRequest::choose(commands::choices()), Input::Commands), - "history" => { - if self.history.is_empty() { - self.input("No commands in this client's session history yet."); - } else { - self.ask( - PromptRequest::choose(self.history.iter().cloned()), - Input::History, - ); - } - } - "" => self.origin = None, - _ => self.execute(), - } - } - (Input::Commands, PromptResult::Answered(PromptValue::Choice(command))) => { - self.line = format!("{command} "); - self.input("Add arguments, or --help for this command's options."); - } - (Input::History, PromptResult::Answered(PromptValue::Choice(command))) => { - self.line = command; - self.input("Review the command, then Enter to run it."); - } - (_, PromptResult::Error(error)) => { - // Do not repeatedly re-open a native prompt that the runtime - // cannot display (eg. an incompatible or missing prompt plugin). - eprintln!("command-prompt: {error}"); - self.origin = None; - } - _ => self.origin = None, - } + fn input(&mut self, error: String) { + let Some(origin) = self.origin else { + return; + }; + self.request += 1; + palette::launch(palette::Open { + owner: self.plugin_id, + client: self.client_id, + request: self.request, + origin, + line: self.line.clone(), + history: self.history.iter().cloned().collect(), + error, + }); } fn execute(&mut self) { @@ -183,28 +205,70 @@ impl CommandPrompt { }; if get_focused_pane_info().ok() != Some(origin) || get_pane_info(origin.1).is_none() { self.origin = None; - self.notice("The original pane or tab changed. Nothing ran; reopen the command prompt in the intended pane.".into()); + prompt(PromptRequest::notify( + "The original pane or tab changed. Reopen : in the intended pane.", + )); return; } let cwd = match get_pane_cwd(origin.1) { Ok(cwd) => cwd, Err(error) => { - self.input(&error); + self.input(error); return; } }; - match commands::parse(&self.line, cwd) { - Ok(commands::Parsed::Help(help)) => self.notice(help), - Ok(commands::Parsed::Actions(actions)) => { - if !actions.is_empty() { - self.history.retain(|old| old != &self.line); - self.history.push_front(self.line.clone()); - self.history.truncate(32); - } + let result = match parse(&self.line, cwd) { + Ok(Parsed::Native(commands::Parsed::Actions(actions))) => { self.actions = actions.into(); self.dispatch(); + Ok(()) + } + Ok(Parsed::Native(commands::Parsed::Help(_))) => Ok(()), + Ok(Parsed::Pane(id)) => { + if get_pane_info(id).is_some_and(|p| p.is_selectable && !p.is_suppressed) { + focus_pane_with_id(id, false, false); + self.origin = None; + Ok(()) + } else { + Err( + "That pane is no longer available. Use list-panes to choose a live target." + .into(), + ) + } + } + Ok(Parsed::Tab(id)) => { + if let Some(tab) = get_tab_info(id) { + switch_tab_to((tab.position + 1) as u32); + self.origin = None; + Ok(()) + } else { + Err( + "That tab is no longer available. Use list-tabs to choose a live target." + .into(), + ) + } + } + Ok(Parsed::Session(name)) => match get_session_list() { + Ok(snapshot) if snapshot.live_sessions.iter().any(|s| s.name == name) => { + switch_session(Some(&name)); + self.origin = None; + Ok(()) + } + Ok(_) => Err( + "That session is no longer running. Use list-sessions to choose a live target." + .into(), + ), + Err(error) => Err(error), + }, + Err(error) => Err(error), + }; + match result { + Ok(()) => { + self.history.retain(|old| old != &self.line); + self.history.push_front(self.line.clone()); + self.history.truncate(32); } - Err(error) => self.input(&error), + Err(error) => self.input(error), } } diff --git a/zellij/command-prompt/src/palette.rs b/zellij/command-prompt/src/palette.rs new file mode 100644 index 00000000..823db6ae --- /dev/null +++ b/zellij/command-prompt/src/palette.rs @@ -0,0 +1,563 @@ +use crate::{ + commands, + completion::{self, Context, Suggestion}, +}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use zellij_tile::prelude::*; + +pub const REPLY: &str = "command-palette-submit"; + +#[derive(Serialize, Deserialize)] +pub struct Open { + pub owner: u32, + pub client: ClientId, + pub request: u64, + pub origin: (usize, PaneId), + pub line: String, + pub history: Vec, + pub error: String, +} + +#[derive(Serialize, Deserialize)] +pub struct Reply { + pub request: u64, + pub client: ClientId, + pub line: String, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum View { + Command, + Help, + History, + Panes, + Tabs, + Sessions, + Documentation, +} + +pub struct Palette { + open: Open, + input: TextInput, + view: View, + context: Context, + selected: usize, + history_at: Option, + draft: String, + error: String, + documentation: String, + scroll: usize, + allowed: bool, +} + +impl Palette { + pub fn new(open: Open) -> Self { + Self { + input: TextInput::new(&open.line).label(":").focused(), + error: open.error.clone(), + open, + view: View::Command, + context: Context::default(), + selected: 0, + history_at: None, + draft: String::new(), + documentation: String::new(), + scroll: 0, + allowed: false, + } + } + + pub fn update(&mut self, event: Event) -> bool { + match event { + Event::PermissionRequestResult(PermissionStatus::Granted) => { + if get_plugin_ids().client_id != self.open.client { + return false; + } + self.allowed = true; + self.refresh(); + true + } + Event::Key(key) if self.allowed => self.key(key), + Event::PastedText(text) if self.allowed && self.view != View::Documentation => { + for c in text.chars() { + if matches!(c, '\n' | '\r' | '\t') { + self.input.insert_char(' '); + } else if !c.is_control() { + self.input.insert_char(c); + } + } + self.selected = 0; + self.error.clear(); + true + } + Event::PaneUpdate(_) | Event::TabUpdate(_) | Event::SessionUpdate(..) + if self.allowed => + { + self.refresh(); + true + } + _ => false, + } + } + + fn refresh(&mut self) { + if let Ok(snapshot) = get_session_list() { + if let Some(session) = snapshot.live_sessions.iter().find(|s| s.is_current_session) { + self.context.tabs = session.tabs.clone(); + self.context.tabs.sort_by_key(|t| t.position); + self.context.panes = session + .panes + .panes + .iter() + .flat_map(|(tab, panes)| panes.iter().map(move |p| (*tab, p.clone()))) + .collect(); + self.context.panes.sort_by_key(|(tab, p)| (*tab, p.id)); + self.context.session_name = session.name.clone(); + self.context.tab_name = session + .tabs + .iter() + .find(|t| t.tab_id == self.open.origin.0) + .map(|t| t.name.clone()) + .unwrap_or_default(); + self.context.pane_name = get_pane_info(self.open.origin.1) + .map(|p| p.title) + .unwrap_or_default(); + } + self.context.sessions = snapshot.live_sessions; + self.context.sessions.sort_by(|a, b| a.name.cmp(&b.name)); + } + self.selected = self.selected.min(self.items().len().saturating_sub(1)); + } + + fn items(&self) -> Vec { + if self.view == View::Command { + return completion::complete( + self.input.get_text(), + self.input.cursor_position(), + &self.context, + &self.open.history, + ) + .items; + } + let items = match self.view { + View::Help => completion::complete("", 0, &self.context, &self.open.history).items, + View::History => self + .open + .history + .iter() + .map(|line| Suggestion::new(line, line, "recent command")) + .collect(), + View::Panes => completion::pane_targets(&self.context), + View::Tabs => completion::tab_targets(&self.context, true), + View::Sessions => completion::session_targets(&self.context), + _ => vec![], + }; + items + .into_iter() + .filter(|item| { + completion::matches( + self.input.get_text(), + &format!("{} {}", item.label, item.detail), + ) + }) + .collect() + } + + fn select(&mut self, delta: isize) { + let count = self.items().len(); + if count > 0 { + self.selected = (self.selected as isize + delta).rem_euclid(count as isize) as usize; + } + } + + fn recall(&mut self, older: bool) { + if self.view != View::Command || self.open.history.is_empty() { + return; + } + if self.history_at.is_none() { + self.draft = self.input.get_text().into(); + } + self.history_at = match (self.history_at, older) { + (None, true) => Some(0), + (Some(i), true) => Some((i + 1).min(self.open.history.len() - 1)), + (Some(0), false) => None, + (Some(i), false) => Some(i - 1), + (None, false) => None, + }; + self.input.set_text( + self.history_at + .map(|i| self.open.history[i].as_str()) + .unwrap_or(&self.draft), + ); + self.selected = 0; + self.error.clear(); + } + + fn back(&mut self) { + self.view = View::Command; + self.input = TextInput::new(&self.open.line).label(":").focused(); + self.selected = 0; + self.error.clear(); + } + + fn complete(&mut self) { + let items = self.items(); + let Some(item) = items.get(self.selected) else { + return; + }; + if self.view != View::Command { + self.activate(); + return; + } + let line = self.input.get_text(); + let completed = completion::complete( + line, + self.input.cursor_position(), + &self.context, + &self.open.history, + ); + let value = if completed.command { + item.value.clone() + } else { + completion::quote(&item.value) + }; + let end = completed.replace.start + value.len(); + let append_space = completed.command && completed.replace.end == line.len(); + let line = format!( + "{}{}{}{}", + &line[..completed.replace.start], + value, + if append_space { " " } else { "" }, + &line[completed.replace.end..] + ); + let cursor = line[..end].chars().count() + usize::from(append_space); + self.input.set_text(line); + self.input.set_cursor_position(cursor); + self.selected = 0; + self.history_at = None; + self.error.clear(); + } + + fn key(&mut self, key: KeyWithModifier) -> bool { + let plain = key.has_no_modifiers(); + let ctrl = |c| key.bare_key == BareKey::Char(c) && key.has_modifiers(&[KeyModifier::Ctrl]); + if (plain && key.bare_key == BareKey::Esc) || ctrl('c') { + if self.view == View::Command { + close_self(); + } else { + self.back(); + } + } else if ctrl('p') { + self.recall(true); + } else if ctrl('n') { + self.recall(false); + } else if ctrl('r') { + self.show(View::History); + } else if plain && key.bare_key == BareKey::Up { + if self.history_at.is_some() || (self.input.is_empty() && self.view == View::Command) { + self.recall(true); + } else if self.view == View::Documentation { + self.scroll = self.scroll.saturating_sub(1); + } else { + self.select(-1); + } + } else if plain && key.bare_key == BareKey::Down { + if self.history_at.is_some() { + self.recall(false); + } else if self.view == View::Documentation { + self.scroll += 1; + } else { + self.select(1); + } + } else if plain && key.bare_key == BareKey::Tab { + self.complete(); + } else if plain && key.bare_key == BareKey::Enter { + self.activate(); + } else if self.view != View::Documentation { + self.input.handle_key(&key); + self.selected = 0; + self.history_at = None; + self.error.clear(); + } + true + } + + fn show(&mut self, view: View) { + if self.view == View::Command { + self.open.line = self.input.get_text().into(); + } + self.view = view; + self.input = TextInput::empty().label("filter").focused(); + self.selected = 0; + self.error.clear(); + self.refresh(); + } + + fn activate(&mut self) { + if self.view == View::Documentation { + self.back(); + return; + } + if self.view != View::Command { + let items = self.items(); + let Some(item) = items.get(self.selected) else { + return; + }; + match self.view { + View::Help | View::History => { + let line = format!( + "{}{}", + item.value, + if self.view == View::Help { " " } else { "" } + ); + self.back(); + self.input.set_text(line); + } + View::Panes => self.submit(format!("focus-pane-id {}", item.value)), + View::Tabs => self.submit(format!("go-to-tab-by-id {}", item.value)), + View::Sessions => { + self.submit(format!("switch-session {}", completion::quote(&item.value))) + } + _ => {} + } + return; + } + let line = self + .input + .get_text() + .trim() + .trim_start_matches(':') + .to_owned(); + match line.as_str() { + "" => close_self(), + "help" => self.show(View::Help), + "history" => self.show(View::History), + "list-panes" => self.show(View::Panes), + "list-tabs" => self.show(View::Tabs), + "list-sessions" => self.show(View::Sessions), + _ => match crate::parse(&line, std::path::PathBuf::from(".")) { + Ok(crate::Parsed::Native(commands::Parsed::Help(help))) => { + self.open.line = self.input.get_text().into(); + self.documentation = help; + self.view = View::Documentation; + self.scroll = 0; + } + Ok(_) => self.submit(line), + Err(error) => self.error = error, + }, + } + } + + fn submit(&self, line: String) { + let reply = Reply { + request: self.open.request, + client: self.open.client, + line, + }; + pipe_message_to_plugin(MessageToPlugin { + destination_plugin_id: Some(self.open.owner), + message_name: REPLY.into(), + message_payload: Some(serde_json::to_string(&reply).expect("serializable reply")), + ..Default::default() + }); + close_self(); + } + + pub fn render(&mut self, rows: usize, columns: usize) { + if !self.allowed || rows < 5 || columns < 12 { + return; + } + print!("\u{1b}[2J\u{1b}[H"); + let width = columns.saturating_sub(4); + let put = + |row, value: Text| print_text_with_coordinates(value, 2, row, Some(width), Some(1)); + let title = match self.view { + View::Command => "Zellij command :", + View::Help => "Zellij commands", + View::History => "Zellij command history", + View::Panes => "Zellij command · panes", + View::Tabs => "Zellij command · tabs", + View::Sessions => "Zellij command · sessions", + View::Documentation => "Zellij command · help", + }; + put(1, Text::from(title).color_range(0, ..)); + let tab = self + .context + .tabs + .iter() + .find(|t| t.tab_id == self.open.origin.0); + put( + 2, + Text::from(sanitize_display_text(&format!( + "{} · tab {}: {} · {}", + self.context.session_name, + tab.map(|t| t.position).unwrap_or(0), + self.context.tab_name, + self.context.pane_name + ))) + .dim_range(..), + ); + if self.view == View::Documentation { + let lines: Vec<_> = self.documentation.lines().collect(); + self.scroll = self + .scroll + .min(lines.len().saturating_sub(rows.saturating_sub(6))); + for (i, line) in lines + .iter() + .skip(self.scroll) + .take(rows.saturating_sub(6)) + .enumerate() + { + put(i + 4, Text::from(*line)); + } + put( + rows - 1, + Text::from("↑↓ scroll · Enter/Esc back").dim_range(..), + ); + return; + } + self.input.render(2, 4, width); + let help = if self.error.is_empty() { + if self.view == View::Command { + completion::complete( + self.input.get_text(), + self.input.cursor_position(), + &self.context, + &self.open.history, + ) + .help + } else { + "Filter by name or details. Enter selects; Esc returns to your command.".into() + } + } else { + self.error.lines().take(2).collect::>().join(" ") + }; + put( + 6, + if self.error.is_empty() { + Text::from(help).dim_range(..) + } else { + Text::from(help).color_range(3, ..) + }, + ); + let items = self.items(); + let visible = rows.saturating_sub(11).max(1); + self.selected = self.selected.min(items.len().saturating_sub(1)); + let start = self.selected.saturating_sub(visible - 1); + let mut table = Table::new().add_styled_row(vec![ + Text::from(match self.view { + View::Panes => "PANE / TITLE", + View::Tabs => "TAB / NAME", + View::Sessions => "SESSION", + _ => "COMMAND / VALUE", + }) + .color_range(0, ..), + Text::from("DETAILS").color_range(0, ..), + ]); + for (i, item) in items.iter().enumerate().skip(start).take(visible) { + let label = sanitize_display_text(&item.label); + let detail = sanitize_display_text(&item.detail); + let cells = [label, detail] + .into_iter() + .map(|v| { + let value = Text::from(v); + if i == self.selected { + value.selected() + } else { + value + } + }) + .collect(); + table = table.add_styled_row(cells); + } + print_table_with_coordinates(table, 2, 8, Some(width), Some(visible + 1)); + if items.is_empty() { + put(9, Text::from("No matches").dim_range(..)); + } + put( + rows.saturating_sub(2), + Text::from(format!("{} suggestions", items.len())).dim_range(..), + ); + put( + rows - 1, + Text::from(if self.view == View::Command { + "Tab complete · ↑↓ select · Enter run · Ctrl-p/n history · Esc close" + } else { + "↑↓ select · Enter choose · Esc back" + }) + .dim_range(..), + ); + } +} + +pub fn launch(open: Open) { + let tab = get_tab_info(open.origin.0).unwrap_or_default(); + let width = tab.display_area_columns.saturating_sub(4).clamp(12, 104); + let height = tab.display_area_rows.saturating_sub(4).clamp(5, 24); + let configuration = BTreeMap::from([( + "palette".into(), + serde_json::to_string(&open).expect("serializable popup"), + )]); + open_plugin_popup_with_options( + "command-prompt", + configuration, + tab.display_area_rows.saturating_sub(height) / 2, + tab.display_area_columns.saturating_sub(width) / 2, + width, + height, + PopupOptions::default() + .capture_all_keys(true) + .outside_click(OutsideClick::Close), + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn editor(line: &str) -> Palette { + Palette::new(Open { + owner: 1, + client: 1, + request: 1, + origin: (0, PaneId::Terminal(1)), + line: line.into(), + history: vec!["rename-tab work".into(), "move-focus right".into()], + error: String::new(), + }) + } + + #[test] + fn completing_a_quoted_name_preserves_the_rest_of_the_command() { + let mut palette = editor("rename-tab \"dé\" --help"); + palette.context.tab_name = "déploy 'green'".into(); + palette.input.set_cursor_position(14); + palette.complete(); + assert_eq!( + shlex::split(palette.input.get_text()).unwrap(), + ["rename-tab", "déploy 'green'", "--help"] + ); + } + + #[test] + fn history_round_trip_restores_the_unsubmitted_draft() { + let mut palette = editor("rename-tab \"unsaved draft\""); + palette.recall(true); + assert_eq!(palette.input.get_text(), "rename-tab work"); + palette.recall(true); + assert_eq!(palette.input.get_text(), "move-focus right"); + palette.recall(false); + palette.recall(false); + assert_eq!(palette.input.get_text(), "rename-tab \"unsaved draft\""); + } + + #[test] + fn invalid_argument_keeps_the_editable_line() { + let mut palette = editor("move-focus sideways"); + palette.activate(); + assert!(!palette.error.is_empty()); + assert_eq!(palette.input.get_text(), "move-focus sideways"); + assert!(palette.view == View::Command); + } +} From 7c29be4a0be64554e1eb9378206e65322e84650f Mon Sep 17 00:00:00 2001 From: Josh Lane <79638+lanej@users.noreply.github.com> Date: Fri, 6 Nov 2026 21:24:24 -0800 Subject: [PATCH 02/18] fix(zellij): refine supported suggestions and palette navigation checks --- scripts/test-zellij-command-prompt.py | 2 ++ zellij/command-prompt/src/commands.rs | 7 +++---- zellij/command-prompt/src/completion.rs | 7 +++++++ zellij/command-prompt/src/main.rs | 11 +++++++---- 4 files changed, 19 insertions(+), 8 deletions(-) diff --git a/scripts/test-zellij-command-prompt.py b/scripts/test-zellij-command-prompt.py index 9591fc86..83ff11c4 100644 --- a/scripts/test-zellij-command-prompt.py +++ b/scripts/test-zellij-command-prompt.py @@ -343,6 +343,8 @@ def unchanged(): second.until('history belongs to second client', lambda: 'live renamed target' in text(second)) assert 'toggle-floating-panes' not in text(second), 'History leaked from first client' second.send(b'\x1b') + second.until('history returns to editable command', lambda: 'Tab complete' in text(second)) + second.send(b'\x1b') second.until('history dialog closes before reload', lambda: 'Zellij command' not in text(second)) # Reload an observable ordinary binding in both clients, then verify the diff --git a/zellij/command-prompt/src/commands.rs b/zellij/command-prompt/src/commands.rs index 9004650b..8e4dc7b1 100644 --- a/zellij/command-prompt/src/commands.rs +++ b/zellij/command-prompt/src/commands.rs @@ -4,9 +4,8 @@ use zellij_tile::prelude::actions::Action; use zellij_utils::cli::CliAction; use zellij_utils::plugin_api::action::ProtobufAction; -// Queries need a stdout consumer, and layout-file loading needs host filesystem -// access. Keep those in the shell rather than silently discarding their output -// or expanding this plugin's permissions. Parsing remains Zellij's own grammar. +// Queries are rendered from native state by the palette. These mutations use +// Zellij's parser and plugin protocol; loading layout files stays in the shell. const COMMANDS: &[&str] = &[ "clear", "close-pane", @@ -55,7 +54,7 @@ pub fn metadata() -> Vec { pub fn argument_supported(name: &str) -> bool { !matches!( - name, + name.replace('_', "-").as_str(), "near-current-pane" | "no-focus" | "stacked" diff --git a/zellij/command-prompt/src/completion.rs b/zellij/command-prompt/src/completion.rs index 973faca5..eb78b241 100644 --- a/zellij/command-prompt/src/completion.rs +++ b/zellij/command-prompt/src/completion.rs @@ -444,5 +444,12 @@ mod tests { assert!(complete("new-pane -- htop --", 18, &ctx, &[]) .items .is_empty()); + assert!(!complete("new-pane --", 11, &ctx, &[]) + .items + .iter() + .any(|i| matches!( + i.value.as_str(), + "--no-focus" | "--in-place" | "--stacked" | "--plugin" + ))); } } diff --git a/zellij/command-prompt/src/main.rs b/zellij/command-prompt/src/main.rs index 55a7bd57..15d56243 100644 --- a/zellij/command-prompt/src/main.rs +++ b/zellij/command-prompt/src/main.rs @@ -145,7 +145,7 @@ impl ZellijPlugin for CommandPrompt { self.input(String::new()); } Err(error) => { - prompt(PromptRequest::notify(error)); + prompt(PromptRequest::notify(error).timeout(std::time::Duration::from_secs(4))); } } } else if matches!(message.source, PipeSource::Plugin(_)) && message.name == palette::REPLY @@ -205,9 +205,12 @@ impl CommandPrompt { }; if get_focused_pane_info().ok() != Some(origin) || get_pane_info(origin.1).is_none() { self.origin = None; - prompt(PromptRequest::notify( - "The original pane or tab changed. Reopen : in the intended pane.", - )); + prompt( + PromptRequest::notify( + "The original pane or tab changed. Reopen : in the intended pane.", + ) + .timeout(std::time::Duration::from_secs(4)), + ); return; } let cwd = match get_pane_cwd(origin.1) { From 4632437dcc23deb6b24bbbedcd993f121c3ac1bb Mon Sep 17 00:00:00 2001 From: Josh Lane <79638+lanej@users.noreply.github.com> Date: Fri, 6 Nov 2026 21:27:31 -0800 Subject: [PATCH 03/18] perf(zellij): cache command metadata and retain terminal diagnostics --- .github/workflows/zellij.yml | 5 ++++- scripts/test-zellij-command-prompt.py | 4 ++++ zellij/command-prompt/src/commands.rs | 21 ++++++++++++--------- zellij/command-prompt/src/completion.rs | 7 ++----- 4 files changed, 22 insertions(+), 15 deletions(-) diff --git a/.github/workflows/zellij.yml b/.github/workflows/zellij.yml index 580d0da7..4104461a 100644 --- a/.github/workflows/zellij.yml +++ b/.github/workflows/zellij.yml @@ -162,7 +162,10 @@ jobs: key: ${{ steps.zellij_cache.outputs.cache-primary-key }} - name: Exercise command palette if: ${{ !cancelled() && steps.zellij_install.outcome == 'success' }} - run: make test-zellij-command-prompt + run: | + mkdir -p "$ZELLIJ_TEST_ARTIFACTS/command-palette" + set -o pipefail + make test-zellij-command-prompt 2>&1 | tee "$ZELLIJ_TEST_ARTIFACTS/command-palette/integration.log" - name: Preserve command palette terminal captures if: always() uses: actions/upload-artifact@v4 diff --git a/scripts/test-zellij-command-prompt.py b/scripts/test-zellij-command-prompt.py index 83ff11c4..e506c605 100644 --- a/scripts/test-zellij-command-prompt.py +++ b/scripts/test-zellij-command-prompt.py @@ -471,6 +471,10 @@ def main(): second = Terminal(binary, env, name, install_navigation=False) clients.append(second) exercise(first, second, home) + except Exception: + for index, client in enumerate(clients): + capture(client, 'failure-client-' + str(index)) + raise finally: subprocess.run([binary, 'kill-session', name], env=env, capture_output=True, timeout=5) for client in clients: diff --git a/zellij/command-prompt/src/commands.rs b/zellij/command-prompt/src/commands.rs index 8e4dc7b1..570e3efc 100644 --- a/zellij/command-prompt/src/commands.rs +++ b/zellij/command-prompt/src/commands.rs @@ -1,5 +1,5 @@ use clap::{CommandFactory, Parser}; -use std::path::PathBuf; +use std::{path::PathBuf, sync::OnceLock}; use zellij_tile::prelude::actions::Action; use zellij_utils::cli::CliAction; use zellij_utils::plugin_api::action::ProtobufAction; @@ -42,14 +42,17 @@ const COMMANDS: &[&str] = &[ "undo-rename-tab", ]; -pub fn metadata() -> Vec { - let mut command = CommandLine::command(); - command.build(); - command - .get_subcommands() - .filter(|command| COMMANDS.contains(&command.get_name())) - .cloned() - .collect() +pub fn metadata() -> &'static [clap::Command] { + static METADATA: OnceLock> = OnceLock::new(); + METADATA.get_or_init(|| { + let mut command = CommandLine::command(); + command.build(); + command + .get_subcommands() + .filter(|command| COMMANDS.contains(&command.get_name())) + .cloned() + .collect() + }) } pub fn argument_supported(name: &str) -> bool { diff --git a/zellij/command-prompt/src/completion.rs b/zellij/command-prompt/src/completion.rs index eb78b241..6031fa3d 100644 --- a/zellij/command-prompt/src/completion.rs +++ b/zellij/command-prompt/src/completion.rs @@ -145,7 +145,7 @@ pub fn complete(line: &str, cursor: usize, context: &Context, history: &[String] help: String::new(), command: before.is_empty(), }; - let mut metadata = commands::metadata(); + let metadata = commands::metadata(); if before.is_empty() { result.items = metadata .iter() @@ -193,10 +193,7 @@ pub fn complete(line: &str, cursor: usize, context: &Context, history: &[String] "switch-session" if before.len() == 1 => session_targets(context), _ => vec![], }; - } else if let Some(command) = metadata - .iter_mut() - .find(|command| command.get_name() == name) - { + } else if let Some(command) = metadata.iter().find(|command| command.get_name() == name) { result.help = command .get_about() .map(ToString::to_string) From 6caa0113bcce5340b1028b13e36eecd58d854ab1 Mon Sep 17 00:00:00 2001 From: Josh Lane <79638+lanej@users.noreply.github.com> Date: Fri, 6 Nov 2026 21:33:33 -0800 Subject: [PATCH 04/18] fix(zellij): center palette and preserve visible details and editing --- .github/workflows/zellij.yml | 22 +++---- scripts/test-zellij-command-prompt.py | 8 ++- zellij/command-prompt/README.md | 2 +- zellij/command-prompt/src/completion.rs | 9 +++ zellij/command-prompt/src/palette.rs | 81 +++++++++++++++++++------ 5 files changed, 91 insertions(+), 31 deletions(-) diff --git a/.github/workflows/zellij.yml b/.github/workflows/zellij.yml index 4104461a..e46efcfb 100644 --- a/.github/workflows/zellij.yml +++ b/.github/workflows/zellij.yml @@ -149,17 +149,6 @@ jobs: bash scripts/install-zellij "$HOME/.local/bin/zellij" --version echo "$HOME/.local/bin" >> "$GITHUB_PATH" - - name: Install pane navigation - run: python3 scripts/install-zellij-navigation.py - - name: Save native Zellij build - uses: actions/cache/save@v4 - if: ${{ !cancelled() && steps.zellij_cache.outputs.cache-hit != 'true' }} - with: - path: | - ~/.cargo/registry - ~/.cargo/git - ~/.cache/dotfiles/zellij - key: ${{ steps.zellij_cache.outputs.cache-primary-key }} - name: Exercise command palette if: ${{ !cancelled() && steps.zellij_install.outcome == 'success' }} run: | @@ -174,6 +163,17 @@ jobs: path: ${{ github.workspace }}/.zellij-diagnostics/command-palette if-no-files-found: ignore retention-days: 7 + - name: Install pane navigation + run: python3 scripts/install-zellij-navigation.py + - name: Save native Zellij build + uses: actions/cache/save@v4 + if: ${{ !cancelled() && steps.zellij_cache.outputs.cache-hit != 'true' }} + with: + path: | + ~/.cargo/registry + ~/.cargo/git + ~/.cache/dotfiles/zellij + key: ${{ steps.zellij_cache.outputs.cache-primary-key }} - name: Install Treehouse 3.1.6 shell: bash run: | diff --git a/scripts/test-zellij-command-prompt.py b/scripts/test-zellij-command-prompt.py index e506c605..b0662fea 100644 --- a/scripts/test-zellij-command-prompt.py +++ b/scripts/test-zellij-command-prompt.py @@ -141,13 +141,15 @@ def open_prompt(terminal): original_terminals = {p['id'] for p in terminal.terminals()} terminal.prefix(b":") try: - terminal.until("Ctrl-f : opens native input", lambda: "Zellij command" in text(terminal)) + terminal.until("Ctrl-f : renders native input and controls", lambda: + "Zellij command :" in text(terminal) and 'Esc close' in text(terminal)) except AssertionError: print("Decoded prompt screen:\n" + text(terminal), flush=True) raise title_rows = [row for row, line in enumerate(screen(terminal)) if "Zellij command" in line] footer_rows = [row for row, line in enumerate(screen(terminal)) if 'Esc close' in line] - assert title_rows and footer_rows and 27 <= title_rows[0] + footer_rows[0] <= 36, 'Palette is not vertically centered' + assert title_rows and footer_rows and 27 <= title_rows[0] + footer_rows[0] <= 36, ( + f'Palette is not vertically centered: title={title_rows}, footer={footer_rows}\n' + text(terminal)) assert all(10 <= screen(terminal)[row].index('Zellij command') <= 90 for row in title_rows), 'Prompt is not horizontally centered' # A native prompt renders over the existing panes; it must not create a @@ -291,6 +293,8 @@ def unchanged(): open_prompt(first) first.send(b'new-pane --dir\t ') first.until('native flag completion', lambda: '--direction' in text(first) and 'right' in text(first)) + first.send(b'r\t') + first.until('direction value completion', lambda: 'new-pane --direction right' in text(first)) capture(first, 'completion') first.send(b'\x1b') unchanged() diff --git a/zellij/command-prompt/README.md b/zellij/command-prompt/README.md index dff5d5ff..f4a36eb4 100644 --- a/zellij/command-prompt/README.md +++ b/zellij/command-prompt/README.md @@ -12,7 +12,7 @@ | `focus-pane-id ` | Live pane IDs, titles, and tabs | | `switch-session ` | Running session names and client counts | -Arrow keys select suggestions. Up from an empty command recalls history; Ctrl-p/Ctrl-n recall older/newer commands and restore the unsubmitted draft. Ctrl-r or `history` opens searchable history. It retains the last 32 distinct submitted commands in memory for each attached client, until the plugin/session restarts. Recent actions rank ahead of other equally matching commands. +Arrow keys select suggestions. Ctrl-u clears before the cursor, Ctrl-k clears after it, and Ctrl-w deletes the previous word. Up from an empty command recalls history; Ctrl-p/Ctrl-n recall older/newer commands and restore the unsubmitted draft. Ctrl-r or `history` opens searchable history. It retains the last 32 distinct submitted commands in memory for each attached client, until the plugin/session restarts. Recent actions rank ahead of other equally matching commands. `help` opens the command list. An action's `--help` displays its native documentation; Enter or Esc returns to the editable command. Parse errors retain the command and display the error inline. The completion engine uses metadata from the pinned Zellij CLI parser; unsupported variants are excluded from flag suggestions. Program arguments after `--` are left to that program. diff --git a/zellij/command-prompt/src/completion.rs b/zellij/command-prompt/src/completion.rs index 6031fa3d..68bd2e1c 100644 --- a/zellij/command-prompt/src/completion.rs +++ b/zellij/command-prompt/src/completion.rs @@ -300,6 +300,15 @@ pub fn complete(line: &str, cursor: usize, context: &Context, history: &[String] result .items .retain(|item| matches(&prefix, &format!("{} {}", item.label, item.detail))); + if !prefix.is_empty() { + let query = prefix.to_lowercase(); + result.items.sort_by_key(|item| { + ( + !item.value.to_lowercase().starts_with(&query), + !matches(&prefix, &item.label), + ) + }); + } result } diff --git a/zellij/command-prompt/src/palette.rs b/zellij/command-prompt/src/palette.rs index 823db6ae..f2541277 100644 --- a/zellij/command-prompt/src/palette.rs +++ b/zellij/command-prompt/src/palette.rs @@ -254,6 +254,23 @@ impl Palette { self.recall(false); } else if ctrl('r') { self.show(View::History); + } else if self.view != View::Documentation && (ctrl('u') || ctrl('k') || ctrl('w')) { + if ctrl('w') { + self.input.delete_word_backward(); + } else { + let cursor = self.input.cursor_position(); + let line: String = if ctrl('u') { + self.input.get_text().chars().skip(cursor).collect() + } else { + self.input.get_text().chars().take(cursor).collect() + }; + self.input.set_text(line); + self.input + .set_cursor_position(if ctrl('u') { 0 } else { cursor }); + } + self.history_at = None; + self.selected = 0; + self.error.clear(); } else if plain && key.bare_key == BareKey::Up { if self.history_at.is_some() || (self.input.is_empty() && self.view == View::Command) { self.recall(true); @@ -445,7 +462,15 @@ impl Palette { let visible = rows.saturating_sub(11).max(1); self.selected = self.selected.min(items.len().saturating_sub(1)); let start = self.selected.saturating_sub(visible - 1); - let mut table = Table::new().add_styled_row(vec![ + let label_width = width * 2 / 5; + let detail_width = width.saturating_sub(label_width + 2); + let cell = |column, row, available, value| { + print_text_with_coordinates(value, column, row, Some(available), Some(1)) + }; + cell( + 2, + 8, + label_width, Text::from(match self.view { View::Panes => "PANE / TITLE", View::Tabs => "TAB / NAME", @@ -453,25 +478,29 @@ impl Palette { _ => "COMMAND / VALUE", }) .color_range(0, ..), + ); + cell( + label_width + 4, + 8, + detail_width, Text::from("DETAILS").color_range(0, ..), - ]); + ); for (i, item) in items.iter().enumerate().skip(start).take(visible) { let label = sanitize_display_text(&item.label); let detail = sanitize_display_text(&item.detail); - let cells = [label, detail] - .into_iter() - .map(|v| { - let value = Text::from(v); - if i == self.selected { - value.selected() - } else { - value - } - }) - .collect(); - table = table.add_styled_row(cells); + for (column, available, v) in [ + (2, label_width, label), + (label_width + 4, detail_width, detail), + ] { + let value = Text::from(v); + let value = if i == self.selected { + value.selected() + } else { + value + }; + cell(column, 9 + i - start, available, value); + } } - print_table_with_coordinates(table, 2, 8, Some(width), Some(visible + 1)); if items.is_empty() { put(9, Text::from("No matches").dim_range(..)); } @@ -493,6 +522,7 @@ impl Palette { pub fn launch(open: Open) { let tab = get_tab_info(open.origin.0).unwrap_or_default(); + let dispatcher = get_pane_info(PaneId::Plugin(open.owner)).unwrap_or_default(); let width = tab.display_area_columns.saturating_sub(4).clamp(12, 104); let height = tab.display_area_rows.saturating_sub(4).clamp(5, 24); let configuration = BTreeMap::from([( @@ -502,8 +532,12 @@ pub fn launch(open: Open) { open_plugin_popup_with_options( "command-prompt", configuration, - tab.display_area_rows.saturating_sub(height) / 2, - tab.display_area_columns.saturating_sub(width) / 2, + // OpenPluginPopup coordinates are relative to the requesting plugin, + // including the retained geometry of a suppressed background pane. + (tab.display_area_rows.saturating_sub(height) / 2) + .saturating_sub(dispatcher.pane_content_y), + (tab.display_area_columns.saturating_sub(width) / 2) + .saturating_sub(dispatcher.pane_content_x), width, height, PopupOptions::default() @@ -559,5 +593,18 @@ mod tests { assert!(!palette.error.is_empty()); assert_eq!(palette.input.get_text(), "move-focus sideways"); assert!(palette.view == View::Command); + palette.key(KeyWithModifier::new(BareKey::Char('u')).with_ctrl_modifier()); + for c in "move-focus right".chars() { + palette.key(KeyWithModifier::new(BareKey::Char(c))); + } + assert_eq!(palette.input.get_text(), "move-focus right"); + assert!(palette.error.is_empty()); + } + + #[test] + fn completing_a_value_prefers_its_name_to_description_matches() { + let mut palette = editor("move-focus r"); + palette.complete(); + assert_eq!(palette.input.get_text(), "move-focus right"); } } From 250d0feb0f8f36aaf9454800ef727a6260347fb8 Mon Sep 17 00:00:00 2001 From: Josh Lane <79638+lanej@users.noreply.github.com> Date: Fri, 6 Nov 2026 21:40:18 -0800 Subject: [PATCH 05/18] test(zellij): retain native popup geometry on placement failures --- scripts/test-zellij-command-prompt.py | 4 +++- zellij/command-prompt/src/palette.rs | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/scripts/test-zellij-command-prompt.py b/scripts/test-zellij-command-prompt.py index b0662fea..12767de2 100644 --- a/scripts/test-zellij-command-prompt.py +++ b/scripts/test-zellij-command-prompt.py @@ -149,7 +149,8 @@ def open_prompt(terminal): title_rows = [row for row, line in enumerate(screen(terminal)) if "Zellij command" in line] footer_rows = [row for row, line in enumerate(screen(terminal)) if 'Esc close' in line] assert title_rows and footer_rows and 27 <= title_rows[0] + footer_rows[0] <= 36, ( - f'Palette is not vertically centered: title={title_rows}, footer={footer_rows}\n' + text(terminal)) + f'Palette is not vertically centered: title={title_rows}, footer={footer_rows}\n' + + json.dumps(terminal.tabs()) + '\n' + json.dumps(terminal.panes()) + '\n' + text(terminal)) assert all(10 <= screen(terminal)[row].index('Zellij command') <= 90 for row in title_rows), 'Prompt is not horizontally centered' # A native prompt renders over the existing panes; it must not create a @@ -478,6 +479,7 @@ def main(): except Exception: for index, client in enumerate(clients): capture(client, 'failure-client-' + str(index)) + print('\n'.join(client.startup_diagnostics()), flush=True) raise finally: subprocess.run([binary, 'kill-session', name], env=env, capture_output=True, timeout=5) diff --git a/zellij/command-prompt/src/palette.rs b/zellij/command-prompt/src/palette.rs index f2541277..e2430844 100644 --- a/zellij/command-prompt/src/palette.rs +++ b/zellij/command-prompt/src/palette.rs @@ -525,6 +525,10 @@ pub fn launch(open: Open) { let dispatcher = get_pane_info(PaneId::Plugin(open.owner)).unwrap_or_default(); let width = tab.display_area_columns.saturating_sub(4).clamp(12, 104); let height = tab.display_area_rows.saturating_sub(4).clamp(5, 24); + eprintln!("command-palette geometry: screen={}x{} dispatcher={}@{},{} content={},{} wanted={}x{} origin={}", + tab.display_area_columns, tab.display_area_rows, open.owner, + dispatcher.pane_x, dispatcher.pane_y, dispatcher.pane_content_x, dispatcher.pane_content_y, + width, height, open.origin.0); let configuration = BTreeMap::from([( "palette".into(), serde_json::to_string(&open).expect("serializable popup"), From 652d51b0cdae936b4ce0326457656d513af859b4 Mon Sep 17 00:00:00 2001 From: Josh Lane <79638+lanej@users.noreply.github.com> Date: Fri, 6 Nov 2026 21:46:27 -0800 Subject: [PATCH 06/18] fix(zellij): normalize dispatcher origin before opening native popups --- zellij/command-prompt/src/main.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/zellij/command-prompt/src/main.rs b/zellij/command-prompt/src/main.rs index 15d56243..945189ee 100644 --- a/zellij/command-prompt/src/main.rs +++ b/zellij/command-prompt/src/main.rs @@ -113,6 +113,21 @@ impl ZellijPlugin for CommandPrompt { match event { Event::PermissionRequestResult(PermissionStatus::Granted) => { self.allowed = true; + let pane_id = PaneId::Plugin(self.plugin_id); + if get_pane_info(pane_id) + .is_some_and(|pane| pane.pane_content_x != 0 || pane.pane_content_y != 0) + { + // Popup offsets are unsigned and relative to this pane. + // Normalize our own retained origin once, before hiding it; + // later clients reuse the already-normalized background pane. + change_floating_panes_coordinates(vec![( + pane_id, + FloatingPaneCoordinates::default() + .with_x_fixed(0) + .with_y_fixed(0), + )]); + set_pane_borderless(pane_id, true); + } hide_self(); self.bind(); } From b37c81fbdf4154a9dd86bed034c7400314a9d06c Mon Sep 17 00:00:00 2001 From: Josh Lane <79638+lanej@users.noreply.github.com> Date: Fri, 6 Nov 2026 21:53:31 -0800 Subject: [PATCH 07/18] fix(zellij): wrap command errors and offer scrollable details --- scripts/test-zellij-command-prompt.py | 12 +++++- zellij/command-prompt/README.md | 2 +- zellij/command-prompt/src/palette.rs | 61 ++++++++++++++++++--------- 3 files changed, 51 insertions(+), 24 deletions(-) diff --git a/scripts/test-zellij-command-prompt.py b/scripts/test-zellij-command-prompt.py index 12767de2..8781c3a9 100644 --- a/scripts/test-zellij-command-prompt.py +++ b/scripts/test-zellij-command-prompt.py @@ -268,8 +268,15 @@ def unchanged(): ('definitely-not-a-command', 'Unknown or unsupported command'), ): submit(first, command, second) - first.until('unsupported variant remains editable: ' + command, lambda: message in text(first)) + first.until('unsupported variant remains editable: ' + command, lambda: message in ' '.join(text(first).split())) unchanged() + if command == 'new-pane --near-current-pane': + first.send(b'\x04') + first.until('full error details are available', lambda: 'Zellij command · help' in text(first) + and message in ' '.join(text(first).split())) + first.send(b'\x1b') + first.until('error details return to the original command', lambda: + 'new-pane --near-current-pane' in text(first) and 'Tab complete' in text(first)) first.send(b'\x1b') submit(first, 'rename-pane --help', second) first.until('native action help is displayed', lambda: 'Usage:' in text(first)) @@ -302,6 +309,8 @@ def unchanged(): submit(first, 'list-panes', second) first.until('pane query is a searchable table', lambda: 'PANE / TITLE' in text(first) and 'other client' in text(first)) + first.send(b'\t') + first.until('Tab selects without leaving the query', lambda: 'PANE / TITLE' in text(first)) capture(first, 'pane-query') first.send(b'other client') first.until('pane table filters by title', lambda: '1 suggestions' in text(first)) @@ -479,7 +488,6 @@ def main(): except Exception: for index, client in enumerate(clients): capture(client, 'failure-client-' + str(index)) - print('\n'.join(client.startup_diagnostics()), flush=True) raise finally: subprocess.run([binary, 'kill-session', name], env=env, capture_output=True, timeout=5) diff --git a/zellij/command-prompt/README.md b/zellij/command-prompt/README.md index f4a36eb4..d323a09f 100644 --- a/zellij/command-prompt/README.md +++ b/zellij/command-prompt/README.md @@ -14,7 +14,7 @@ Arrow keys select suggestions. Ctrl-u clears before the cursor, Ctrl-k clears after it, and Ctrl-w deletes the previous word. Up from an empty command recalls history; Ctrl-p/Ctrl-n recall older/newer commands and restore the unsubmitted draft. Ctrl-r or `history` opens searchable history. It retains the last 32 distinct submitted commands in memory for each attached client, until the plugin/session restarts. Recent actions rank ahead of other equally matching commands. -`help` opens the command list. An action's `--help` displays its native documentation; Enter or Esc returns to the editable command. Parse errors retain the command and display the error inline. The completion engine uses metadata from the pinned Zellij CLI parser; unsupported variants are excluded from flag suggestions. Program arguments after `--` are left to that program. +`help` opens the command list. An action's `--help` displays its native documentation; Enter or Esc returns to the editable command. Parse errors retain the command and wrap the error inline. Ctrl-d opens the full error in a scrollable view; Esc returns to editing. The completion engine uses metadata from the pinned Zellij CLI parser; unsupported variants are excluded from flag suggestions. Program arguments after `--` are left to that program. ## Search and jump diff --git a/zellij/command-prompt/src/palette.rs b/zellij/command-prompt/src/palette.rs index e2430844..596dc3aa 100644 --- a/zellij/command-prompt/src/palette.rs +++ b/zellij/command-prompt/src/palette.rs @@ -202,6 +202,10 @@ impl Palette { } fn complete(&mut self) { + if matches!(self.view, View::Panes | View::Tabs | View::Sessions) { + self.select(1); + return; + } let items = self.items(); let Some(item) = items.get(self.selected) else { return; @@ -254,6 +258,11 @@ impl Palette { self.recall(false); } else if ctrl('r') { self.show(View::History); + } else if ctrl('d') && !self.error.is_empty() { + self.open.line = self.input.get_text().into(); + self.documentation = self.error.clone(); + self.view = View::Documentation; + self.scroll = 0; } else if self.view != View::Documentation && (ctrl('u') || ctrl('k') || ctrl('w')) { if ctrl('w') { self.input.delete_word_backward(); @@ -416,7 +425,11 @@ impl Palette { .dim_range(..), ); if self.view == View::Documentation { - let lines: Vec<_> = self.documentation.lines().collect(); + let lines: Vec<_> = self + .documentation + .lines() + .flat_map(|line| Text::from(line).wrap(width)) + .collect(); self.scroll = self .scroll .min(lines.len().saturating_sub(rows.saturating_sub(6))); @@ -426,7 +439,7 @@ impl Palette { .take(rows.saturating_sub(6)) .enumerate() { - put(i + 4, Text::from(*line)); + put(i + 4, line.clone()); } put( rows - 1, @@ -448,18 +461,26 @@ impl Palette { "Filter by name or details. Enter selects; Esc returns to your command.".into() } } else { - self.error.lines().take(2).collect::>().join(" ") + self.error.clone() }; - put( - 6, - if self.error.is_empty() { - Text::from(help).dim_range(..) - } else { - Text::from(help).color_range(3, ..) - }, - ); + let message = if self.error.is_empty() { + Text::from(help).dim_range(..) + } else { + Text::from(help).color_range(3, ..) + }; + let lines = message.wrap(width); + let limit = if self.error.is_empty() { + 2 + } else { + rows.saturating_sub(14).clamp(1, 6) + }; + let message_rows = lines.len().max(1).min(limit); + for (i, line) in lines.into_iter().take(limit).enumerate() { + put(6 + i, line); + } + let table_row = 7 + message_rows; let items = self.items(); - let visible = rows.saturating_sub(11).max(1); + let visible = rows.saturating_sub(table_row + 3).max(1); self.selected = self.selected.min(items.len().saturating_sub(1)); let start = self.selected.saturating_sub(visible - 1); let label_width = width * 2 / 5; @@ -469,7 +490,7 @@ impl Palette { }; cell( 2, - 8, + table_row, label_width, Text::from(match self.view { View::Panes => "PANE / TITLE", @@ -481,7 +502,7 @@ impl Palette { ); cell( label_width + 4, - 8, + table_row, detail_width, Text::from("DETAILS").color_range(0, ..), ); @@ -498,11 +519,11 @@ impl Palette { } else { value }; - cell(column, 9 + i - start, available, value); + cell(column, table_row + 1 + i - start, available, value); } } if items.is_empty() { - put(9, Text::from("No matches").dim_range(..)); + put(table_row + 1, Text::from("No matches").dim_range(..)); } put( rows.saturating_sub(2), @@ -510,7 +531,9 @@ impl Palette { ); put( rows - 1, - Text::from(if self.view == View::Command { + Text::from(if self.view == View::Command && !self.error.is_empty() { + "Tab complete · Enter run · Ctrl-d error details · Esc close" + } else if self.view == View::Command { "Tab complete · ↑↓ select · Enter run · Ctrl-p/n history · Esc close" } else { "↑↓ select · Enter choose · Esc back" @@ -525,10 +548,6 @@ pub fn launch(open: Open) { let dispatcher = get_pane_info(PaneId::Plugin(open.owner)).unwrap_or_default(); let width = tab.display_area_columns.saturating_sub(4).clamp(12, 104); let height = tab.display_area_rows.saturating_sub(4).clamp(5, 24); - eprintln!("command-palette geometry: screen={}x{} dispatcher={}@{},{} content={},{} wanted={}x{} origin={}", - tab.display_area_columns, tab.display_area_rows, open.owner, - dispatcher.pane_x, dispatcher.pane_y, dispatcher.pane_content_x, dispatcher.pane_content_y, - width, height, open.origin.0); let configuration = BTreeMap::from([( "palette".into(), serde_json::to_string(&open).expect("serializable popup"), From db432b7948e10db435fa86947fafa9ac91bf763f Mon Sep 17 00:00:00 2001 From: Josh Lane <79638+lanej@users.noreply.github.com> Date: Fri, 6 Nov 2026 22:00:41 -0800 Subject: [PATCH 08/18] docs(zellij): show captured command palette --- docs/images/zellij-command-palette.png | Bin 0 -> 40577 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/images/zellij-command-palette.png diff --git a/docs/images/zellij-command-palette.png b/docs/images/zellij-command-palette.png new file mode 100644 index 0000000000000000000000000000000000000000..b20bebf0125da6d398579c16ef6a419405907212 GIT binary patch literal 40577 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 SmYjgUfcuh2iQKynU;ID&zJ!ke literal 0 HcmV?d00001 From 73e628b5a203aa0ddbf50611a90dca8d31902ccd Mon Sep 17 00:00:00 2001 From: Josh Lane <79638+lanej@users.noreply.github.com> Date: Sat, 7 Nov 2026 07:24:06 -0800 Subject: [PATCH 09/18] refactor(zellij): retain native editing in the compact command line --- scripts/test-zellij-command-prompt.py | 10 +++- zellij/command-prompt/src/palette.rs | 81 ++++++++------------------- 2 files changed, 32 insertions(+), 59 deletions(-) diff --git a/scripts/test-zellij-command-prompt.py b/scripts/test-zellij-command-prompt.py index d9b1d727..ffed1986 100644 --- a/scripts/test-zellij-command-prompt.py +++ b/scripts/test-zellij-command-prompt.py @@ -153,6 +153,12 @@ def command_line(terminal): return screen(terminal)[top + 1][left + 1:left + width - 1] +def prompt_text(terminal): + top, left, width, height = prompt_frame(terminal) + return '\n'.join(line[left + 1:left + width - 1] + for line in screen(terminal)[top + 1:top + height - 1]) + + def open_prompt(terminal): # Wait for asynchronous closure so new keys cannot enter a stale popup. terminal.until("previous command dialog closes", lambda: "Zellij command" not in text(terminal)) @@ -280,12 +286,12 @@ def unchanged(): ('definitely-not-a-command', 'Unknown or unsupported command'), ): submit(first, command, second) - first.until('unsupported variant remains editable: ' + command, lambda: message in ' '.join(text(first).split())) + first.until('unsupported variant remains editable: ' + command, lambda: message in ' '.join(prompt_text(first).split())) unchanged() if command == 'new-pane --near-current-pane': first.send(b'\x04') first.until('full error details are available', lambda: 'Zellij command · help' in text(first) - and message in ' '.join(text(first).split())) + and message in ' '.join(prompt_text(first).split())) first.send(b'\x1b') first.until('error details return to the original command', lambda: 'new-pane --near-current-pane' in text(first) and 'Tab complete' in text(first)) diff --git a/zellij/command-prompt/src/palette.rs b/zellij/command-prompt/src/palette.rs index 360b0e31..0370fd66 100644 --- a/zellij/command-prompt/src/palette.rs +++ b/zellij/command-prompt/src/palette.rs @@ -54,13 +54,15 @@ pub struct Palette { scroll: usize, allowed: bool, popup_size: Option<(usize, usize)>, - input_offset: usize, } impl Palette { pub fn new(open: Open) -> Self { Self { - input: TextInput::new(&open.line).label(":").focused(), + input: TextInput::new(&open.line) + .label(":") + .label_width(2) + .focused(), error: open.error.clone(), open, view: View::Command, @@ -72,7 +74,6 @@ impl Palette { scroll: 0, allowed: false, popup_size: None, - input_offset: 0, } } @@ -204,7 +205,10 @@ impl Palette { fn back(&mut self) { self.view = View::Command; - self.input = TextInput::new(&self.open.line).label(":").focused(); + self.input = TextInput::new(&self.open.line) + .label(":") + .label_width(2) + .focused(); self.selected = 0; self.error.clear(); } @@ -322,7 +326,7 @@ impl Palette { self.open.line = self.input.get_text().into(); } self.view = view; - self.input = TextInput::empty().label("filter").focused(); + self.input = TextInput::empty().label("filter").label_width(2).focused(); self.selected = 0; self.error.clear(); self.refresh(); @@ -398,59 +402,22 @@ impl Palette { close_self(); } - // Keep the SDK editor's grapheme-aware editing, but draw one unboxed line - // inside the popup frame instead of nesting a second input box inside it. - fn render_input(&mut self, width: usize) { - let label = if self.view == View::Command { ":" } else { "/" }; - print_text_with_coordinates(Text::from(label).color_range(0, ..), 2, 1, Some(1), Some(1)); - let available = width.saturating_sub(2); - let text: String = self - .input - .get_text() - .chars() - .filter(|c| !c.is_control()) - .collect(); - let prefix: String = self - .input - .get_text() - .chars() - .take(self.input.cursor_position()) - .filter(|c| !c.is_control()) - .collect(); - let clusters: Vec<_> = graphemes::clusters(&text).collect(); - let cursor = graphemes::clusters(&prefix).count().min(clusters.len()); - let (caret, caret_width) = clusters.get(cursor).copied().unwrap_or((" ", 1)); - let caret_width = caret_width.max(1); - self.input_offset = self.input_offset.min(cursor); - while clusters[self.input_offset..cursor] - .iter() - .map(|(_, w)| *w) - .sum::() - + caret_width - > available - { - self.input_offset += 1; - } - let mut used = 0; - let visible: String = clusters - .iter() - .skip(self.input_offset) - .take_while(|(_, w)| { - used += *w; - used <= available - }) - .map(|(s, _)| *s) - .collect(); - print_text_with_coordinates(Text::from(visible), 4, 1, Some(available), Some(1)); - let cursor_column = clusters[self.input_offset..cursor] - .iter() - .map(|(_, w)| *w) - .sum::(); + fn render_input(&mut self, columns: usize) { + // Reuse the native editor's scrolling and grapheme cursor. Its field + // brackets share the frame's edges, so replace them with our prompt + // and right border rather than drawing another box inside the popup. + self.input.render(0, 1, columns); + let label = if self.view == View::Command { + "│ : " + } else { + "│ / " + }; + print_text_with_coordinates(Text::from(label).color_range(0, ..), 0, 1, Some(4), Some(1)); print_text_with_coordinates( - Text::from(caret).selected(), - 4 + cursor_column, + Text::from("│").color_range(0, ..), + columns - 1, 1, - Some(caret_width.min(available - cursor_column)), + Some(1), Some(1), ); } @@ -561,7 +528,7 @@ impl Palette { ); return; } - self.render_input(width); + self.render_input(columns); border(2, '├', '┤'); let table_row = if compact { 3 } else { 4 }; let visible = if compact { From 1ef29792945cfe206351b2a8e049769e0935e813 Mon Sep 17 00:00:00 2001 From: Josh Lane <79638+lanej@users.noreply.github.com> Date: Sat, 7 Nov 2026 07:27:17 -0800 Subject: [PATCH 10/18] style(zellij): keep the compact frame continuous around its labels --- zellij/command-prompt/src/palette.rs | 30 +++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/zellij/command-prompt/src/palette.rs b/zellij/command-prompt/src/palette.rs index 0370fd66..ec24592b 100644 --- a/zellij/command-prompt/src/palette.rs +++ b/zellij/command-prompt/src/palette.rs @@ -510,7 +510,23 @@ impl Palette { View::Sessions => "Zellij command · sessions", View::Documentation => "Zellij command · help", }; - put(0, Text::from(format!(" {title} ")).color_range(0, ..)); + let title = format!(" {title} "); + print_text_with_coordinates( + Text::from(&title).color_range(0, ..), + 2, + 0, + Some(graphemes::display_width(&title).min(width)), + Some(1), + ); + let frame_label = |row, label: &str| { + print_text_with_coordinates( + Text::from(label).dim_range(..), + 2, + row, + Some(graphemes::display_width(label).min(width)), + Some(1), + ) + }; if self.view == View::Documentation { let lines: Vec<_> = self .documentation @@ -522,10 +538,7 @@ impl Palette { for (i, line) in lines.iter().skip(self.scroll).take(visible).enumerate() { put(i + 1, line.clone()); } - put( - rows - 1, - Text::from(" ↑↓ scroll · Enter/Esc back ").dim_range(..), - ); + frame_label(rows - 1, " ↑↓ scroll · Enter/Esc back "); return; } self.render_input(columns); @@ -613,16 +626,15 @@ impl Palette { Text::from(format!("{} suggestions", items.len())).dim_range(..), ); } - put( + frame_label( rows - 1, - Text::from(if compact && !self.error.is_empty() { + if compact && !self.error.is_empty() { " Ctrl-d details · Esc close " } else if compact { " Tab complete · Enter run · Esc close " } else { " ↑↓ select · Enter choose · Esc back " - }) - .dim_range(..), + }, ); } } From f0f1163979fd671df8f1dc95e5763c02641c0b40 Mon Sep 17 00:00:00 2001 From: Josh Lane <79638+lanej@users.noreply.github.com> Date: Sat, 7 Nov 2026 07:30:16 -0800 Subject: [PATCH 11/18] feat(zellij): give command history the full compact width --- scripts/test-zellij-command-prompt.py | 4 ++- zellij/command-prompt/src/palette.rs | 39 ++++++++++++++++----------- 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/scripts/test-zellij-command-prompt.py b/scripts/test-zellij-command-prompt.py index ffed1986..8125555c 100644 --- a/scripts/test-zellij-command-prompt.py +++ b/scripts/test-zellij-command-prompt.py @@ -309,8 +309,10 @@ def unchanged(): open_prompt(first) command_anchor = prompt_frame(first)[:2] + original_height = prompt_frame(first)[3] first.send(b'rename-ta') - first.until('inline command suggestions', lambda: 'rename-tab' in text(first) and prompt_frame(first)[3] == 5) + first.until('inline command suggestions shrink the dropdown', lambda: + 'rename-tab' in text(first) and prompt_frame(first)[3] < original_height) assert prompt_frame(first)[:2] == command_anchor, 'Completion moved the command line' first.send(b'\t') first.until('Tab completes the action', lambda: 'rename-tab ' in text(first)) diff --git a/zellij/command-prompt/src/palette.rs b/zellij/command-prompt/src/palette.rs index ec24592b..cb1abd67 100644 --- a/zellij/command-prompt/src/palette.rs +++ b/zellij/command-prompt/src/palette.rs @@ -551,15 +551,19 @@ impl Palette { }; self.selected = self.selected.min(items.len().saturating_sub(1)); let start = self.selected.saturating_sub(visible.saturating_sub(1)); - let label_width = items - .iter() - .skip(start) - .take(visible) - .map(|s| graphemes::display_width(&sanitize_display_text(&s.label))) - .max() - .unwrap_or(12) - .clamp(12, (width / 2).max(12)) - .min(width); + let label_width = if self.view == View::History { + width + } else { + items + .iter() + .skip(start) + .take(visible) + .map(|s| graphemes::display_width(&sanitize_display_text(&s.label))) + .max() + .unwrap_or(12) + .clamp(12, (width / 2).max(12)) + .min(width) + }; let detail_width = width.saturating_sub(label_width + 2); let cell = |column, row, available, value| { print_text_with_coordinates(value, column, row, Some(available), Some(1)) @@ -575,12 +579,14 @@ impl Palette { }) .color_range(0, ..), ); - cell( - label_width + 4, - 3, - detail_width, - Text::from("DETAILS").color_range(0, ..), - ); + if detail_width > 0 { + cell( + label_width + 4, + 3, + detail_width, + Text::from("DETAILS").color_range(0, ..), + ); + } } if !message.is_empty() && compact { for (i, line) in message.iter().take(visible).enumerate() { @@ -604,6 +610,9 @@ impl Palette { Text::from(sanitize_display_text(&item.detail)).dim_range(..), ), ] { + if available == 0 { + continue; + } cell( column, row, From 62f0dccfac5fd6efedfc4b2752443d2fbfe3a963 Mon Sep 17 00:00:00 2001 From: Josh Lane <79638+lanej@users.noreply.github.com> Date: Sat, 7 Nov 2026 07:42:46 -0800 Subject: [PATCH 12/18] test(zellij): verify settled completion and UTF-8 paste --- scripts/test-zellij-command-prompt.py | 51 ++++++++++++++++++--------- zellij/command-prompt/README.md | 2 ++ 2 files changed, 37 insertions(+), 16 deletions(-) diff --git a/scripts/test-zellij-command-prompt.py b/scripts/test-zellij-command-prompt.py index 8125555c..6542e8cc 100644 --- a/scripts/test-zellij-command-prompt.py +++ b/scripts/test-zellij-command-prompt.py @@ -140,21 +140,30 @@ def capture(terminal, name): def prompt_frame(terminal): lines = screen(terminal) - top = next(row for row, line in enumerate(lines) if '╭' in line and 'Zellij command' in line) - left = lines[top].index('╭') - right = lines[top].index('╮', left) - bottom = next(row for row in range(top + 1, len(lines)) - if lines[row][left] == '╰' and lines[row][right] == '╯') - return top, left, right - left + 1, bottom - top + 1 + try: + top = next(row for row, line in enumerate(lines) if '╭' in line and 'Zellij command' in line) + left = lines[top].index('╭') + right = lines[top].index('╮', left) + bottom = next(row for row in range(top + 1, len(lines)) + if lines[row][left] == '╰' and lines[row][right] == '╯') + return top, left, right - left + 1, bottom - top + 1 + except (StopIteration, ValueError): + return None # Native resize and redraw can span multiple PTY reads. def command_line(terminal): - top, left, width, _ = prompt_frame(terminal) + frame = prompt_frame(terminal) + if frame is None: + return '' + top, left, width, _ = frame return screen(terminal)[top + 1][left + 1:left + width - 1] def prompt_text(terminal): - top, left, width, height = prompt_frame(terminal) + frame = prompt_frame(terminal) + if frame is None: + return '' + top, left, width, height = frame return '\n'.join(line[left + 1:left + width - 1] for line in screen(terminal)[top + 1:top + height - 1]) @@ -165,7 +174,8 @@ def open_prompt(terminal): original_terminals = {p['id'] for p in terminal.terminals()} terminal.prefix(b":") terminal.until("Ctrl-f : renders compact command input", lambda: - "Zellij command" in text(terminal) and 'Esc close' in text(terminal)) + "Zellij command" in text(terminal) and 'Esc close' in text(terminal) + and prompt_frame(terminal) is not None) top, left, width, height = prompt_frame(terminal) assert width <= 64 and 5 <= height <= 9, f'Command popup is oversized: {width}x{height}' assert abs(left * 2 + width - 120) <= 2, 'Command popup is not horizontally centered' @@ -312,7 +322,8 @@ def unchanged(): original_height = prompt_frame(first)[3] first.send(b'rename-ta') first.until('inline command suggestions shrink the dropdown', lambda: - 'rename-tab' in text(first) and prompt_frame(first)[3] < original_height) + 'rename-tab' in prompt_text(first) and prompt_frame(first) is not None + and prompt_frame(first)[3] < original_height) assert prompt_frame(first)[:2] == command_anchor, 'Completion moved the command line' first.send(b'\t') first.until('Tab completes the action', lambda: 'rename-tab ' in text(first)) @@ -322,11 +333,14 @@ def unchanged(): open_prompt(first) first.send(b'new-pane --dir\t ') - first.until('native flag completion', lambda: '--direction' in text(first) and 'right' in text(first)) + first.until('native flag completion and direction choices', lambda: + 'new-pane --direction' in command_line(first) + and re.search(r'\bright\s+direction\b', prompt_text(first))) capture(first, 'completion') first.send(b'r\t') - first.until('direction value completion', lambda: 'new-pane --direction right' in text(first)) - assert prompt_frame(first)[3] <= 5, 'Completed argument did not shrink the popup' + first.until('direction completion shrinks to one choice', lambda: + 'new-pane --direction right' in command_line(first) + and prompt_frame(first) is not None and prompt_frame(first)[3] <= 5) first.send(b'\x1b') unchanged() @@ -371,10 +385,15 @@ def unchanged(): open_prompt(first) long_name = 'wide-' + '界' * 40 + '-cafe\u0301-👩‍💻' - first.send(('rename-tab "' + long_name + '"').encode()) + # The pinned SDK truncates non-Latin-1 Key events; PastedText retains UTF-8. + first.send(b'\x1b[200~' + ('rename-tab "' + long_name + '"').encode() + b'\x1b[201~') first.send(b'\x01\x05\x1b[D!\r') - first.until('long Unicode command survives scrolling and cursor editing', lambda: - tabs()[initial_tab]['name'] == long_name + '!') + try: + first.until('pasted Unicode command survives scrolling and cursor editing', lambda: + tabs()[initial_tab]['name'] == long_name + '!') + except AssertionError: + print('Tab names after Unicode edit: ' + json.dumps(tabs(), ensure_ascii=False), flush=True) + raise # A parse failure keeps the original line, allowing a real correction. submit(first, 'move-focus sideways', second) diff --git a/zellij/command-prompt/README.md b/zellij/command-prompt/README.md index 01193a1a..3f3083f3 100644 --- a/zellij/command-prompt/README.md +++ b/zellij/command-prompt/README.md @@ -38,6 +38,8 @@ The native action API does not return CLI stdout or all runtime errors. Submitti `Ctrl-f C` uses the same client-private routing to create a session immediately in the focused pane’s directory, using the installed `tmux` layout. The original session and its processes remain live. +The pinned SDK’s [key decoder](https://github.com/zellij-org/zellij/blob/bf517077c848d91f75091f66d1996cfeb84f4732/zellij-utils/src/plugin_api/key.rs#L147) truncates individual typed characters above U+00FF. Unicode paste, recalled commands and completed live names preserve full text; cursor editing uses the native grapheme-aware editor. + ## Installation and permissions For an existing pinned installation, run `make zellij-command-prompt`, then `make zellij` and restart the session. Default configuration never invokes Cargo. The legacy 0.45.1 configuration does not enable the palette. No additional runtime patch or shell dependency is required. From d6664cc9a36f981b5eac425dad1f5eabc9d3eac0 Mon Sep 17 00:00:00 2001 From: Josh Lane <79638+lanej@users.noreply.github.com> Date: Sat, 7 Nov 2026 08:09:47 -0800 Subject: [PATCH 13/18] fix(zellij): pin complete popup input routing --- docs/zellij.md | 2 +- scripts/install-zellij | 2 +- scripts/test-zellij-command-prompt.py | 7 ++++++- zellij/command-prompt/Cargo.lock | 4 ++-- zellij/command-prompt/Cargo.toml | 4 ++-- zellij/command-prompt/README.md | 4 ++-- 6 files changed, 14 insertions(+), 9 deletions(-) diff --git a/docs/zellij.md b/docs/zellij.md index 2234e7eb..43749742 100644 --- a/docs/zellij.md +++ b/docs/zellij.md @@ -8,7 +8,7 @@ Terminal capture of this configuration running on Zellij 0.45.1. ## Start a session -Use the **pinned [lanej/zellij fork](https://github.com/lanej/zellij)** installed by this checkout, Python 3 for the configuration helpers, fzf, and Rust through rustup. The selected Zellij recipe defaults to the exact fork revision in [`scripts/install-zellij`](../scripts/install-zellij). The fork builds on upstream `main` at `bf517077`, which includes [OSC 7501 support](https://github.com/zellij-org/zellij/pull/5734) and the startup repair; it carries the CLI completion, shutdown flush, initial-client registration, and concurrent permission-cache fixes with their native regressions. Select the runtime explicitly through [configuration sets](../README.md#configuration-sets): `tools.zellij = true` enables the recipe; `zellij.source = "patched"` installs the fork, `"release"` installs upstream stable, and `"main"` builds pinned upstream main without the fork's fixes. The `terminal` and `workstation` bundles enable Zellij. The main and release choices omit the fork's fixes; the documented popup, query, and shutdown acceptance applies to the patched runtime. The native builds use Rust 1.95.0 without changing your default toolchain. Neovim is used to edit scrollback. Return the default pin to a stable release once it includes OSC support and these reliability fixes; the status bar uses its native attention protocol. +Use the **pinned [lanej/zellij fork](https://github.com/lanej/zellij)** installed by this checkout, Python 3 for the configuration helpers, fzf, and Rust through rustup. The selected Zellij recipe defaults to the exact fork revision in [`scripts/install-zellij`](../scripts/install-zellij). The fork builds on upstream `main` at `bf517077`, which includes [OSC 7501 support](https://github.com/zellij-org/zellij/pull/5734) and the startup repair; it carries the CLI completion, shutdown flush, initial-client registration, concurrent permission-cache fixes, popup-paste routing, and lossless Unicode plugin keys with their native regressions. Select the runtime explicitly through [configuration sets](../README.md#configuration-sets): `tools.zellij = true` enables the recipe; `zellij.source = "patched"` installs the fork, `"release"` installs upstream stable, and `"main"` builds pinned upstream main without the fork's fixes. The `terminal` and `workstation` bundles enable Zellij. The main and release choices omit the fork's fixes; the documented popup, query, and shutdown acceptance applies to the patched runtime. The native builds use Rust 1.95.0 without changing your default toolchain. Neovim is used to edit scrollback. Return the default pin to a stable release once it includes OSC support and these reliability fixes; the status bar uses its native attention protocol. After upgrading the runtime, save your work and restart existing sessions so every session uses the permission-cache lock; an older session process can still perform unlocked writes. Malformed permission caches are preserved for correction rather than silently overwritten. diff --git a/scripts/install-zellij b/scripts/install-zellij index a9c1e31c..c9c78736 100755 --- a/scripts/install-zellij +++ b/scripts/install-zellij @@ -6,7 +6,7 @@ if [ "$#" -ne 0 ]; then exit 2 fi -zellij_revision=00e5384772e7b3e025b2982a851d08c59581774a +zellij_revision=7d738ed500f973a45df0f19cc6f5b1fe478bd1f0 zellij_toolchain=1.95.0 rustup toolchain install "$zellij_toolchain" --profile minimal diff --git a/scripts/test-zellij-command-prompt.py b/scripts/test-zellij-command-prompt.py index 6542e8cc..4ad09ea8 100644 --- a/scripts/test-zellij-command-prompt.py +++ b/scripts/test-zellij-command-prompt.py @@ -385,8 +385,8 @@ def unchanged(): open_prompt(first) long_name = 'wide-' + '界' * 40 + '-cafe\u0301-👩‍💻' - # The pinned SDK truncates non-Latin-1 Key events; PastedText retains UTF-8. first.send(b'\x1b[200~' + ('rename-tab "' + long_name + '"').encode() + b'\x1b[201~') + assert tabs()[initial_tab]['name'] == 'reopened successfully', 'Paste submitted a command' first.send(b'\x01\x05\x1b[D!\r') try: first.until('pasted Unicode command survives scrolling and cursor editing', lambda: @@ -394,6 +394,11 @@ def unchanged(): except AssertionError: print('Tab names after Unicode edit: ' + json.dumps(tabs(), ensure_ascii=False), flush=True) raise + first.until('Unicode paste popup closes', lambda: 'Zellij command' not in text(first)) + assert 'rename-tab \"wide-' not in text(first), 'Paste leaked into the underlying shell' + submit(first, 'rename-tab \"typed 界é👩‍💻\"', second) + first.until('typed Unicode key events preserve complete characters', lambda: + tabs()[initial_tab]['name'] == 'typed 界é👩‍💻') # A parse failure keeps the original line, allowing a real correction. submit(first, 'move-focus sideways', second) diff --git a/zellij/command-prompt/Cargo.lock b/zellij/command-prompt/Cargo.lock index e71b262b..717e2ad3 100644 --- a/zellij/command-prompt/Cargo.lock +++ b/zellij/command-prompt/Cargo.lock @@ -2618,7 +2618,7 @@ dependencies = [ [[package]] name = "zellij-tile" version = "0.46.0" -source = "git+https://github.com/zellij-org/zellij?rev=bf517077c848d91f75091f66d1996cfeb84f4732#bf517077c848d91f75091f66d1996cfeb84f4732" +source = "git+https://github.com/lanej/zellij?rev=7d738ed500f973a45df0f19cc6f5b1fe478bd1f0#7d738ed500f973a45df0f19cc6f5b1fe478bd1f0" dependencies = [ "prost", "serde", @@ -2632,7 +2632,7 @@ dependencies = [ [[package]] name = "zellij-utils" version = "0.46.0" -source = "git+https://github.com/zellij-org/zellij?rev=bf517077c848d91f75091f66d1996cfeb84f4732#bf517077c848d91f75091f66d1996cfeb84f4732" +source = "git+https://github.com/lanej/zellij?rev=7d738ed500f973a45df0f19cc6f5b1fe478bd1f0#7d738ed500f973a45df0f19cc6f5b1fe478bd1f0" dependencies = [ "anyhow", "backtrace", diff --git a/zellij/command-prompt/Cargo.toml b/zellij/command-prompt/Cargo.toml index 78afc16b..49f5ce6a 100644 --- a/zellij/command-prompt/Cargo.toml +++ b/zellij/command-prompt/Cargo.toml @@ -9,8 +9,8 @@ clap = { version = "4.6", default-features = false, features = ["std", "derive", shlex = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" -zellij-tile = { git = "https://github.com/zellij-org/zellij", rev = "bf517077c848d91f75091f66d1996cfeb84f4732" } -zellij-utils = { git = "https://github.com/zellij-org/zellij", rev = "bf517077c848d91f75091f66d1996cfeb84f4732" } +zellij-tile = { git = "https://github.com/lanej/zellij", rev = "7d738ed500f973a45df0f19cc6f5b1fe478bd1f0" } +zellij-utils = { git = "https://github.com/lanej/zellij", rev = "7d738ed500f973a45df0f19cc6f5b1fe478bd1f0" } [profile.release] strip = true diff --git a/zellij/command-prompt/README.md b/zellij/command-prompt/README.md index 3f3083f3..55fe6e6b 100644 --- a/zellij/command-prompt/README.md +++ b/zellij/command-prompt/README.md @@ -38,11 +38,11 @@ The native action API does not return CLI stdout or all runtime errors. Submitti `Ctrl-f C` uses the same client-private routing to create a session immediately in the focused pane’s directory, using the installed `tmux` layout. The original session and its processes remain live. -The pinned SDK’s [key decoder](https://github.com/zellij-org/zellij/blob/bf517077c848d91f75091f66d1996cfeb84f4732/zellij-utils/src/plugin_api/key.rs#L147) truncates individual typed characters above U+00FF. Unicode paste, recalled commands and completed live names preserve full text; cursor editing uses the native grapheme-aware editor. +The palette and runtime share a pinned SDK revision that preserves Unicode key events and routes bracketed paste to the focused native popup. Pasting never submits a command; Enter runs the edited line. ## Installation and permissions -For an existing pinned installation, run `make zellij-command-prompt`, then `make zellij` and restart the session. Default configuration never invokes Cargo. The legacy 0.45.1 configuration does not enable the palette. No additional runtime patch or shell dependency is required. +Install the matching runtime with `bash scripts/install-zellij`, then run `make zellij-command-prompt`, apply `make zellij` and restart the session. Default configuration never invokes Cargo. The legacy 0.45.1 configuration does not enable the palette. Use the matching runtime pin for popup paste routing and Unicode input. No shell dependency is required. The background dispatcher opens the same WASM as a client-local native popup. The popup edits and renders suggestions; the dispatcher retains history, checks the original pane/tab, and applies accepted commands for that client. Opening the palette leaves hidden floating panes and the persistent `Ctrl-f P` popup hidden. From 467df344366884298ba71a18ca355594ca1b8aea Mon Sep 17 00:00:00 2001 From: Josh Lane <79638+lanej@users.noreply.github.com> Date: Sat, 7 Nov 2026 08:23:50 -0800 Subject: [PATCH 14/18] docs(zellij): show compact native completion --- docs/images/zellij-command-palette.png | Bin 40577 -> 18344 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/docs/images/zellij-command-palette.png b/docs/images/zellij-command-palette.png index b20bebf0125da6d398579c16ef6a419405907212..2f17f0ae1ff4f584425b95b3f2c4b80aaacb4e34 100644 GIT binary patch literal 18344 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 BmK*>8 literal 40577 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 SmYjgUfcuh2iQKynU;ID&zJ!ke From 47a2595c1764f50b97683bdc9e2785ea1c94802d Mon Sep 17 00:00:00 2001 From: Josh Lane <79638+lanej@users.noreply.github.com> Date: Sat, 7 Nov 2026 08:53:57 -0800 Subject: [PATCH 15/18] fix(zellij): retain quick reopen requests after actions --- .github/workflows/zellij.yml | 28 +++--- scripts/test-zellij-command-prompt.py | 7 ++ zellij/command-prompt/src/main.rs | 127 ++++++++++++++++++-------- 3 files changed, 110 insertions(+), 52 deletions(-) diff --git a/.github/workflows/zellij.yml b/.github/workflows/zellij.yml index 5877dfd1..afa2cde3 100644 --- a/.github/workflows/zellij.yml +++ b/.github/workflows/zellij.yml @@ -149,20 +149,6 @@ jobs: bash scripts/install-zellij "$HOME/.local/bin/zellij" --version echo "$HOME/.local/bin" >> "$GITHUB_PATH" - - name: Exercise command palette - if: ${{ !cancelled() && steps.zellij_install.outcome == 'success' }} - run: | - mkdir -p "$ZELLIJ_TEST_ARTIFACTS/command-palette" - set -o pipefail - make test-zellij-command-prompt 2>&1 | tee "$ZELLIJ_TEST_ARTIFACTS/command-palette/integration.log" - - name: Preserve command palette terminal captures - if: always() - uses: actions/upload-artifact@v4 - with: - name: command-palette-${{ matrix.os }} - path: ${{ github.workspace }}/.zellij-diagnostics/command-palette - if-no-files-found: ignore - retention-days: 7 - name: Install pane navigation run: python3 scripts/install-zellij-navigation.py - name: Save native Zellij build @@ -200,6 +186,20 @@ jobs: mkdir -p "$RUNNER_TEMP/zellij-tools" curl --fail --location --retry 3 "https://github.com/neovim/neovim/releases/download/v0.12.5/nvim-$nvim_platform.tar.gz" | tar -xz -C "$RUNNER_TEMP/zellij-tools" echo "$RUNNER_TEMP/zellij-tools/nvim-$nvim_platform/bin" >> "$GITHUB_PATH" + - name: Exercise command palette + if: ${{ !cancelled() && steps.zellij_install.outcome == 'success' }} + run: | + mkdir -p "$ZELLIJ_TEST_ARTIFACTS/command-palette" + set -o pipefail + make test-zellij-command-prompt 2>&1 | tee "$ZELLIJ_TEST_ARTIFACTS/command-palette/integration.log" + - name: Preserve command palette terminal captures + if: always() + uses: actions/upload-artifact@v4 + with: + name: command-palette-${{ matrix.os }} + path: ${{ github.workspace }}/.zellij-diagnostics/command-palette + if-no-files-found: ignore + retention-days: 7 - name: Build native attention bar if: ${{ !cancelled() && steps.zellij_install.outcome == 'success' }} run: make zellij-status diff --git a/scripts/test-zellij-command-prompt.py b/scripts/test-zellij-command-prompt.py index 4ad09ea8..46a389b9 100644 --- a/scripts/test-zellij-command-prompt.py +++ b/scripts/test-zellij-command-prompt.py @@ -365,6 +365,13 @@ def unchanged(): p['tab_id'] == other_tab and p['title'] == 'completed tab target' for p in first.terminals())) submit(first, 'go-to-tab 1', second) + # Reopen while a tab switch can still be completing, with the other client + # active. The private binding must survive each transition without retries. + for position in (2, 1, 2, 1, 2, 1): + submit(first, f'go-to-tab {position}', second) + open_prompt(first) + first.send(b'\x1b') + submit(first, 'list-tabs', second) first.until('tab query opens', lambda: 'TAB / NAME' in text(first)) submit(second, 'rename-tab "live renamed target"', first) diff --git a/zellij/command-prompt/src/main.rs b/zellij/command-prompt/src/main.rs index e42c7d84..9795d67f 100644 --- a/zellij/command-prompt/src/main.rs +++ b/zellij/command-prompt/src/main.rs @@ -71,6 +71,7 @@ struct CommandPrompt { history: VecDeque, actions: VecDeque, action_pending: bool, + pending_keybind: Option<&'static str>, palette: Option, } @@ -147,51 +148,28 @@ impl ZellijPlugin for CommandPrompt { } fn pipe(&mut self, message: PipeMessage) -> bool { - if !self.allowed || self.palette.is_some() || !message.is_private || self.action_pending { + if !self.allowed || self.palette.is_some() || !message.is_private { return false; } if message.source == PipeSource::Keybind && matches!(message.name.as_str(), OPEN | NEW_SESSION) && message.payload.as_deref() == Some(self.client_id.to_string().as_str()) { - match get_focused_pane_info() { - Ok(origin) => { - if message.name == NEW_SESSION { - match get_pane_cwd(origin.1) { - Ok(cwd) => { - // This API treats /tmp, /host, /data and /cache - // as WASI aliases, but get_pane_cwd returns a - // host path. POSIX /.. is root on macOS/Linux; - // keep that component to avoid alias rewriting. - let Ok(relative) = cwd.strip_prefix("/") else { - self.notice( - "The focused pane has no absolute working directory." - .into(), - ); - return false; - }; - let cwd = std::path::Path::new("/..").join(relative); - // Never reuse a popup's inline layout: its P key - // detaches instead of opening the shared popup. - switch_session_with_layout( - None, - LayoutInfo::File("tmux".into(), Default::default()), - Some(cwd), - ); - } - Err(error) => self.notice(error), - } - return false; - } - self.origin = Some(origin); - self.line.clear(); - self.input(String::new()); - } - Err(error) => { - prompt(PromptRequest::notify(error).timeout(std::time::Duration::from_secs(4))); - } + let keybind = if message.name == OPEN { + OPEN + } else { + NEW_SESSION + }; + if self.action_pending { + // A popup can close before the previous action completes. + // Keep one private request instead of dropping a quick reopen. + self.pending_keybind = Some(keybind); + } else { + self.open_keybind(keybind); } - } else if matches!(message.source, PipeSource::Plugin(_)) && message.name == palette::REPLY + } else if !self.action_pending + && matches!(message.source, PipeSource::Plugin(_)) + && message.name == palette::REPLY { if let Some(reply) = message .payload @@ -219,6 +197,45 @@ impl ZellijPlugin for CommandPrompt { } impl CommandPrompt { + fn open_keybind(&mut self, name: &str) { + match get_focused_pane_info() { + Ok(origin) => { + if name == NEW_SESSION { + match get_pane_cwd(origin.1) { + Ok(cwd) => { + // This API treats /tmp, /host, /data and /cache + // as WASI aliases, but get_pane_cwd returns a + // host path. POSIX /.. is root on macOS/Linux; + // keep that component to avoid alias rewriting. + let Ok(relative) = cwd.strip_prefix("/") else { + self.notice( + "The focused pane has no absolute working directory.".into(), + ); + return; + }; + let cwd = std::path::Path::new("/..").join(relative); + // Never reuse a popup's inline layout: its P key + // detaches instead of opening the shared popup. + switch_session_with_layout( + None, + LayoutInfo::File("tmux".into(), Default::default()), + Some(cwd), + ); + } + Err(error) => self.notice(error), + } + return; + } + self.origin = Some(origin); + self.line.clear(); + self.input(String::new()); + } + Err(error) => { + prompt(PromptRequest::notify(error).timeout(std::time::Duration::from_secs(4))); + } + } + } + fn notice(&self, message: String) { prompt(PromptRequest::notify(message).timeout(std::time::Duration::from_secs(4))); } @@ -336,6 +353,40 @@ impl CommandPrompt { run_action(action, BTreeMap::from([("owner".into(), OPEN.into())])); } else { self.origin = None; + if let Some(keybind) = self.pending_keybind.take() { + self.open_keybind(keybind); + } } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_busy_dispatcher_retains_only_its_clients_latest_keybind_request() { + let mut state = CommandPrompt { + allowed: true, + client_id: 2, + action_pending: true, + ..Default::default() + }; + let request = |name: &str, client: u16, private| PipeMessage { + source: PipeSource::Keybind, + name: name.into(), + payload: Some(client.to_string()), + args: BTreeMap::new(), + is_private: private, + }; + state.pipe(request(OPEN, 1, true)); + state.pipe(request(OPEN, 2, false)); + assert_eq!(state.pending_keybind, None); + state.pipe(request(OPEN, 2, true)); + assert_eq!(state.pending_keybind, Some(OPEN)); + state.pipe(request(NEW_SESSION, 2, true)); + state.pipe(request(OPEN, 1, true)); + assert_eq!(state.pending_keybind, Some(NEW_SESSION)); + assert!(state.action_pending); + } +} From 69fef6fc546ec22e7461adeed510e128015af82f Mon Sep 17 00:00:00 2001 From: Josh Lane <79638+lanej@users.noreply.github.com> Date: Sun, 8 Nov 2026 10:27:36 -0800 Subject: [PATCH 16/18] docs(zellij): refresh the uncluttered palette capture --- docs/images/zellij-command-palette.png | Bin 18344 -> 16433 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/docs/images/zellij-command-palette.png b/docs/images/zellij-command-palette.png index 2f17f0ae1ff4f584425b95b3f2c4b80aaacb4e34..583b3570992964d8eed032aecaf19eae1cd89cc7 100644 GIT binary patch literal 16433 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 JzRAOa%Y(hS`(l!AnGcSyrf(hc7^ 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 BmK*>8 From 3ad530058b25fd36f15cfd01040536dc17e8dc05 Mon Sep 17 00:00:00 2001 From: Josh Lane <79638+lanej@users.noreply.github.com> Date: Sun, 8 Nov 2026 10:56:47 -0800 Subject: [PATCH 17/18] refactor(zellij): strip command palette chrome --- scripts/test-zellij-command-prompt.py | 47 ++++++++++------- zellij/command-prompt/README.md | 2 +- zellij/command-prompt/src/completion.rs | 4 +- zellij/command-prompt/src/palette.rs | 70 ++++--------------------- 4 files changed, 42 insertions(+), 81 deletions(-) diff --git a/scripts/test-zellij-command-prompt.py b/scripts/test-zellij-command-prompt.py index 82254278..85edb196 100644 --- a/scripts/test-zellij-command-prompt.py +++ b/scripts/test-zellij-command-prompt.py @@ -107,14 +107,14 @@ def screen(terminal): def check_decoder(): # Reproduce CI's metadata after a dialog render, including chunk boundaries # inside ESC/ST and UTF-8. The message must neither draw nor move the cursor. - rendered = '\x1b[12;32HZellij command :\x1b[12;32H' + rendered = '\x1b[12;32H: rename-tab\x1b[12;32H' metadata = '\x1bP26661nCh0KF2NvbW1hbmQ=\x1b\\' data = (rendered + metadata + '\x1b[14;32H→ editable').encode() for size in (1, 7, len(data)): decoder = Decoder() for offset in range(0, len(data), size): decoder.feed(data[offset:offset + size]) - assert decoder.display.display[11][31:47] == 'Zellij command :' + assert decoder.display.display[11][31:43] == ': rename-tab' assert decoder.display.display[13][31:41] == '→ editable' assert '26661n' not in '\n'.join(decoder.display.display) @@ -141,7 +141,7 @@ def capture(terminal, name): def prompt_frame(terminal): lines = screen(terminal) try: - top = next(row for row, line in enumerate(lines) if '╭' in line and 'Zellij command' in line) + top = next(row for row, line in enumerate(lines) if re.search(r'╭─+╮', line)) left = lines[top].index('╭') right = lines[top].index('╮', left) bottom = next(row for row in range(top + 1, len(lines)) @@ -174,19 +174,21 @@ def command_ready(terminal): def open_prompt(terminal): # Wait for asynchronous closure so new keys cannot enter a stale popup. - terminal.until("previous command dialog closes", lambda: "Zellij command" not in text(terminal)) + terminal.until("previous command dialog closes", lambda: prompt_frame(terminal) is None) original_terminals = {p['id'] for p in terminal.terminals()} terminal.prefix(b":") terminal.until("Ctrl-f : renders compact command input", lambda: - "Zellij command" in text(terminal) and command_ready(terminal)) + command_ready(terminal)) top, left, width, height = prompt_frame(terminal) - assert width <= 64 and 5 <= height <= 9, f'Command popup is oversized: {width}x{height}' + assert width <= 64 and 3 <= height <= 8, f'Command popup is oversized: {width}x{height}' assert abs(left * 2 + width - 120) <= 2, 'Command popup is not horizontally centered' assert 4 <= top <= 12, f'Command popup is not near the upper third: {top}' - assert 'COMMAND / VALUE' not in text(terminal) and 'suggestions' not in text(terminal), ( + assert 'Zellij command' not in text(terminal) and 'COMMAND / VALUE' not in text(terminal) and 'suggestions' not in text(terminal), ( 'Command entry includes browsing chrome') - assert screen(terminal)[top + height - 1][left + 1:left + width - 1] == '─' * (width - 2), ( - 'Command entry includes instructions in its frame') + assert all(screen(terminal)[row][left + 1:left + width - 1] == '─' * (width - 2) + for row in (top, top + height - 1)), 'Command entry includes labels in its frame' + assert all('├' not in line[left:left + width] and '┤' not in line[left:left + width] + for line in screen(terminal)[top + 1:top + height - 1]), 'Command entry includes an extra divider' # A native overlay must not create shells or reveal suppressed plugin panes. assert {p['id'] for p in terminal.terminals()} == original_terminals, 'Prompt opened a shell pane' @@ -304,7 +306,7 @@ def unchanged(): unchanged() if command == 'new-pane --near-current-pane': first.send(b'\x04') - first.until('full error details are available', lambda: 'Zellij command · help' in text(first) + first.until('full error details are available', lambda: not command_ready(first) and message in ' '.join(prompt_text(first).split())) first.send(b'\x1b') first.until('error details return to the original command', lambda: @@ -318,7 +320,7 @@ def unchanged(): first.send(b'\x1b') open_prompt(first) first.send(b'\r') - first.until('empty input closes', lambda: 'Zellij command' not in text(first)) + first.until('empty input closes', lambda: prompt_frame(first) is None) unchanged() open_prompt(first) @@ -339,22 +341,29 @@ def unchanged(): first.send(b'new-pane --dir\t ') first.until('native flag completion and direction choices', lambda: 'new-pane --direction' in command_line(first) - and re.search(r'\bright\s+direction\b', prompt_text(first))) + and {'left', 'right', 'up', 'down'} <= {line.strip() for line in prompt_text(first).splitlines()}) capture(first, 'completion') first.send(b'r\t') first.until('direction completion shrinks to one choice', lambda: 'new-pane --direction right' in command_line(first) - and prompt_frame(first) is not None and prompt_frame(first)[3] <= 5) + and prompt_frame(first) is not None and prompt_frame(first)[3] <= 4) + first.send(b'\x15rename-tab "no completion requested"') + first.until('free-form input collapses to a single command line', lambda: + prompt_frame(first) is not None and prompt_frame(first)[3] == 3 + and prompt_text(first).strip() == ': rename-tab "no completion requested"') + capture(first, 'input') first.send(b'\x1b') unchanged() submit(first, 'list-panes', second) - first.until('pane query is a searchable table', lambda: 'PANE / TITLE' in text(first) and 'other client' in text(first)) + first.until('pane query is a searchable table', lambda: command_line(first).lstrip().startswith('/') and 'other client' in prompt_text(first)) first.send(b'\t') - first.until('Tab selects without leaving the query', lambda: 'PANE / TITLE' in text(first)) + first.until('Tab selects without leaving the query', lambda: command_line(first).lstrip().startswith('/')) capture(first, 'pane-query') first.send(b'other client') - first.until('pane table filters by title', lambda: '1 suggestions' in text(first)) + first.until('pane table filters by title', lambda: command_line(first).strip() == '/ other client' + and len([line for line in prompt_text(first).splitlines()[1:] if line.strip()]) == 1 + and 'other client' in prompt_text(first).splitlines()[1]) first.send(b'\r') submit(first, 'rename-pane "selected through query"', second) first.until('query jumps by stable pane ID', lambda: pane(other_id)['title'] == 'selected through query') @@ -377,7 +386,7 @@ def unchanged(): first.send(b'\x1b') submit(first, 'list-tabs', second) - first.until('tab query opens', lambda: 'TAB / NAME' in text(first)) + first.until('tab query opens', lambda: command_line(first).lstrip().startswith('/') and 'second named tab' in prompt_text(first)) submit(second, 'rename-tab "live renamed target"', first) first.until('open query updates from live state', lambda: 'live renamed target' in text(first)) capture(first, 'tab-query') @@ -405,7 +414,7 @@ def unchanged(): except AssertionError: print('Tab names after Unicode edit: ' + json.dumps(tabs(), ensure_ascii=False), flush=True) raise - first.until('Unicode paste popup closes', lambda: 'Zellij command' not in text(first)) + first.until('Unicode paste popup closes', lambda: prompt_frame(first) is None) assert 'rename-tab \"wide-' not in text(first), 'Paste leaked into the underlying shell' submit(first, 'rename-tab \"typed 界é👩‍💻\"', second) first.until('typed Unicode key events preserve complete characters', lambda: @@ -425,7 +434,7 @@ def unchanged(): second.send(b'\x1b') second.until('history returns to editable command', lambda: command_ready(second)) second.send(b'\x1b') - second.until('history dialog closes before reload', lambda: 'Zellij command' not in text(second)) + second.until('history dialog closes before reload', lambda: prompt_frame(second) is None) # Reload an observable ordinary binding in both clients, then verify the # plugin's private binding is restored independently for both of them. diff --git a/zellij/command-prompt/README.md b/zellij/command-prompt/README.md index 928e00db..7c4829f3 100644 --- a/zellij/command-prompt/README.md +++ b/zellij/command-prompt/README.md @@ -1,6 +1,6 @@ # Zellij command palette -`Ctrl-f :` opens a slim native command line near the upper third of the terminal. Its width is capped at 64 columns; up to five completion choices appear directly below the input, and the height follows the matches. The command line stays in place as the dropdown changes. Controls and usage instructions stay in this guide rather than the popup. Type a Zellij action directly, or search the suggestions by name or description. Tab accepts the highlighted suggestion; Enter runs the command you entered. Suggestions never execute a command. Esc or clicking outside closes the popup. +`Ctrl-f :` opens a slim native command line near the upper third of the terminal. Its width is capped at 64 columns; up to five completion choices appear directly below the input, and the height follows the matches. The command line stays in place as the dropdown changes. The popup shows only the input and relevant results, without titles, table headings, counts or control hints. Controls and usage instructions stay in this guide rather than the popup. Type a Zellij action directly, or search the suggestions by name or description. Tab accepts the highlighted suggestion; Enter runs the command you entered. Suggestions never execute a command. Esc or clicking outside closes the popup. | Input | Suggestions | | --- | --- | diff --git a/zellij/command-prompt/src/completion.rs b/zellij/command-prompt/src/completion.rs index a79da66a..0125d8b8 100644 --- a/zellij/command-prompt/src/completion.rs +++ b/zellij/command-prompt/src/completion.rs @@ -255,11 +255,11 @@ pub fn complete(line: &str, cursor: usize, context: &Context, history: &[String] )], (_, "direction") => ["left", "right", "up", "down"] .into_iter() - .map(|v| Suggestion::new(v, v, "direction")) + .map(|v| Suggestion::new(v, v, "")) .collect(), (_, "resize") => ["increase", "decrease"] .into_iter() - .map(|v| Suggestion::new(v, v, "resize amount")) + .map(|v| Suggestion::new(v, v, "")) .collect(), _ => arg .get_value_parser() diff --git a/zellij/command-prompt/src/palette.rs b/zellij/command-prompt/src/palette.rs index 27e0bcf5..8e2bdd4d 100644 --- a/zellij/command-prompt/src/palette.rs +++ b/zellij/command-prompt/src/palette.rs @@ -423,7 +423,7 @@ impl Palette { } pub fn render(&mut self, rows: usize, columns: usize) { - if !self.allowed || rows < 5 || columns < 12 { + if !self.allowed || rows < 3 || columns < 12 { return; } let tab = get_tab_info(self.open.origin.0).unwrap_or_default(); @@ -438,8 +438,6 @@ impl Palette { .into_iter() .take(3) .collect::>() - } else if compact && items.is_empty() { - vec![Text::from("No matches").dim_range(..)] } else { vec![] }; @@ -449,9 +447,11 @@ impl Palette { 0 }; let height = if compact { - (4 + candidate_rows + message.len()).max(5) - } else { + 3 + candidate_rows + message.len() + } else if self.view == View::Documentation { BROWSE_ROWS + } else { + (3 + items.len().max(1)).min(BROWSE_ROWS) } .min(max_height); if self.popup_size != Some((desired_width, height)) { @@ -488,23 +488,6 @@ impl Palette { ); } } - let title = match self.view { - View::Command => "Zellij command", - View::Help => "Zellij commands", - View::History => "Zellij command history", - View::Panes => "Zellij command · panes", - View::Tabs => "Zellij command · tabs", - View::Sessions => "Zellij command · sessions", - View::Documentation => "Zellij command · help", - }; - let title = format!(" {title} "); - print_text_with_coordinates( - Text::from(&title).color_range(0, ..), - 2, - 0, - Some(graphemes::display_width(&title).min(width)), - Some(1), - ); if self.view == View::Documentation { let lines: Vec<_> = self .documentation @@ -519,13 +502,8 @@ impl Palette { return; } self.render_input(columns); - border(2, '├', '┤'); - let table_row = if compact { 3 } else { 4 }; - let visible = if compact { - rows.saturating_sub(4) - } else { - rows.saturating_sub(6) - }; + let table_row = 2; + let visible = rows.saturating_sub(3); self.selected = self.selected.min(items.len().saturating_sub(1)); let start = self.selected.saturating_sub(visible.saturating_sub(1)); let label_width = if self.view == View::History { @@ -545,29 +523,9 @@ impl Palette { let cell = |column, row, available, value| { print_text_with_coordinates(value, column, row, Some(available), Some(1)) }; - if !compact { - put( - 3, - Text::from(match self.view { - View::Panes => "PANE / TITLE", - View::Tabs => "TAB / NAME", - View::Sessions => "SESSION", - _ => "COMMAND / VALUE", - }) - .color_range(0, ..), - ); - if detail_width > 0 { - cell( - label_width + 4, - 3, - detail_width, - Text::from("DETAILS").color_range(0, ..), - ); - } - } if !message.is_empty() && compact { for (i, line) in message.iter().take(visible).enumerate() { - put(3 + i, line.clone()); + put(table_row + i, line.clone()); } } else { for (i, item) in items.iter().enumerate().skip(start).take(visible) { @@ -602,16 +560,10 @@ impl Palette { ); } } - if items.is_empty() { + if items.is_empty() && !compact { put(table_row, Text::from("No matches").dim_range(..)); } } - if !compact { - put( - rows - 2, - Text::from(format!("{} suggestions", items.len())).dim_range(..), - ); - } } } @@ -623,7 +575,7 @@ fn geometry(tab: &TabInfo) -> (usize, usize, usize, usize) { let height = tab .display_area_rows .saturating_sub(top + 1) - .clamp(5, BROWSE_ROWS); + .clamp(3, BROWSE_ROWS); ( width, height, @@ -636,7 +588,7 @@ pub fn launch(open: Open) { let tab = get_tab_info(open.origin.0).unwrap_or_default(); let dispatcher = get_pane_info(PaneId::Plugin(open.owner)).unwrap_or_default(); let (width, max_height, top, left) = geometry(&tab); - let height = (4 + COMPLETION_ROWS).min(max_height); + let height = (3 + COMPLETION_ROWS).min(max_height); let configuration = BTreeMap::from([( "palette".into(), serde_json::to_string(&open).expect("serializable popup"), From 5e776b93988e2037aad3cc14ebae3d701380b5e6 Mon Sep 17 00:00:00 2001 From: Josh Lane <79638+lanej@users.noreply.github.com> Date: Sun, 8 Nov 2026 11:07:13 -0800 Subject: [PATCH 18/18] docs(zellij): show the title-free command palette --- docs/images/zellij-command-palette.png | Bin 16433 -> 7846 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/docs/images/zellij-command-palette.png b/docs/images/zellij-command-palette.png index 583b3570992964d8eed032aecaf19eae1cd89cc7..f924e875fb4cfed67510a11b2bea412e1ad0e3c5 100644 GIT binary patch literal 7846 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 JzR