feat: rename tmux -> popup and add zellij (#1027)

* feat: rename tmux -> popup and add zellij

* chore: generate completions & manpage

* Apply suggestions from code review

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* chore: fixes

* chore: generate completions & manpage

* chore: misc, windows todo

* chore: disable popup on windows for now

* chore: generate completions & manpage

* fix: always quote using sh

* chore: expect

* fix: avoid nested popup invocations

* fix: tests

* fix: correctly gate popup

* chore(docs): update ARCHITECTURE.md [skip ci]

---------

Co-authored-by: Skim bot <skim-bot@skim-rs.github.io>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
LoricAndre 2026-04-03 13:46:12 +02:00 committed by GitHub
parent a18e88aafd
commit 6b355e144a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 925 additions and 335 deletions

View file

@ -12,7 +12,7 @@
- [Interactive / Command Mode (`--interactive`)](#interactive--command-mode---interactive)
- [Select-1 / Exit-0 / Sync Modes](#select-1--exit-0--sync-modes)
- [ANSI Mode (`--ansi`)](#ansi-mode---ansi)
- [Tmux Mode (`--tmux`)](#tmux-mode---tmux)
- [Popup Mode (`--popup` / `--tmux`)](#popup-mode---popup----tmux)
6. [Item Ingestion Pipeline](#item-ingestion-pipeline)
7. [The Matching Subsystem](#the-matching-subsystem)
- [Match Engine Hierarchy](#match-engine-hierarchy)
@ -105,7 +105,10 @@ skim/ ← workspace root
│ ├── field.rs ← field range parsing (--nth / --with-nth)
│ ├── spinlock.rs ← lightweight SpinLock<T>
│ ├── util.rs ← printf helper, misc utilities
│ ├── tmux.rs ← tmux popup integration
│ ├── popup/ ← tmux & zellij popup integration
│ │ ├── mod.rs ← SkimPopup trait, run_with(), check_env(), SkimPopupOutput
│ │ ├── tmux.rs ← TmuxPopup (builds/runs tmux display-popup)
│ │ └── zellij.rs ← ZellijPopup (builds/runs zellij action new-floating-pane)
│ ├── prelude.rs ← convenience re-exports
│ ├── manpage.rs ← man-page generation (cli feature)
│ ├── shell.rs ← shell completion generation (cli feature)
@ -186,7 +189,7 @@ main()
│ ├─ SkimItemReader::new(reader_opts) ← configure stdin reader
│ ├─ opts.cmd_collector = cmd_collector
│ │
│ ├─ if --tmux && TMUX is set → tmux::run_with(&opts)
│ ├─ if --popup/--tmux && check_env() → popup::run_with(&opts)
│ │
│ └─ else:
│ ├─ if stdin not a TTY (piped) → cmd_collector.of_bufread(stdin)
@ -346,21 +349,40 @@ Without `--ansi`, any ANSI escape codes are passed through to `text()` and displ
**Key files:** `src/helper/item.rs` (`DefaultSkimItem::new`, `strip_ansi`, `display`)
### Tmux Mode (`--tmux`)
### Popup Mode (`--popup` / `--tmux`)
When `--tmux [direction[,size[,size]]]` is set and `$TMUX` is in the environment, the binary delegates to `tmux::run_with()` instead of `Skim::run_with()`.
When `--popup [direction[,size[,size]]]` (alias `--tmux`) is set, the binary calls `check_and_run_popup()`, which checks `popup::check_env()` and, if true, delegates to `popup::run_with()` instead of `Skim::run_with()`.
The tmux mode works by:
1. Creating a temp directory for IPC (`/tmp/sk-tmux-XXXXXXXX/`).
2. If stdin is piped, creating a named FIFO and spawning a thread to copy stdin to it (so the child process can read incrementally).
3. Reconstructing the full `sk` command line without `--tmux`, adding `--print-query --print-cmd --print-header --print-current --print-score`.
4. Launching `tmux display-popup -E … sh -c <sk command> > stdout_file`.
5. Waiting for the popup to exit.
6. Parsing the structured stdout file (always `query\ncmd\nheader\ncurrent\nitem1\nscore1\nitem2\nscore2\n…`) into a `SkimOutput`.
**`check_env()`** returns `true` only when:
- `$_SKIM_POPUP` is **not** set in the environment (prevents the child process from recursing back into popup mode), and
- at least one supported multiplexer is detected: tmux (`$TMUX` set) or Zellij (`$ZELLIJ` set).
The child `sk` process runs fully independently inside the tmux popup. The parent reads back a synthetic `SkimOutput` from the file.
The popup flow:
1. Creates a temp directory for IPC (`/tmp/sk-popup-XXXXXXXX/`).
2. If stdin is piped, creates a named FIFO (`tmp_stdin`) and spawns a thread to relay stdin into it incrementally so the child can stream-read.
3. Reconstructs the `sk` command line from `std::env::args()`, stripping `--popup`/`--tmux` and `--output-format`, then appending `--print-query --print-cmd --print-header --print-current --print-score`.
4. Forwards all `SKIM_*`, `RUST*`, and `PATH` environment variables to the child via the multiplexer's `-e` flag, **plus `_SKIM_POPUP=1`** to prevent re-entry.
5. Launches the popup via the appropriate backend:
- **tmux**: `tmux display-popup -E … sh -c <cmd> > stdout_file`
- **Zellij**: `zellij action new-floating-pane … -- sh -c <cmd> > stdout_file`
6. Waits for the popup process to exit.
7. Parses the structured stdout file (`query\ncmd\nheader\ncurrent_item\nitem1\nscore1\n…`) into a synthetic `SkimOutput`.
**Key files:** `src/tmux.rs`, `src/bin/main.rs` (`sk_main`)
The internal `SkimPopup` trait abstracts the two multiplexer backends:
```rust
trait SkimPopup {
fn from_options(options: &SkimOptions) -> Box<dyn SkimPopup>;
fn add_env(&mut self, key: &str, value: &str);
fn run_and_wait(&mut self, command: &str) -> std::io::Result<ExitStatus>;
}
```
`TmuxPopup` and `ZellijPopup` each implement this trait. The active backend is selected at runtime: Zellij takes priority if both are available.
The child `sk` process runs fully independently inside the popup. The parent reads back a synthetic `SkimOutput` from the captured file. Because `_SKIM_POPUP=1` is set in the child's environment, `check_env()` returns `false` in the child, so it runs as a normal interactive skim session regardless of what `SKIM_DEFAULT_OPTIONS` contains.
**Key files:** `src/popup/mod.rs` (`run_with`, `check_env`), `src/popup/tmux.rs` (`TmuxPopup`), `src/popup/zellij.rs` (`ZellijPopup`), `src/bin/main.rs` (`check_and_run_popup`)
---
@ -1062,7 +1084,7 @@ Preview thread (OS thread, per preview spawn):
IPC handler task (Tokio, per connection):
└─ reads RON actions → sends Event::Action to TUI channel
Tmux stdin relay thread (OS thread, only in --tmux mode):
Popup stdin relay thread (OS thread, only in --popup/--tmux mode):
└─ copies stdin → FIFO for child sk process
```
@ -1108,8 +1130,10 @@ The global allocator is `mimalloc` (v3), chosen for its low-latency multi-thread
| `Tui::new_with_height_and_backend` | `src/tui/backend.rs:68` | Terminal init + viewport sizing |
| `Tui::enter` | `src/tui/backend.rs:130` | Enable raw mode + start event pump |
| `Tui::start` | `src/tui/backend.rs:163` | Spawn crossterm EventStream task |
| `tmux::run_with` | `src/tmux.rs:107` | Delegate to tmux popup + parse output |
| `sk_main` | `src/bin/main.rs:121` | CLI orchestration + output printing |
| `popup::run_with` | `src/popup/mod.rs:91` | Delegate to multiplexer popup + parse output |
| `popup::check_env` | `src/popup/mod.rs:78` | Guard: multiplexer present and not already in popup |
| `check_and_run_popup` | `src/bin/main.rs:130` | Check popup conditions, dispatch to popup::run_with |
| `sk_main` | `src/bin/main.rs:142` | CLI orchestration + output printing |
| `parse_key` | `src/binds.rs:130` | `"ctrl-a"``KeyEvent` |
| `parse_action_chain` | `src/binds.rs:214` | `"down+select"``Vec<Action>` |
| `Matcher::create_engine_factory_with_builder` | `src/matcher.rs:~140` | Build engine factory chain from options |

42
Cargo.lock generated
View file

@ -2096,12 +2096,27 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "scc"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc"
dependencies = [
"sdd",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "sdd"
version = "3.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca"
[[package]]
name = "semver"
version = "1.0.27"
@ -2162,6 +2177,32 @@ dependencies = [
"winapi",
]
[[package]]
name = "serial_test"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f"
dependencies = [
"futures-executor",
"futures-util",
"log",
"once_cell",
"parking_lot",
"scc",
"serial_test_derive",
]
[[package]]
name = "serial_test_derive"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "sha2"
version = "0.10.9"
@ -2297,6 +2338,7 @@ dependencies = [
"ron",
"serde",
"serde_json",
"serial_test",
"shell-quote",
"shlex",
"tempfile",

View file

@ -97,6 +97,7 @@ gungraun = ["dep:gungraun"]
criterion = { version = "0.8.2", features = ["async_tokio"] }
insta = "1.47"
serde_json = { version = "=1.0.149" }
serial_test = "=3.4.0"
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage, coverage_nightly)'] }

View file

@ -3,23 +3,18 @@
inputs.nixpkgs.url = "https://channels.nixos.org/nixpkgs-unstable/nixexprs.tar.xz";
outputs =
inputs:
let
outputs = inputs: let
inherit (inputs.nixpkgs) lib;
systems = lib.systems.flakeExposed;
eachSystem = lib.genAttrs systems;
pkgsFor =
system:
pkgsFor = system:
import inputs.nixpkgs {
inherit system;
config.allowUnfreePredicate = pkg: builtins.elem (lib.getName pkg) ["vagrant"];
};
in
{
in {
devShells = eachSystem (
system:
let
system: let
pkgs = pkgsFor system;
# --- package groups -------------------------------------------------------
@ -39,6 +34,8 @@
cargo-public-api
git-cliff
cargo-dist
cargo-cross
cargo-xwin
];
gungraun = with pkgs; [
valgrind
@ -60,8 +57,7 @@
'';
mkShell = packages: shellHook: pkgs.mkShellNoCC {inherit packages shellHook;};
in
{
in {
default = mkShell base "";
tests = mkShell (base ++ tests) "";
utils = mkShell (base ++ utils) "";

View file

@ -8,7 +8,7 @@ sk \- Fuzzy Finder in rust!
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.SH SYNOPSIS
\fBsk\fR [\fB\-\-tac\fR] [\fB\-\-min\-query\-length\fR] [\fB\-\-no\-sort\fR] [\fB\-t\fR|\fB\-\-tiebreak\fR] [\fB\-n\fR|\fB\-\-nth\fR] [\fB\-\-with\-nth\fR] [\fB\-d\fR|\fB\-\-delimiter\fR] [\fB\-e\fR|\fB\-\-exact\fR] [\fB\-\-regex\fR] [\fB\-\-algo\fR] [\fB\-\-case\fR] [\fB\-\-typos\fR] [\fB\-\-no\-typos\fR] [\fB\-\-normalize\fR] [\fB\-\-split\-match\fR] [\fB\-\-last\-match\fR] [\fB\-\-scheme\fR] [\fB\-b\fR|\fB\-\-bind\fR] [\fB\-m\fR|\fB\-\-multi\fR] [\fB\-\-no\-multi\fR] [\fB\-\-no\-mouse\fR] [\fB\-c\fR|\fB\-\-cmd\fR] [\fB\-i\fR|\fB\-\-interactive\fR] [\fB\-I \fR] [\fB\-\-color\fR] [\fB\-\-highlight\-line\fR] [\fB\-\-no\-hscroll\fR] [\fB\-\-keep\-right\fR] [\fB\-\-skip\-to\-pattern\fR] [\fB\-\-no\-clear\-if\-empty\fR] [\fB\-\-no\-clear\-start\fR] [\fB\-\-no\-clear\fR] [\fB\-\-show\-cmd\-error\fR] [\fB\-\-cycle\fR] [\fB\-\-disabled\fR] [\fB\-\-layout\fR] [\fB\-\-reverse\fR] [\fB\-\-height\fR] [\fB\-\-no\-height\fR] [\fB\-\-min\-height\fR] [\fB\-\-margin\fR] [\fB\-p\fR|\fB\-\-prompt\fR] [\fB\-\-cmd\-prompt\fR] [\fB\-\-selector\fR] [\fB\-\-multi\-selector\fR] [\fB\-\-ansi\fR] [\fB\-\-tabstop\fR] [\fB\-\-info\fR] [\fB\-\-no\-info\fR] [\fB\-\-inline\-info\fR] [\fB\-\-header\fR] [\fB\-\-header\-lines\fR] [\fB\-\-border\fR] [\fB\-\-wrap\fR] [\fB\-\-multiline\fR] [\fB\-\-scrollbar\fR] [\fB\-\-no\-scrollbar\fR] [\fB\-\-history\fR] [\fB\-\-history\-size\fR] [\fB\-\-cmd\-history\fR] [\fB\-\-cmd\-history\-size\fR] [\fB\-\-preview\fR] [\fB\-\-preview\-window\fR] [\fB\-q\fR|\fB\-\-query\fR] [\fB\-\-cmd\-query\fR] [\fB\-\-read0\fR] [\fB\-\-print0\fR] [\fB\-\-print\-query\fR] [\fB\-\-print\-cmd\fR] [\fB\-\-print\-score\fR] [\fB\-\-print\-header\fR] [\fB\-\-print\-current\fR] [\fB\-\-output\-format\fR] [\fB\-\-no\-strip\-ansi\fR] [\fB\-1\fR|\fB\-\-select\-1\fR] [\fB\-0\fR|\fB\-\-exit\-0\fR] [\fB\-\-sync\fR] [\fB\-\-pre\-select\-n\fR] [\fB\-\-pre\-select\-pat\fR] [\fB\-\-pre\-select\-items\fR] [\fB\-\-pre\-select\-file\fR] [\fB\-f\fR|\fB\-\-filter\fR] [\fB\-\-shell\fR] [\fB\-\-shell\-bindings\fR] [\fB\-\-man\fR] [\fB\-\-listen\fR] [\fB\-\-remote\fR] [\fB\-\-tmux\fR] [\fB\-\-log\-level\fR] [\fB\-\-log\-file\fR] [\fB\-\-expect\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fB\-V\fR|\fB\-\-version\fR]
\fBsk\fR [\fB\-\-tac\fR] [\fB\-\-min\-query\-length\fR] [\fB\-\-no\-sort\fR] [\fB\-t\fR|\fB\-\-tiebreak\fR] [\fB\-n\fR|\fB\-\-nth\fR] [\fB\-\-with\-nth\fR] [\fB\-d\fR|\fB\-\-delimiter\fR] [\fB\-e\fR|\fB\-\-exact\fR] [\fB\-\-regex\fR] [\fB\-\-algo\fR] [\fB\-\-case\fR] [\fB\-\-typos\fR] [\fB\-\-no\-typos\fR] [\fB\-\-normalize\fR] [\fB\-\-split\-match\fR] [\fB\-\-last\-match\fR] [\fB\-\-scheme\fR] [\fB\-b\fR|\fB\-\-bind\fR] [\fB\-m\fR|\fB\-\-multi\fR] [\fB\-\-no\-multi\fR] [\fB\-\-no\-mouse\fR] [\fB\-c\fR|\fB\-\-cmd\fR] [\fB\-i\fR|\fB\-\-interactive\fR] [\fB\-I \fR] [\fB\-\-color\fR] [\fB\-\-highlight\-line\fR] [\fB\-\-no\-hscroll\fR] [\fB\-\-keep\-right\fR] [\fB\-\-skip\-to\-pattern\fR] [\fB\-\-no\-clear\-if\-empty\fR] [\fB\-\-no\-clear\-start\fR] [\fB\-\-no\-clear\fR] [\fB\-\-show\-cmd\-error\fR] [\fB\-\-cycle\fR] [\fB\-\-disabled\fR] [\fB\-\-layout\fR] [\fB\-\-reverse\fR] [\fB\-\-height\fR] [\fB\-\-no\-height\fR] [\fB\-\-min\-height\fR] [\fB\-\-margin\fR] [\fB\-p\fR|\fB\-\-prompt\fR] [\fB\-\-cmd\-prompt\fR] [\fB\-\-selector\fR] [\fB\-\-multi\-selector\fR] [\fB\-\-ansi\fR] [\fB\-\-tabstop\fR] [\fB\-\-info\fR] [\fB\-\-no\-info\fR] [\fB\-\-inline\-info\fR] [\fB\-\-header\fR] [\fB\-\-header\-lines\fR] [\fB\-\-border\fR] [\fB\-\-wrap\fR] [\fB\-\-multiline\fR] [\fB\-\-scrollbar\fR] [\fB\-\-no\-scrollbar\fR] [\fB\-\-history\fR] [\fB\-\-history\-size\fR] [\fB\-\-cmd\-history\fR] [\fB\-\-cmd\-history\-size\fR] [\fB\-\-preview\fR] [\fB\-\-preview\-window\fR] [\fB\-q\fR|\fB\-\-query\fR] [\fB\-\-cmd\-query\fR] [\fB\-\-read0\fR] [\fB\-\-print0\fR] [\fB\-\-print\-query\fR] [\fB\-\-print\-cmd\fR] [\fB\-\-print\-score\fR] [\fB\-\-print\-header\fR] [\fB\-\-print\-current\fR] [\fB\-\-output\-format\fR] [\fB\-\-no\-strip\-ansi\fR] [\fB\-1\fR|\fB\-\-select\-1\fR] [\fB\-0\fR|\fB\-\-exit\-0\fR] [\fB\-\-sync\fR] [\fB\-\-pre\-select\-n\fR] [\fB\-\-pre\-select\-pat\fR] [\fB\-\-pre\-select\-items\fR] [\fB\-\-pre\-select\-file\fR] [\fB\-f\fR|\fB\-\-filter\fR] [\fB\-\-shell\fR] [\fB\-\-shell\-bindings\fR] [\fB\-\-man\fR] [\fB\-\-listen\fR] [\fB\-\-remote\fR] [\fB\-\-popup\fR] [\fB\-\-log\-level\fR] [\fB\-\-log\-file\fR] [\fB\-\-expect\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fB\-V\fR|\fB\-\-version\fR]
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.SH OPTIONS
@ -458,15 +458,12 @@ The optional value is used as the indicator
\fB\-\-no\-scrollbar\fR
Disable the scrollbar in the item list
.TP
\fB\-\-tmux\fR [\fI<TMUX>...\fR]
Run in a tmux popup
\fB\-\-popup\fR [\fI<POPUP>...\fR]
Run in a tmux or zellij popup
Format: `sk \-\-tmux <center|top|bottom|left|right>[,SIZE[%]][,SIZE[%]]`
Depending on the direction, the order and behavior of the sizes varies:
Default: center,50%
Ignored on Windows
Format: `sk \-\-popup <center|top|bottom|left|right>[,SIZE[%]][,SIZE[%]]`
Note: this will try to detect a Zellij session, then a Tmux session
This means that in nested sesions, skim will prioritize Zellij over Tmux
.SH HISTORY
.TP
\fB\-\-history\fR \fI<HISTORY_FILE>\fR

View file

@ -23,7 +23,7 @@ _sk() {
case "${cmd}" in
sk)
opts="-t -n -d -e -b -m -c -i -I -p -q -1 -0 -f -x -h -V --tac --min-query-length --no-sort --tiebreak --nth --with-nth --delimiter --exact --regex --algo --case --typos --no-typos --normalize --split-match --last-match --scheme --bind --multi --no-multi --no-mouse --cmd --interactive --color --highlight-line --no-hscroll --keep-right --skip-to-pattern --no-clear-if-empty --no-clear-start --no-clear --show-cmd-error --cycle --disabled --layout --reverse --height --no-height --min-height --margin --prompt --cmd-prompt --selector --multi-selector --ansi --tabstop --ellipsis --info --no-info --inline-info --header --header-lines --border --wrap --multiline --scrollbar --no-scrollbar --history --history-size --cmd-history --cmd-history-size --preview --preview-window --query --cmd-query --read0 --print0 --print-query --print-cmd --print-score --print-header --print-current --output-format --no-strip-ansi --select-1 --exit-0 --sync --pre-select-n --pre-select-pat --pre-select-items --pre-select-file --filter --shell --shell-bindings --man --listen --remote --tmux --log-level --log-file --flags --extended --literal --hscroll-off --filepath-word --jump-labels --no-bold --phony --tail --style --no-color --padding --border-label --border-label-pos --wrap-sign --no-multi-line --raw --track --gap --gap-line --freeze-left --freeze-right --scroll-off --gutter --gutter-raw --marker-multi-line --list-border --list-label --list-label-pos --no-input --info-command --separator --no-separator --ghost --input-border --input-label --input-label-pos --preview-label --preview-label-pos --header-first --header-border --header-lines-border --footer --footer-border --footer-label --footer-label-pos --with-shell --expect --help --version"
opts="-t -n -d -e -b -m -c -i -I -p -q -1 -0 -f -x -h -V --tac --min-query-length --no-sort --tiebreak --nth --with-nth --delimiter --exact --regex --algo --case --typos --no-typos --normalize --split-match --last-match --scheme --bind --multi --no-multi --no-mouse --cmd --interactive --color --highlight-line --no-hscroll --keep-right --skip-to-pattern --no-clear-if-empty --no-clear-start --no-clear --show-cmd-error --cycle --disabled --layout --reverse --height --no-height --min-height --margin --prompt --cmd-prompt --selector --multi-selector --ansi --tabstop --ellipsis --info --no-info --inline-info --header --header-lines --border --wrap --multiline --scrollbar --no-scrollbar --history --history-size --cmd-history --cmd-history-size --preview --preview-window --query --cmd-query --read0 --print0 --print-query --print-cmd --print-score --print-header --print-current --output-format --no-strip-ansi --select-1 --exit-0 --sync --pre-select-n --pre-select-pat --pre-select-items --pre-select-file --filter --shell --shell-bindings --man --listen --remote --popup --log-level --log-file --flags --extended --literal --hscroll-off --filepath-word --jump-labels --no-bold --phony --tail --style --no-color --padding --border-label --border-label-pos --wrap-sign --no-multi-line --raw --track --gap --gap-line --freeze-left --freeze-right --scroll-off --gutter --gutter-raw --marker-multi-line --list-border --list-label --list-label-pos --no-input --info-command --separator --no-separator --ghost --input-border --input-label --input-label-pos --preview-label --preview-label-pos --header-first --header-border --header-lines-border --footer --footer-border --footer-label --footer-label-pos --with-shell --expect --help --version"
if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
@ -253,7 +253,7 @@ _sk() {
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--tmux)
--popup)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
@ -266,7 +266,7 @@ _sk() {
return 0
;;
--flags)
COMPREPLY=($(compgen -W "no-preview-pty show-score show-index" -- "${cur}"))
COMPREPLY=($(compgen -W "no-preview-pty show-score show-index single-reader single-matcher" -- "${cur}"))
return 0
;;
--hscroll-off)

View file

@ -85,12 +85,14 @@ power-shell\t'PowerShell'
zsh\t'Zsh'"
complete -c sk -l listen -d 'Run an IPC socket with optional name (defaults to sk)' -r
complete -c sk -l remote -d 'Send commands to an IPC socket with optional name (defaults to sk)' -r
complete -c sk -l tmux -d 'Run in a tmux popup' -r
complete -c sk -l popup -d 'Run in a tmux or zellij popup' -r
complete -c sk -l log-level -d 'Set the log level' -r
complete -c sk -l log-file -d 'Pipe log output to a file' -r
complete -c sk -l flags -d 'Feature flags' -r -f -a "no-preview-pty\t'Disable preview PTY on linux'
show-score\t'Display the item\'s match score before its value in the item list (for matcher debugging)'
show-index\t'Display the item\'s index before its value in the item list'"
show-index\t'Display the item\'s index before its value in the item list'
single-reader\t'Limit the reader thread pool to a single thread'
single-matcher\t'Limit the matcher thread pool to a single thread'"
complete -c sk -l hscroll-off -r
complete -c sk -l jump-labels -r
complete -c sk -l tail -r

View file

@ -33,7 +33,7 @@ module completions {
}
def "nu-complete sk flags" [] {
[ "no-preview-pty" "show-score" "show-index" ]
[ "no-preview-pty" "show-score" "show-index" "single-reader" "single-matcher" ]
}
# Fuzzy Finder in rust!
@ -126,7 +126,7 @@ module completions {
--man # Generate man page and output it to stdout
--listen: string # Run an IPC socket with optional name (defaults to sk)
--remote: string # Send commands to an IPC socket with optional name (defaults to sk)
--tmux: string # Run in a tmux popup
--popup: string # Run in a tmux or zellij popup
--log-level: string # Set the log level
--log-file: string # Pipe log output to a file
--flags: string@"nu-complete sk flags" # Feature flags

View file

@ -86,12 +86,14 @@ power-shell\:"PowerShell"
zsh\:"Zsh"))' \
'--listen=[Run an IPC socket with optional name (defaults to sk)]::LISTEN:_default' \
'--remote=[Send commands to an IPC socket with optional name (defaults to sk)]::REMOTE:_default' \
'--tmux=[Run in a tmux popup]::TMUX:_default' \
'--popup=[Run in a tmux or zellij popup]::POPUP:_default' \
'--log-level=[Set the log level]:LOG_LEVEL:_default' \
'--log-file=[Pipe log output to a file]:LOG_FILE:_default' \
'*--flags=[Feature flags]:FLAGS:((no-preview-pty\:"Disable preview PTY on linux"
show-score\:"Display the item'\''s match score before its value in the item list (for matcher debugging)"
show-index\:"Display the item'\''s index before its value in the item list"))' \
show-index\:"Display the item'\''s index before its value in the item list"
single-reader\:"Limit the reader thread pool to a single thread"
single-matcher\:"Limit the matcher thread pool to a single thread"))' \
'--hscroll-off=[]:HSCROLL_OFF:_default' \
'--jump-labels=[]:JUMP_LABELS:_default' \
'--tail=[]:TAIL:_default' \

View file

@ -20,8 +20,8 @@ use skim::binds::parse_action_chain;
use skim::reader::CommandCollector;
use skim::tui::event::Action;
use std::fs::File;
use std::io;
use std::io::{BufReader, BufWriter, IsTerminal, Write};
use std::{env, io};
use skim::prelude::*;
@ -125,6 +125,20 @@ fn main() -> Result<()> {
}
}
/// Returns `None` if the popup should not open, otherwise run the popup and return the result
#[cfg(unix)]
fn check_and_run_popup(opts: &SkimOptions) -> Option<Option<SkimOutput>> {
if opts.popup.is_some() && popup::check_env() {
Some(crate::popup::run_with(opts))
} else {
None
}
}
#[cfg(not(unix))]
fn check_and_run_popup(_opts: &SkimOptions) -> Option<Option<SkimOutput>> {
None
}
fn sk_main(mut opts: SkimOptions) -> Result<i32> {
let reader_opts = SkimItemReaderOption::from_options(&opts);
let cmd_collector = Rc::new(RefCell::new(SkimItemReader::new(reader_opts)));
@ -154,12 +168,7 @@ fn sk_main(mut opts: SkimOptions) -> Result<i32> {
//------------------------------------------------------------------------------
// output
let Some(result) = (if opts.tmux.is_some() && env::var("TMUX").is_ok() && cfg!(unix) {
#[cfg(not(unix))]
unreachable!("tmux is ignored on windows");
#[cfg(unix)]
crate::tmux::run_with(&opts)
} else {
let Some(result) = check_and_run_popup(&opts).unwrap_or_else(|| {
// read from pipe or command
let rx_item = if io::stdin().is_terminal() || (opts.interactive && opts.cmd.is_some()) {
None
@ -167,7 +176,7 @@ fn sk_main(mut opts: SkimOptions) -> Result<i32> {
let rx_item = cmd_collector.borrow().of_bufread(BufReader::new(std::io::stdin()));
Some(rx_item)
};
Some(Skim::run_with(opts, rx_item)?)
Skim::run_with(opts, rx_item).ok()
}) else {
return Ok(135);
};

View file

@ -61,6 +61,8 @@ pub mod item;
pub mod matcher;
pub mod options;
mod output;
#[cfg(unix)]
pub mod popup;
pub mod prelude;
pub mod reader;
mod skim;
@ -68,8 +70,6 @@ mod skim_item;
pub mod spinlock;
pub mod theme;
pub mod thread_pool;
#[cfg(unix)]
pub mod tmux;
pub mod tui;
mod util;

View file

@ -304,7 +304,7 @@ impl Matcher {
//
// The chunk size controls the granularity of work distribution and
// the frequency of atomic counter updates / interrupt checks.
const CHUNK_SIZE: usize = 512;
const CHUNK_SIZE: usize = 1 << 12;
// Convert items into an Arc slice so all workers can share them.
let shared_items: Arc<[Arc<dyn SkimItem>]> = items.into();

View file

@ -804,16 +804,13 @@ pub struct SkimOptions {
#[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting", default_missing_value = "sk", num_args=0..))]
pub remote: Option<String>,
/// Run in a tmux popup
/// Run in a tmux or zellij popup
///
/// Format: `sk --tmux <center|top|bottom|left|right>[,SIZE[%]][,SIZE[%]]`
///
/// Depending on the direction, the order and behavior of the sizes varies:
///
/// Default: center,50%
/// Ignored on Windows
#[cfg_attr(feature = "cli", arg(long, verbatim_doc_comment, help_heading = "Display", default_missing_value = "center,50%", num_args=0..))]
pub tmux: Option<String>,
/// Format: `sk --popup <center|top|bottom|left|right>[,SIZE[%]][,SIZE[%]]`
/// Note: this will try to detect a Zellij session, then a Tmux session
/// This means that in nested sesions, skim will prioritize Zellij over Tmux
#[cfg_attr(feature = "cli", arg(long, verbatim_doc_comment, help_heading = "Display", default_missing_value = "center,50%", num_args=0.., alias = "tmux"))]
pub popup: Option<String>,
/// Set the log level
#[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
@ -1084,7 +1081,7 @@ impl Default for SkimOptions {
pre_select_items: Default::default(),
pre_select_file: Default::default(),
filter: Default::default(),
tmux: Default::default(),
popup: Default::default(),
log_file: Default::default(),
extended: Default::default(),
literal: Default::default(),
@ -1327,6 +1324,17 @@ pub enum FeatureFlag {
ShowScore,
/// Display the item's index before its value in the item list
ShowIndex,
/// Limit the reader thread pool to a single thread
///
/// Forces the reader pipeline to run on exactly one thread, regardless of the number of
/// available CPU cores. Useful for debugging reader-side behaviour or for environments where
/// parallelism causes ordering issues.
SingleReader,
/// Limit the matcher thread pool to a single thread
///
/// Forces the matcher to run on exactly one thread, regardless of the number of available CPU
/// cores. Useful for reproducing deterministic match ordering or for debugging the matcher.
SingleMatcher,
}
#[allow(unused_macros)]

View file

@ -1,14 +1,16 @@
//! Tmux integration utilities.
//! Tmux & Zellij integration utilities.
//!
//! This module provides functionality for running skim within tmux panes,
//! This module provides functionality for running skim within tmux/zellij panes,
//! allowing skim to be used as a tmux popup or split pane.
mod tmux;
mod zellij;
use std::{
borrow::Cow,
env,
fmt::Write as FmtWrite,
io::{BufRead as _, BufReader, BufWriter, IsTerminal as _, Write as _},
process::{Command, Stdio},
process::ExitStatus,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
@ -20,7 +22,6 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use nix::sys::stat::Mode;
use nix::unistd::mkfifo;
use rand::{RngExt as _, distr::Alphanumeric};
use which::which;
use crate::{
Rank, SkimItem, SkimOptions, SkimOutput,
@ -28,8 +29,11 @@ use crate::{
tui::{Event, event::Action},
};
use tmux::TmuxPopup;
use zellij::ZellijPopup;
#[derive(Debug, PartialEq, Eq)]
enum TmuxWindowDir {
enum PopupWindowDir {
Center,
Top,
Bottom,
@ -37,9 +41,9 @@ enum TmuxWindowDir {
Right,
}
impl From<&str> for TmuxWindowDir {
impl From<&str> for PopupWindowDir {
fn from(value: &str) -> Self {
use TmuxWindowDir::{Bottom, Center, Left, Right, Top};
use PopupWindowDir::{Bottom, Center, Left, Right, Top};
match value {
"top" => Top,
"bottom" => Bottom,
@ -50,51 +54,29 @@ impl From<&str> for TmuxWindowDir {
}
}
#[derive(Debug, PartialEq, Eq)]
struct TmuxOptions<'a> {
width: &'a str,
height: &'a str,
x: &'a str,
y: &'a str,
trait SkimPopup {
fn from_options(options: &SkimOptions) -> Box<dyn SkimPopup>
where
Self: Sized;
fn add_env(&mut self, key: &str, value: &str);
fn run_and_wait(&mut self, command: &str) -> std::io::Result<ExitStatus>;
}
struct SkimTmuxOutput {
struct SkimPopupOutput {
line: String,
}
impl SkimItem for SkimTmuxOutput {
impl SkimItem for SkimPopupOutput {
fn text(&self) -> Cow<'_, str> {
Cow::from(&self.line)
}
}
impl<'a> From<&'a String> for TmuxOptions<'a> {
fn from(value: &'a String) -> Self {
let (raw_dir, size) = value.split_once(',').unwrap_or((value, "50%"));
let dir = TmuxWindowDir::from(raw_dir);
let (height, width) = if let Some((lhs, rhs)) = size.split_once(',') {
match dir {
TmuxWindowDir::Center | TmuxWindowDir::Left | TmuxWindowDir::Right => (rhs, lhs),
TmuxWindowDir::Top | TmuxWindowDir::Bottom => (lhs, rhs),
}
} else {
match dir {
TmuxWindowDir::Left | TmuxWindowDir::Right => ("100%", size),
TmuxWindowDir::Top | TmuxWindowDir::Bottom => (size, "100%"),
TmuxWindowDir::Center => (size, size),
}
};
let (x, y) = match dir {
TmuxWindowDir::Center => ("C", "C"),
TmuxWindowDir::Top => ("C", "0%"),
TmuxWindowDir::Bottom => ("C", "100%"),
TmuxWindowDir::Left => ("0%", "C"),
TmuxWindowDir::Right => ("100%", "C"),
};
Self { width, height, x, y }
}
/// Returns true if a compatible multiplexer is running and we are not already in a popup
/// (`$_SKIM_POPUP`)
#[must_use]
pub fn check_env() -> bool {
std::env::var("_SKIM_POPUP").is_err() && (tmux::is_available() || zellij::is_available())
}
/// Run skim in a tmux popup
@ -109,7 +91,7 @@ impl<'a> From<&'a String> for TmuxOptions<'a> {
pub fn run_with(opts: &SkimOptions) -> Option<SkimOutput> {
// Create temp dir for downstream output
let temp_dir_name = format!(
"sk-tmux-{}",
"sk-popup-{}",
&rand::rng()
.sample_iter(&Alphanumeric)
.take(8)
@ -173,14 +155,14 @@ pub fn run_with(opts: &SkimOptions) -> Option<SkimOutput> {
};
// Build args to send to downstream sk invocation
let mut tmux_shell_cmd = String::new();
let mut prev_is_tmux_flag = false;
let mut stripped_shell_cmd = String::new();
let mut prev_is_popup = false;
let mut prev_is_output_format_flag = false;
// We keep argv[0] to use in the popup's command
for arg in std::env::args() {
debug!("Got arg {arg}");
if prev_is_tmux_flag {
prev_is_tmux_flag = false;
if prev_is_popup {
prev_is_popup = false;
if !arg.starts_with('-') {
continue;
}
@ -188,12 +170,12 @@ pub fn run_with(opts: &SkimOptions) -> Option<SkimOutput> {
prev_is_output_format_flag = false;
continue;
}
if arg == "--tmux" {
debug!("Found tmux arg, skipping this and the next");
prev_is_tmux_flag = true;
if arg == "--tmux" || arg == "--popup" {
debug!("Found popup arg, skipping this and the next");
prev_is_popup = true;
continue;
} else if arg.starts_with("--tmux") {
debug!("Found equal tmux arg, skipping");
} else if arg.starts_with("--tmux") || arg.starts_with("--popup") {
debug!("Found equal popup arg, skipping");
continue;
} else if arg == "--output-format" {
debug!("Found output format arg, skipping this and the next");
@ -203,7 +185,7 @@ pub fn run_with(opts: &SkimOptions) -> Option<SkimOutput> {
debug!("Found equal output format arg, skipping");
continue;
}
push_quoted_arg(&mut tmux_shell_cmd, &arg);
push_quoted_arg(&mut stripped_shell_cmd, &arg);
}
// Always add all --print-xxx flags to the child sk command so that the output
// is fully structured and can be parsed unconditionally below, regardless of
@ -215,49 +197,37 @@ pub fn run_with(opts: &SkimOptions) -> Option<SkimOutput> {
"--print-current",
"--print-score",
] {
let _ = write!(tmux_shell_cmd, " {flag}");
let _ = write!(stripped_shell_cmd, " {flag}");
}
tmux_shell_cmd = tmux_shell_cmd.replace("--output-format", "");
if has_piped_input {
let _ = write!(tmux_shell_cmd, " <{}", tmp_stdin.display());
let _ = write!(stripped_shell_cmd, " <{}", tmp_stdin.display());
}
let _ = write!(tmux_shell_cmd, " >{}", tmp_stdout.display());
let _ = write!(stripped_shell_cmd, " >{}", tmp_stdout.display());
debug!("build cmd {}", &tmux_shell_cmd);
debug!("build cmd {}", &stripped_shell_cmd);
// Run downstream sk in tmux
let raw_tmux_opts = &opts.tmux.clone().unwrap();
let tmux_opts = TmuxOptions::from(raw_tmux_opts);
let mut tmux_cmd = Command::new(which("tmux").unwrap_or_else(|e| panic!("Failed to find tmux in path: {e}")));
tmux_cmd
.arg("display-popup")
.arg("-E")
.args(["-d", std::env::current_dir().unwrap().to_str().unwrap()])
.args(["-h", tmux_opts.height])
.args(["-w", tmux_opts.width])
.args(["-x", tmux_opts.x])
.args(["-y", tmux_opts.y]);
let mut popup: Box<dyn SkimPopup> = if zellij::is_available() {
ZellijPopup::from_options(opts)
} else if tmux::is_available() {
TmuxPopup::from_options(opts)
} else {
panic!("You shouldn't have been able to get here");
};
for (name, value) in std::env::vars() {
if name.starts_with("SKIM") || name == "PATH" || name.starts_with("RUST") {
let value = sanitize_value(value);
debug!("adding {name} = {value} to the command's env");
tmux_cmd.args(["-e", &format!("{name}={value}")]);
popup.add_env(&name, &value);
}
}
popup.add_env("_SKIM_POPUP", "1");
tmux_cmd.args(["sh", "-c", &tmux_shell_cmd]);
debug!("tmux command: {tmux_cmd:?}");
let status = tmux_cmd
.stdout(Stdio::null())
.stderr(Stdio::null())
.stdin(Stdio::null())
.status()
.unwrap_or_else(|e| panic!("Tmux invocation failed with {e}"));
let status = popup
.run_and_wait(&stripped_shell_cmd)
.unwrap_or_else(|e| panic!("Popup invocation of {stripped_shell_cmd} failed with {e}"));
// Signal the stdin thread to stop and wait for it to exit
stop_reading.store(true, Ordering::Relaxed);
@ -268,6 +238,8 @@ pub fn run_with(opts: &SkimOptions) -> Option<SkimOutput> {
let mut stdout = stdout_bytes.split(output_ending);
let _ = std::fs::remove_dir_all(temp_dir);
debug!("popup stdout: {stdout:?}");
// The child sk process always runs with --print-query, --print-cmd, --print-header,
// and --print-score, so we always read those lines unconditionally.
let query_str = if status.success() {
@ -295,7 +267,7 @@ pub fn run_with(opts: &SkimOptions) -> Option<SkimOutput> {
None
} else {
Some(MatchedItem::new(
Arc::new(SkimTmuxOutput { line: line.to_string() }),
Arc::new(SkimPopupOutput { line: line.to_string() }),
Rank::default(),
None,
&RankBuilder::default(),
@ -315,7 +287,7 @@ pub fn run_with(opts: &SkimOptions) -> Option<SkimOutput> {
..Default::default()
};
let item = MatchedItem::new(
Arc::new(SkimTmuxOutput { line: line.to_string() }),
Arc::new(SkimPopupOutput { line: line.to_string() }),
rank,
None,
&RankBuilder::default(),
@ -334,8 +306,8 @@ pub fn run_with(opts: &SkimOptions) -> Option<SkimOutput> {
final_event,
is_abort,
final_key: KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
// Note: In tmux mode, the actual final key is not available since skim runs in a separate
// tmux popup process. Only the output text is captured. Use --expect with --bind to capture
// Note: In poup mode, the actual final key is not available since skim runs in a separate
// popup process. Only the output text is captured. Use --expect with --bind to capture
// specific accept keys in the output if needed.
query: query_str.to_string(),
cmd: command_str.to_string(),
@ -347,19 +319,11 @@ pub fn run_with(opts: &SkimOptions) -> Option<SkimOutput> {
}
fn push_quoted_arg(args_str: &mut String, arg: &str) {
use shell_quote::{Bash, Fish, Quote as _, Sh, Zsh};
let shell_path = env::var("SHELL").unwrap_or(String::from("/bin/sh"));
let shell = shell_path.rsplit_once('/').unwrap_or(("", "sh")).1;
let quoted_arg: Vec<u8> = match shell {
"zsh" => Zsh::quote(arg),
"bash" => Bash::quote(arg),
"fish" => Fish::quote(arg),
_ => Sh::quote(arg),
};
use shell_quote::{Quote as _, Sh};
let _ = write!(
args_str,
" {}",
String::from_utf8(quoted_arg).expect("Failed to parse quoted arg as utf8, this should not happen")
String::from_utf8(Sh::quote(arg)).expect("Failed to parse quoted arg as utf8, this should not happen")
);
}
@ -377,76 +341,106 @@ fn sanitize_value(value: String) -> String {
mod tests {
use super::*;
fn check(input: &str, height: &str, width: &str, x: &str, y: &str) {
assert_eq!(
TmuxOptions::from(&String::from(input)),
TmuxOptions { width, height, x, y }
);
// ── PopupWindowDir::from ──────────────────────────────────────────────────
#[test]
fn popup_window_dir_known_values() {
assert_eq!(PopupWindowDir::from("center"), PopupWindowDir::Center);
assert_eq!(PopupWindowDir::from("top"), PopupWindowDir::Top);
assert_eq!(PopupWindowDir::from("bottom"), PopupWindowDir::Bottom);
assert_eq!(PopupWindowDir::from("left"), PopupWindowDir::Left);
assert_eq!(PopupWindowDir::from("right"), PopupWindowDir::Right);
}
#[test]
fn tmux_options_default() {
check("", "50%", "50%", "C", "C");
fn popup_window_dir_unknown_falls_back_to_center() {
assert_eq!(PopupWindowDir::from(""), PopupWindowDir::Center);
assert_eq!(PopupWindowDir::from("foobar"), PopupWindowDir::Center);
assert_eq!(PopupWindowDir::from("CENTER"), PopupWindowDir::Center); // case-sensitive
}
// ── sanitize_value ────────────────────────────────────────────────────────
#[test]
fn tmux_options_center() {
let (x, y) = ("C", "C");
check("center", "50%", "50%", x, y);
check("center,10", "10", "10", x, y);
check("center,10,20", "20", "10", x, y);
check("center,10%,20", "20", "10%", x, y);
check("center,10%,20%", "20%", "10%", x, y);
}
#[test]
fn tmux_options_top() {
let (x, y) = ("C", "0%");
check("top", "50%", "100%", x, y);
check("top,10", "10", "100%", x, y);
check("top,10,20", "10", "20", x, y);
check("top,10%,20", "10%", "20", x, y);
check("top,10%,20%", "10%", "20%", x, y);
}
#[test]
fn tmux_options_bottom() {
let (x, y) = ("C", "100%");
check("bottom", "50%", "100%", x, y);
check("bottom,10", "10", "100%", x, y);
check("bottom,10,20", "10", "20", x, y);
check("bottom,10%,20", "10%", "20", x, y);
check("bottom,10%,20%", "10%", "20%", x, y);
}
#[test]
fn tmux_options_left() {
let (x, y) = ("0%", "C");
check("left", "100%", "50%", x, y);
check("left,10", "100%", "10", x, y);
check("left,10,20", "20", "10", x, y);
check("left,10%,20", "20", "10%", x, y);
check("left,10%,20%", "20%", "10%", x, y);
}
#[test]
fn tmux_options_right() {
let (x, y) = ("100%", "C");
check("right", "100%", "50%", x, y);
check("right,10", "100%", "10", x, y);
check("right,10,20", "20", "10", x, y);
check("right,10%,20", "20", "10%", x, y);
check("right,10%,20%", "20%", "10%", x, y);
fn sanitize_value_no_semicolon() {
assert_eq!(sanitize_value("hello".to_string()), "hello");
assert_eq!(sanitize_value("foo=bar".to_string()), "foo=bar");
assert_eq!(sanitize_value(String::new()), "");
}
#[test]
fn test_sanitize_value() {
assert_eq!(sanitize_value("some-value".to_string()), "some-value".to_string());
assert_eq!(sanitize_value("some-value;".to_string()), "some-value\\;".to_string());
assert_eq!(sanitize_value("some-value;;".to_string()), "some-value;\\;".to_string());
assert_eq!(
sanitize_value("some-value;;;".to_string()),
"some-value;;\\;".to_string()
);
assert_eq!(sanitize_value("some-value;x".to_string()), "some-value;x".to_string());
assert_eq!(
sanitize_value("some-value;x;".to_string()),
"some-value;x\\;".to_string()
);
fn sanitize_value_trailing_semicolon_is_escaped() {
assert_eq!(sanitize_value("hello;".to_string()), "hello\\;");
assert_eq!(sanitize_value(";".to_string()), "\\;");
}
#[test]
fn sanitize_value_semicolon_in_middle_unchanged() {
assert_eq!(sanitize_value("hel;lo".to_string()), "hel;lo");
assert_eq!(sanitize_value("a;b;c".to_string()), "a;b;c");
}
// ── push_quoted_arg ───────────────────────────────────────────────────────
// These tests mutate the SHELL env var. `#[serial]` ensures they never run
// concurrently. `set_var`/`remove_var` are `unsafe fn` in Rust ≥ 1.81
// (edition 2024); the SAFETY invariant holds because `#[serial]` serialises
// access so no other thread reads the var while it is being written.
#[test]
#[serial_test::serial]
fn push_quoted_arg_simple_word_sh() {
// SAFETY: serialised by #[serial]; no concurrent reads of SHELL.
unsafe { std::env::set_var("SHELL", "/bin/sh") };
let mut s = String::new();
push_quoted_arg(&mut s, "hello");
assert_eq!(s, " hello");
unsafe { std::env::remove_var("SHELL") };
}
#[test]
#[serial_test::serial]
fn push_quoted_arg_spaces_are_quoted() {
// SAFETY: serialised by #[serial]; no concurrent reads of SHELL.
unsafe { std::env::set_var("SHELL", "/bin/sh") };
let mut s = String::new();
push_quoted_arg(&mut s, "hello world");
// The result must preserve both words and not be a bare unquoted string
assert!(s.contains("hello"));
assert!(s.contains("world"));
assert_ne!(s.trim(), "hello world"); // must be quoted somehow
unsafe { std::env::remove_var("SHELL") };
}
#[test]
#[serial_test::serial]
fn push_quoted_arg_appends_with_space_prefix() {
// SAFETY: serialised by #[serial]; no concurrent reads of SHELL.
unsafe { std::env::set_var("SHELL", "/bin/sh") };
let mut s = String::from("sk");
push_quoted_arg(&mut s, "--flag");
assert!(s.starts_with("sk "));
unsafe { std::env::remove_var("SHELL") };
}
#[test]
#[serial_test::serial]
fn push_quoted_arg_bash_shell() {
// SAFETY: serialised by #[serial]; no concurrent reads of SHELL.
unsafe { std::env::set_var("SHELL", "/usr/bin/bash") };
let mut s = String::new();
push_quoted_arg(&mut s, "simple");
assert_eq!(s, " simple");
unsafe { std::env::remove_var("SHELL") };
}
#[test]
#[serial_test::serial]
fn push_quoted_arg_zsh_shell() {
// SAFETY: serialised by #[serial]; no concurrent reads of SHELL.
unsafe { std::env::set_var("SHELL", "/bin/zsh") };
let mut s = String::new();
push_quoted_arg(&mut s, "simple");
assert_eq!(s, " simple");
unsafe { std::env::remove_var("SHELL") };
}
}

239
src/popup/tmux.rs Normal file
View file

@ -0,0 +1,239 @@
use crate::SkimOptions;
use super::{PopupWindowDir, SkimPopup};
use std::process::{Command, ExitStatus, Stdio};
pub fn is_available() -> bool {
cfg!(unix) && std::env::var("TMUX").is_ok() && which::which("tmux").is_ok()
}
pub(super) struct TmuxPopup {
cmd: Command,
}
impl TmuxPopup {
fn build(options: &SkimOptions) -> Self {
let arg = options.popup.as_ref().expect("this arg should be present to get here");
let mut cmd = Command::new(
which::which("tmux").expect("tmux not found in path. This should have been caught by is_available"),
);
cmd.arg("display-popup").arg("-E").args([
"-d",
&std::env::current_dir()
.ok()
.map_or(".".to_string(), |d| d.to_string_lossy().to_string()),
]);
let border = {
use crate::tui::BorderType::{Plain, Rounded, Thick};
match options.border {
None => "none",
Some(Plain) => "single",
Some(Rounded) => "rounded",
Some(Thick) => "heavy",
Some(_) => "double",
}
};
let (raw_dir, size) = arg.split_once(',').unwrap_or((arg, "50%"));
let dir = PopupWindowDir::from(raw_dir);
let (height, width) = if let Some((lhs, rhs)) = size.split_once(',') {
match dir {
PopupWindowDir::Center | PopupWindowDir::Left | PopupWindowDir::Right => (rhs, lhs),
PopupWindowDir::Top | PopupWindowDir::Bottom => (lhs, rhs),
}
} else {
match dir {
PopupWindowDir::Left | PopupWindowDir::Right => ("100%", size),
PopupWindowDir::Top | PopupWindowDir::Bottom => (size, "100%"),
PopupWindowDir::Center => (size, size),
}
};
let (x, y) = match dir {
PopupWindowDir::Center => ("C", "C"),
PopupWindowDir::Top => ("C", "0%"),
PopupWindowDir::Bottom => ("C", "100%"),
PopupWindowDir::Left => ("0%", "C"),
PopupWindowDir::Right => ("100%", "C"),
};
cmd.args(["-h", height])
.args(["-w", width])
.args(["-x", x])
.args(["-y", y])
.args(["-b", border]);
Self { cmd }
}
}
impl SkimPopup for TmuxPopup {
fn from_options(options: &SkimOptions) -> Box<dyn SkimPopup> {
Box::new(Self::build(options)) as Box<dyn SkimPopup>
}
fn add_env(&mut self, key: &str, value: &str) {
self.cmd.args(["-e", &format!("{key}={value}")]);
}
fn run_and_wait(&mut self, command: &str) -> std::io::Result<ExitStatus> {
debug!("tmux command: {command:?}");
self.cmd.args(["sh", "-c", command]);
debug!("tmux full command: {:?}", self.cmd);
self.cmd
.stdout(Stdio::null())
.stderr(Stdio::null())
.stdin(Stdio::null())
.status()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::options::SkimOptionsBuilder;
/// Skip the test if `tmux` is not in PATH (CI environments without tmux).
macro_rules! require_tmux {
() => {
if which::which("tmux").is_err() {
return;
}
};
}
fn opts(tmux: &str) -> crate::SkimOptions {
SkimOptionsBuilder::default()
.popup(tmux)
.build()
.expect("valid options")
}
fn opts_with_border(tmux: &str, border: crate::tui::BorderType) -> crate::SkimOptions {
SkimOptionsBuilder::default()
.popup(tmux)
.border(border)
.build()
.expect("valid options")
}
#[test]
fn border_none_does_not_panic() {
require_tmux!();
// Ensure each BorderType variant can be passed without panicking.
for border in [
crate::tui::BorderType::Plain,
crate::tui::BorderType::Rounded,
crate::tui::BorderType::Thick,
crate::tui::BorderType::Double,
] {
let _ = TmuxPopup::build(&opts_with_border("center", border));
}
// No border option
let _ = TmuxPopup::build(&opts("center"));
}
fn args(popup: &TmuxPopup) -> Vec<String> {
popup.cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect()
}
fn get_flag<'a>(args: &'a [String], flag: &str) -> Option<&'a str> {
args.windows(2).find(|w| w[0] == flag).map(|w| w[1].as_str())
}
#[test]
fn center_default_size() {
require_tmux!();
let popup = TmuxPopup::build(&opts("center"));
let a = args(&popup);
assert_eq!(get_flag(&a, "-h"), Some("50%"));
assert_eq!(get_flag(&a, "-w"), Some("50%"));
assert_eq!(get_flag(&a, "-x"), Some("C"));
assert_eq!(get_flag(&a, "-y"), Some("C"));
}
#[test]
fn center_no_direction_defaults_to_center() {
require_tmux!();
// Bare "50%" with no direction keyword defaults to Center
let popup = TmuxPopup::build(&opts("50%"));
let a = args(&popup);
assert_eq!(get_flag(&a, "-x"), Some("C"));
assert_eq!(get_flag(&a, "-y"), Some("C"));
}
#[test]
fn top_direction() {
require_tmux!();
let popup = TmuxPopup::build(&opts("top,40%"));
let a = args(&popup);
assert_eq!(get_flag(&a, "-h"), Some("40%"));
assert_eq!(get_flag(&a, "-w"), Some("100%"));
assert_eq!(get_flag(&a, "-x"), Some("C"));
assert_eq!(get_flag(&a, "-y"), Some("0%"));
}
#[test]
fn bottom_direction() {
require_tmux!();
let popup = TmuxPopup::build(&opts("bottom,30%"));
let a = args(&popup);
assert_eq!(get_flag(&a, "-h"), Some("30%"));
assert_eq!(get_flag(&a, "-w"), Some("100%"));
assert_eq!(get_flag(&a, "-x"), Some("C"));
assert_eq!(get_flag(&a, "-y"), Some("100%"));
}
#[test]
fn left_direction() {
require_tmux!();
let popup = TmuxPopup::build(&opts("left,30%"));
let a = args(&popup);
assert_eq!(get_flag(&a, "-h"), Some("100%"));
assert_eq!(get_flag(&a, "-w"), Some("30%"));
assert_eq!(get_flag(&a, "-x"), Some("0%"));
assert_eq!(get_flag(&a, "-y"), Some("C"));
}
#[test]
fn right_direction() {
require_tmux!();
let popup = TmuxPopup::build(&opts("right,30%"));
let a = args(&popup);
assert_eq!(get_flag(&a, "-h"), Some("100%"));
assert_eq!(get_flag(&a, "-w"), Some("30%"));
assert_eq!(get_flag(&a, "-x"), Some("100%"));
assert_eq!(get_flag(&a, "-y"), Some("C"));
}
#[test]
fn two_dimensional_size_center() {
// "center,WIDTH,HEIGHT" — for Center/Left/Right: height=rhs, width=lhs
require_tmux!();
let popup = TmuxPopup::build(&opts("center,60%,40%"));
let a = args(&popup);
assert_eq!(get_flag(&a, "-w"), Some("60%"));
assert_eq!(get_flag(&a, "-h"), Some("40%"));
}
#[test]
fn two_dimensional_size_top() {
// "top,HEIGHT,WIDTH" — for Top/Bottom: height=lhs, width=rhs
require_tmux!();
let popup = TmuxPopup::build(&opts("top,30%,80%"));
let a = args(&popup);
assert_eq!(get_flag(&a, "-h"), Some("30%"));
assert_eq!(get_flag(&a, "-w"), Some("80%"));
}
#[test]
fn add_env_appends_e_flag() {
require_tmux!();
let mut popup = TmuxPopup::build(&opts("center"));
popup.add_env("FOO", "bar");
let a = args(&popup);
assert!(a.windows(2).any(|w| w[0] == "-e" && w[1] == "FOO=bar"));
}
}

288
src/popup/zellij.rs Normal file
View file

@ -0,0 +1,288 @@
use super::{PopupWindowDir, SkimPopup};
use crate::{SkimOptions, tui::Size};
use std::fmt::Write as _;
use std::process::{Command, ExitStatus, Stdio};
pub fn is_available() -> bool {
std::env::var("ZELLIJ").is_ok() && which::which("zellij").is_ok()
}
pub(super) struct ZellijPopup {
cmd: Command,
env: String,
}
fn middle_coord(size: Size, var: &str) -> Size {
match size {
Size::Percent(p) => Size::Percent(100u16.saturating_sub(p) / 2),
Size::Fixed(cols) => Size::Fixed(
std::env::var(var)
.map(|s| s.parse().unwrap_or(80))
.unwrap_or(80u16)
.saturating_sub(cols)
/ 2,
),
}
}
fn align_end_coord(size: Size, var: &str) -> Size {
match size {
Size::Percent(p) => Size::Percent(100 - p),
Size::Fixed(cols) => Size::Fixed(
std::env::var(var)
.map(|s| s.parse().unwrap_or(80))
.unwrap_or(80u16)
.saturating_sub(cols),
),
}
}
impl ZellijPopup {
fn build(options: &SkimOptions) -> Self {
let mut cmd = Command::new(
which::which("zellij").expect("zellij not found in path. This should have been caught by is_available"),
);
cmd.arg("run")
.arg("--floating")
.arg("--block-until-exit")
.arg("--close-on-exit")
.args(["--pinned", "true"])
.args(["--name", "skim"])
.args([
"--cwd",
&std::env::current_dir()
.ok()
.map_or(".".to_string(), |d| d.to_string_lossy().to_string()),
]);
if options.border.is_none() {
cmd.args(["--borderless", "true"]);
}
let arg = options.popup.as_ref().expect("this arg should be present to get here");
let (raw_dir, size) = arg.split_once(',').unwrap_or((arg, "50%"));
let dir = PopupWindowDir::from(raw_dir);
let (height, width) = if let Some((lhs, rhs)) = size.split_once(',') {
let parsed_rhs = Size::try_from(rhs).unwrap_or(Size::Percent(50));
let parsed_lhs = Size::try_from(lhs).unwrap_or(Size::Percent(50));
match dir {
PopupWindowDir::Center | PopupWindowDir::Left | PopupWindowDir::Right => (parsed_rhs, parsed_lhs),
PopupWindowDir::Top | PopupWindowDir::Bottom => (parsed_lhs, parsed_rhs),
}
} else {
let parsed_size = Size::try_from(size).unwrap_or(Size::Percent(50));
let full_size = Size::Percent(100);
match dir {
PopupWindowDir::Left | PopupWindowDir::Right => (full_size, parsed_size),
PopupWindowDir::Top | PopupWindowDir::Bottom => (parsed_size, full_size),
PopupWindowDir::Center => (parsed_size, parsed_size),
}
};
let (x, y) = match dir {
PopupWindowDir::Center => {
let x = middle_coord(width, "COLUMNS");
let y = middle_coord(height, "ROWS");
(x, y)
}
PopupWindowDir::Top => (middle_coord(width, "COLUMNS"), Size::Fixed(0)),
PopupWindowDir::Bottom => (middle_coord(width, "COLUMNS"), align_end_coord(height, "ROWS")),
PopupWindowDir::Left => (Size::Fixed(0), middle_coord(height, "ROWS")),
PopupWindowDir::Right => (align_end_coord(width, "COLUMNS"), middle_coord(height, "ROWS")),
};
cmd.args(["--height", &height.to_string()])
.args(["--width", &width.to_string()])
.args(["-x", &x.to_string()])
.args(["-y", &y.to_string()]);
Self {
cmd,
env: String::new(),
}
}
}
impl SkimPopup for ZellijPopup {
fn from_options(options: &SkimOptions) -> Box<dyn SkimPopup> {
Box::new(Self::build(options))
}
fn add_env(&mut self, key: &str, value: &str) {
let _ = write!(
self.env,
" {key}={}",
&String::from_utf8_lossy(&shell_quote::Sh::quote_vec(value))
);
}
fn run_and_wait(&mut self, command: &str) -> std::io::Result<ExitStatus> {
debug!("zellij command: {command:?}");
self.cmd
.arg("--")
.args(["sh", "-c", format!("{} {command}", self.env).trim()]);
debug!("zellij full command: {:?}", self.cmd);
self.cmd
// .stdout(Stdio::null())
// .stderr(Stdio::null())
.stdin(Stdio::null())
.status()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::options::SkimOptionsBuilder;
/// Skip the test if `zellij` is not in PATH (CI environments without zellij).
macro_rules! require_zellij {
() => {
if which::which("zellij").is_err() {
return;
}
};
}
fn opts(popup: &str) -> crate::SkimOptions {
SkimOptionsBuilder::default()
.popup(popup)
.build()
.expect("valid options")
}
fn args(popup: &ZellijPopup) -> Vec<String> {
popup.cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect()
}
fn get_flag<'a>(args: &'a [String], flag: &str) -> Option<&'a str> {
args.windows(2).find(|w| w[0] == flag).map(|w| w[1].as_str())
}
// ── middle_coord ────────────────────────────────────────────────────────
// Tests that mutate COLUMNS are annotated with #[serial] so they never run
// concurrently. `set_var`/`remove_var` are `unsafe fn` in Rust ≥ 1.81
// (edition 2024); the SAFETY invariant holds because #[serial] serialises
// access so no other thread reads the var while it is being written.
#[test]
fn middle_coord_percent() {
// 50% wide in a 100% viewport → offset should be 25%
assert_eq!(middle_coord(Size::Percent(50), "COLUMNS"), Size::Percent(25));
}
#[test]
#[serial_test::serial]
fn middle_coord_fixed_uses_env_var() {
// SAFETY: serialised by #[serial]; no concurrent reads of COLUMNS.
unsafe { std::env::set_var("COLUMNS", "80") };
// 20 cols wide → offset = (80 - 20) / 2 = 30
assert_eq!(middle_coord(Size::Fixed(20), "COLUMNS"), Size::Fixed(30));
unsafe { std::env::remove_var("COLUMNS") };
}
#[test]
#[serial_test::serial]
fn middle_coord_fixed_fallback() {
// SAFETY: serialised by #[serial]; no concurrent reads of COLUMNS.
unsafe { std::env::remove_var("COLUMNS") };
// fallback width = 80; (80 - 20) / 2 = 30
assert_eq!(middle_coord(Size::Fixed(20), "COLUMNS"), Size::Fixed(30));
}
// ── align_end_coord ──────────────────────────────────────────────────────
#[test]
fn align_end_coord_percent() {
// 30% → end offset = 70%
assert_eq!(align_end_coord(Size::Percent(30), "COLUMNS"), Size::Percent(70));
}
#[test]
#[serial_test::serial]
fn align_end_coord_fixed_uses_env_var() {
// SAFETY: serialised by #[serial]; no concurrent reads of COLUMNS.
unsafe { std::env::set_var("COLUMNS", "80") };
// 20 cols wide → end offset = 80 - 20 = 60
assert_eq!(align_end_coord(Size::Fixed(20), "COLUMNS"), Size::Fixed(60));
unsafe { std::env::remove_var("COLUMNS") };
}
// ── from_options / build ─────────────────────────────────────────────────
#[test]
fn center_default_size() {
require_zellij!();
let popup = ZellijPopup::build(&opts("center"));
let a = args(&popup);
assert_eq!(get_flag(&a, "--height"), Some("50%"));
assert_eq!(get_flag(&a, "--width"), Some("50%"));
}
#[test]
fn top_direction() {
require_zellij!();
let popup = ZellijPopup::build(&opts("top,40%"));
let a = args(&popup);
assert_eq!(get_flag(&a, "--height"), Some("40%"));
assert_eq!(get_flag(&a, "--width"), Some("100%"));
assert_eq!(get_flag(&a, "-y"), Some("0"));
}
#[test]
fn left_direction() {
require_zellij!();
let popup = ZellijPopup::build(&opts("left,30%"));
let a = args(&popup);
assert_eq!(get_flag(&a, "--width"), Some("30%"));
assert_eq!(get_flag(&a, "-x"), Some("0"));
}
#[test]
#[serial_test::serial]
fn right_direction() {
require_zellij!();
// SAFETY: serialised by #[serial]; no concurrent reads of COLUMNS.
unsafe { std::env::set_var("COLUMNS", "80") };
let popup = ZellijPopup::build(&opts("right,25%"));
let a = args(&popup);
// width = 25%, x = align_end_coord(25%, "COLUMNS") = 75%
assert_eq!(get_flag(&a, "--width"), Some("25%"));
assert_eq!(get_flag(&a, "-x"), Some("75%"));
unsafe { std::env::remove_var("COLUMNS") };
}
#[test]
fn borderless_when_no_border_option() {
require_zellij!();
let popup = ZellijPopup::build(&opts("center"));
let a = args(&popup);
assert!(a.contains(&"--borderless".to_string()));
}
#[test]
fn no_borderless_when_border_set() {
require_zellij!();
let opts = SkimOptionsBuilder::default()
.popup("center")
.border(crate::tui::BorderType::Plain)
.build()
.expect("valid options");
let popup = ZellijPopup::build(&opts);
let a = args(&popup);
assert!(!a.contains(&"--borderless".to_string()));
}
#[test]
fn add_env_appends_to_env_string() {
require_zellij!();
let mut popup = ZellijPopup::build(&opts("center"));
popup.add_env("FOO", "bar");
popup.add_env("BAZ", "qux");
assert_eq!(popup.env, " FOO=bar BAZ=qux");
}
}

View file

@ -259,7 +259,13 @@ impl App {
let initial_header_height = header.height();
let layout_template = LayoutTemplate::from_options(&options, initial_header_height);
let layout = layout_template.apply(Rect::default());
let (reader_threads, matcher_threads) = thread_pool::partition_threads(*NUM_THREADS);
let (mut reader_threads, mut matcher_threads) = thread_pool::partition_threads(*NUM_THREADS);
if options.flags.contains(&crate::options::FeatureFlag::SingleReader) {
reader_threads = 1;
}
if options.flags.contains(&crate::options::FeatureFlag::SingleMatcher) {
matcher_threads = 1;
}
Self {
input: Input::from_options(&options, theme.clone()),
preview: Preview::from_options(&options, theme.clone()),

View file

@ -112,6 +112,15 @@ impl Default for Size {
}
}
impl std::fmt::Display for Size {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Percent(p) => f.write_fmt(format_args!("{p}%")),
Self::Fixed(s) => f.write_fmt(format_args!("{s}")),
}
}
}
/// This mirrors Ratatui's border type
///
/// We need it so that we can properly use `ValueEnum`

View file

@ -42,6 +42,30 @@ fn get_tmux_cmd(outfile: &str) -> Result<String> {
Ok(cmd)
}
/// Regression test: when --popup/--tmux is set via SKIM_DEFAULT_OPTIONS the child
/// sk process must not re-enter the popup path (infinite recursion guard).
#[test]
fn tmux_via_skim_default_options() -> Result<()> {
let tmux = TmuxController::new()?;
let outfile = setup_tmux_mock(&tmux)?;
// Run sk with SKIM_DEFAULT_OPTIONS=--tmux inline (bypassing the SK constant
// which always clears SKIM_DEFAULT_OPTIONS).
let sk_bin = crate::common::SK
.split_whitespace()
.last()
.expect("SK must have a binary path");
let cmd = format!("SKIM_DEFAULT_OPTIONS='--tmux' {sk_bin}");
tmux.send_keys(&[Str(&cmd), Enter])?;
tmux.until(|_| Path::new(&outfile).exists())?;
let cmd = get_tmux_cmd(&outfile)?;
// The parent should have opened exactly one popup; the child must not loop back.
assert!(cmd.starts_with("display-popup"));
// _SKIM_POPUP must be forwarded so the child knows it is already inside a popup
assert!(cmd.contains("_SKIM_POPUP=1"));
Ok(())
}
#[test]
fn tmux_vanilla() -> Result<()> {
let mut tmux = TmuxController::new()?;
@ -104,42 +128,7 @@ fn tmux_stdin() -> Result<()> {
}
#[test]
fn tmux_quote_bash() -> Result<()> {
let mut tmux = TmuxController::new()?;
let outfile = setup_tmux_mock(&tmux)?;
tmux.send_keys(&[Str("export SHELL=/bin/bash"), Enter])?;
tmux.send_keys(&[Str("export SKIM_ESCAPED_VAR=';;'"), Enter])?;
tmux.start_sk(None, &["--tmux", "--bind 'ctrl-a:reload(ls /foo*)'"])?;
tmux.until(|_| Path::new(&outfile).exists())?;
let cmd = get_tmux_cmd(&outfile)?;
assert!(cmd.starts_with("display-popup"));
assert!(cmd.contains("-E"));
assert!(cmd.contains("--bind $'ctrl-a:reload(ls /foo*)'"));
assert!(cmd.contains("SKIM_ESCAPED_VAR=;\\;"));
Ok(())
}
#[test]
fn tmux_quote_zsh() -> Result<()> {
let mut tmux = TmuxController::new()?;
let outfile = setup_tmux_mock(&tmux)?;
tmux.send_keys(&[Str("export SHELL=/bin/zsh"), Enter])?;
tmux.send_keys(&[Str("export SKIM_ESCAPED_VAR=';;'"), Enter])?;
tmux.start_sk(None, &["--tmux", "--bind 'ctrl-a:reload(ls /foo*)'"])?;
tmux.until(|_| Path::new(&outfile).exists())?;
let cmd = get_tmux_cmd(&outfile)?;
println!("{cmd}");
assert!(cmd.starts_with("display-popup"));
assert!(cmd.contains("-E"));
assert!(cmd.contains(
"sk --bind $'ctrl-a:reload(ls /foo*)' --print-query --print-cmd --print-header --print-current --print-score >"
));
assert!(cmd.contains("SKIM_ESCAPED_VAR=;\\;"));
Ok(())
}
#[test]
fn tmux_quote_sh() -> Result<()> {
fn tmux_quote() -> Result<()> {
let mut tmux = TmuxController::new()?;
let outfile = setup_tmux_mock(&tmux)?;
tmux.send_keys(&[Str("export SHELL=/bin/sh"), Enter])?;
@ -154,19 +143,3 @@ fn tmux_quote_sh() -> Result<()> {
Ok(())
}
#[test]
fn tmux_quote_fish() -> Result<()> {
let mut tmux = TmuxController::new()?;
let outfile = setup_tmux_mock(&tmux)?;
tmux.send_keys(&[Str("export SHELL=/bin/fish"), Enter])?;
tmux.send_keys(&[Str("export SKIM_ESCAPED_VAR=';;'"), Enter])?;
tmux.start_sk(None, &["--tmux", "--bind 'ctrl-a:reload(ls /foo*)'"])?;
tmux.until(|_| Path::new(&outfile).exists())?;
let cmd = get_tmux_cmd(&outfile)?;
assert!(cmd.starts_with("display-popup"));
assert!(cmd.contains("-E"));
assert!(cmd.contains("--bind ctrl-a':reload(ls /foo*)'"));
assert!(cmd.contains("SKIM_ESCAPED_VAR=;\\;"));
Ok(())
}