Use floating pane instead of popup on tmux 3.7 or above (#4850) (#4852)
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled

Unlike a popup, a floating pane is not modal; you can switch to other
panes and windows while fzf is running, move and resize the pane with
the mouse, zoom it to fullscreen, and use copy-mode in it.

- Floating pane always has a native border, so 'border-native' is
  implied; give new 'border-fzf' option to fall back to a popup where
  fzf draws its own border
- Popup is also used on tmux versions below 3.7, or when the window is
  too small to fit a floating pane
- new-pane does not block until the command finishes and does not
  propagate the exit status; block on a wait-for channel signaled by
  the pane and pass the exit status through a temporary file
- Watchdog process signals the channel when the pane is closed
  abnormally (e.g. kill-pane)
- Kill the pane when the proxy process is interrupted, like a popup
  dying with its client
- Unzoom the window before creating the floating pane; doing so over a
  zoomed window crashes the tmux server on 3.7b, and newer versions of
  tmux unzoom the window anyway
- Floating pane size excludes the border and the position is that of
  the content area; treat the requested size as the total footprint
  including the border for consistency with popups
- Close the pane on exit even when remain-on-exit is on
- Pre-create the exit status file with O_EXCL to prevent tampering on
  a shared TMPDIR
This commit is contained in:
Junegunn Choi
2026-07-05 14:15:11 +09:00
committed by GitHub
parent a1fb01462d
commit 1e31e5dfbe
7 changed files with 240 additions and 14 deletions
+7
View File
@@ -3,6 +3,13 @@ CHANGELOG
0.74.0 (WIP)
------------
- On tmux 3.7 or above, `--popup` starts fzf in a floating pane instead of a popup (#4850)
- Unlike a popup, a floating pane is not modal; you can switch to other panes and windows while fzf is running, move and resize the pane with the mouse, zoom it to fullscreen, and use copy-mode in it
- A floating pane always has a native border, which is what makes the pane movable and resizable, so `border-native` is implied
- Give `border-fzf` to fall back to a popup where fzf draws its own border
```sh
fzf --popup center,80%,border-fzf
```
- Added `result-final` event, a variant of `result` that is not triggered while the input stream is still open (#4835)
- Use it for one-shot, per-query actions that would otherwise re-fire on every intermediate snapshot during loading
```sh
+11 -3
View File
@@ -417,11 +417,19 @@ layout options so that the specified number of items are visible in the list
section (default: \fB10+\fR).
Ignored when \fB\-\-height\fR is not specified or set as an absolute value.
.TP
.BI "\-\-popup" "[=[center|top|bottom|left|right][,SIZE[%]][,SIZE[%]][,border-native]]"
Start fzf in a tmux popup or in a Zellij floating pane (default
\fBcenter,50%\fR). Requires tmux 3.3+ or Zellij 0.44+. This option is ignored if you
.BI "\-\-popup" "[=[center|top|bottom|left|right][,SIZE[%]][,SIZE[%]][,border-native|border-fzf]]"
Start fzf in a tmux or Zellij floating pane (default \fBcenter,50%\fR).
Requires tmux 3.3+ or Zellij 0.44+. This option is ignored if you
are not running fzf inside tmux or Zellij. \fB\-\-tmux\fR is an alias for this option.
On tmux 3.7 or above, the floating pane is not modal; you can switch to
other panes and windows while fzf is running, move and resize the pane with
the mouse, zoom it to fullscreen, and use copy\-mode in it. The pane always
has a native border, which is what makes it movable and resizable, so
\fBborder\-native\fR is implied. On tmux versions below 3.7, or when
\fBborder\-fzf\fR is given, a modal tmux popup is used instead and fzf draws
its own border.
e.g.
\fB# Popup in the center with 70% width and height
fzf \-\-popup 70%
+19 -7
View File
@@ -75,9 +75,9 @@ Usage: fzf [options]
--min-height=HEIGHT[+] Minimum height when --height is given as a percentage.
Add '+' to automatically increase the value
according to the other layout options (default: 10+).
--popup[=OPTS] Start fzf in a popup window (requires tmux 3.3+ or Zellij 0.44+)
--popup[=OPTS] Start fzf in a floating pane (requires tmux 3.3+ or Zellij 0.44+)
[center|top|bottom|left|right][,SIZE[%]][,SIZE[%]]
[,border-native] (default: center,50%)
[,border-native|border-fzf] (default: center,50%)
--tmux[=OPTS] Alias for --popup
LAYOUT
@@ -335,12 +335,20 @@ const (
posNext // adjacent to the input section, on the list side
)
type tmuxBorder int
const (
tmuxBorderAuto tmuxBorder = iota
tmuxBorderNative
tmuxBorderFzf
)
type tmuxOptions struct {
width sizeSpec
height sizeSpec
position windowPosition
index int
border bool
border tmuxBorder
}
type layoutType int
@@ -427,15 +435,19 @@ func parseTmuxOptions(arg string, index int) (*tmuxOptions, error) {
var err error
opts := defaultTmuxOptions(index)
tokens := splitRegexp.Split(arg, -1)
errorToReturn := errors.New("invalid popup option: " + arg + " (expected: [center|top|bottom|left|right][,SIZE[%]][,SIZE[%][,border-native]])")
errorToReturn := errors.New("invalid popup option: " + arg + " (expected: [center|top|bottom|left|right][,SIZE[%]][,SIZE[%]][,border-native|border-fzf])")
if len(tokens) == 0 || len(tokens) > 4 {
return nil, errorToReturn
}
for i, token := range tokens {
if token == "border-native" {
tokens = append(tokens[:i], tokens[i+1:]...) // cut the 'border-native' option
opts.border = true
if token == "border-native" || token == "border-fzf" {
tokens = append(tokens[:i], tokens[i+1:]...) // cut the border option
if token == "border-native" {
opts.border = tmuxBorderNative
} else {
opts.border = tmuxBorderFzf
}
break
}
}
+2 -2
View File
@@ -26,14 +26,14 @@ func escapeSingleQuote(str string) string {
func popupArgStr(args []string, opts *Options) (string, string) {
fzf, rest := args[0], args[1:]
args = []string{"--bind=ctrl-z:ignore"}
if !opts.Tmux.border && (opts.BorderShape == tui.BorderUndefined || opts.BorderShape == tui.BorderLine) {
if opts.Tmux.border != tmuxBorderNative && (opts.BorderShape == tui.BorderUndefined || opts.BorderShape == tui.BorderLine) {
if tui.DefaultBorderShape == tui.BorderRounded {
rest = append(rest, "--border=rounded")
} else {
rest = append(rest, "--border=sharp")
}
}
if opts.Tmux.border && opts.Margin == defaultMargin() {
if opts.Tmux.border == tmuxBorderNative && opts.Margin == defaultMargin() {
args = append(args, "--margin=0,1")
}
argStr := escapeSingleQuote(fzf)
+136 -1
View File
@@ -1,10 +1,145 @@
package fzf
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
// Returns the size of the current window if the tmux server supports
// floating panes (tmux 3.7 or above)
func tmuxFloatingPaneInfo() (int, int, bool) {
target := os.Getenv("TMUX_PANE")
if target == "" {
return 0, 0, false
}
// A single invocation for both checks. Cannot rely on the exit status;
// tmux versions before 3.7 exit normally with empty output for an
// unknown command name, so check the output instead.
out, err := exec.Command("tmux", "display-message", "-p", "-t", target,
"#{window_width} #{window_height}", ";", "list-commands", "new-pane").Output()
if err != nil || !strings.Contains(string(out), "new-pane") {
return 0, 0, false
}
var width, height int
if _, err := fmt.Sscanf(string(out), "%d %d", &width, &height); err != nil {
return 0, 0, false
}
// The window is too small to fit a floating pane of the minimum size
if width < 3 || height < 3 {
return 0, 0, false
}
return width, height, true
}
// Convert sizeSpec to the number of cells, clamped between the minimum
// footprint of 3, including the border, and the window size
func tmuxDim(spec sizeSpec, window int) int {
dim := int(spec.size)
if spec.percent {
dim = window * dim / 100
}
return max(3, min(dim, window))
}
func runTmuxFloatingPane(argStr string, dir string, windowWidth int, windowHeight int, opts *Options) (int, error) {
// Unlike display-popup, the size of a floating pane does not account for
// the border around it, and the position is that of the content area. To
// stay consistent with popups, treat the requested size as the total
// footprint including the border.
width := tmuxDim(opts.Tmux.width, windowWidth)
height := tmuxDim(opts.Tmux.height, windowHeight)
x := (windowWidth-width)/2 + 1
y := (windowHeight-height)/2 + 1
switch opts.Tmux.position {
case posUp:
y = 1
case posDown:
y = windowHeight - height + 1
case posLeft:
x = 1
case posRight:
x = windowWidth - width + 1
}
return runProxy(argStr, func(temp string, needBash bool) (*exec.Cmd, error) {
sh, err := sh(needBash)
if err != nil {
return nil, err
}
// Unlike display-popup, new-pane does not block until the command
// finishes, and it does not propagate the exit status. So we block on
// a wait-for channel that the pane signals on completion, and pass
// the exit status through a temporary file. A watchdog process
// signals the same channel if the pane is closed abnormally
// (e.g. kill-pane), in which case the file is not written.
//
// has-session is the liveness check because it fails when the target
// pane is gone, while display-message succeeds even for a dead pane.
signal := escapeSingleQuote("fzf-" + filepath.Base(temp))
// Pre-create the exit status file so that another user on a shared
// TMPDIR cannot plant a file or a symbolic link at the predictable
// path while the pane is running
codeFile := temp + ".code"
f, err := os.OpenFile(codeFile, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
return nil, err
}
f.Close()
code := escapeSingleQuote(codeFile)
paneCmd := fmt.Sprintf("%s %s; echo $? > %s; tmux wait-for -S %s",
escapeSingleQuote(sh), escapeSingleQuote(temp), code, signal)
// Unzoom the window first; creating a floating pane over a zoomed
// window crashes the tmux server on 3.7b, and newer versions of
// tmux unzoom the window anyway. The pane should always close on
// exit like a popup, even when remain-on-exit is on. set-option
// targets the new pane as new-pane makes it the current pane.
target := os.Getenv("TMUX_PANE")
newPane := fmt.Sprintf(
"tmux if -F -t %s '#{window_zoomed_flag}' %s ';' new-pane -P -F '#{pane_id}' -t %s -c %s -x %d -y %d -X %d -Y %d %s -c %s ';' set-option -p remain-on-exit off",
escapeSingleQuote(target), escapeSingleQuote("resize-pane -Z -t "+target),
escapeSingleQuote(target), escapeSingleQuote(dir), width-2, height-2, x, y,
escapeSingleQuote(sh), escapeSingleQuote(paneCmd))
// The pane is killed when the proxy process is interrupted or hung up,
// like a popup dying with its client. wait-for runs in the background
// and is awaited with the interruptible wait builtin so that the trap
// can fire while blocked. The trap is installed before creating the
// pane; a signal received during creation is deferred until the
// command substitution completes, and the pane is killed right after.
// An interrupted wait does not reap the waiter, so it is killed
// along with the watchdog.
script := fmt.Sprintf(`trap '[ -n "$id" ] && tmux kill-pane -t "$id" 2> /dev/null' INT TERM HUP
id=$(%s) || { status=$?; rm -f %s; exit "$status"; }
{ while tmux has-session -t "$id" 2> /dev/null; do sleep 1; done; tmux wait-for -S %s; } &
watchdog=$!
tmux wait-for %s &
waiter=$!
wait "$waiter"
kill "$watchdog" "$waiter" 2> /dev/null
wait 2> /dev/null
if [ -s %s ]; then code=$(cat %s); else code=130; fi
rm -f %s
exit "$code"`, newPane, code, signal, signal, code, code, code)
return exec.Command(sh, "-c", script), nil
}, opts, true)
}
func runTmux(args []string, opts *Options) (int, error) {
// On tmux 3.7 or above, fzf runs in a floating pane instead of a popup.
// A floating pane always has a native border, so 'border-native' is
// implied. Give 'border-fzf' to fall back to a popup where fzf draws
// its own border.
if opts.Tmux.border != tmuxBorderFzf {
if windowWidth, windowHeight, ok := tmuxFloatingPaneInfo(); ok {
opts.Tmux.border = tmuxBorderNative
argStr, dir := popupArgStr(args, opts)
return runTmuxFloatingPane(argStr, dir, windowWidth, windowHeight, opts)
}
}
argStr, dir := popupArgStr(args, opts)
// Set tmux options for popup placement
@@ -15,7 +150,7 @@ func runTmux(args []string, opts *Options) (int, error) {
// W Both The window position on the status line
// S -y The line above or below the status line
tmuxArgs := []string{"display-popup", "-E", "-d", dir}
if !opts.Tmux.border {
if opts.Tmux.border != tmuxBorderNative {
tmuxArgs = append(tmuxArgs, "-B")
}
switch opts.Tmux.position {
+1 -1
View File
@@ -11,7 +11,7 @@ func runZellij(args []string, opts *Options) (int, error) {
"run", "--floating", "--close-on-exit", "--block-until-exit",
"--cwd", dir,
}
if !opts.Tmux.border {
if opts.Tmux.border != tmuxBorderNative {
zellijArgs = append(zellijArgs, "--borderless", "true")
}
switch opts.Tmux.position {
+64
View File
@@ -0,0 +1,64 @@
# frozen_string_literal: true
require 'shellwords'
require 'tmpdir'
require_relative 'lib/common'
# Tests for running fzf in a tmux floating pane (--popup on tmux 3.7 or above)
class TestTmux < TestInteractive
def setup
super
# Cannot rely on the exit status; tmux versions before 3.7 exit
# normally with empty output for an unknown command name
supported = IO.popen(%w[tmux list-commands new-pane], err: File::NULL, &:read).include?('new-pane')
skip('floating panes not supported') unless supported
end
def test_floating_pane
tmux.send_keys "seq 100 | #{fzf('--popup center,80% --margin 0')}", :Enter
tmux.until { |lines| assert_equal 100, lines.item_count }
tmux.send_keys '99'
tmux.until { |lines| assert_equal 1, lines.match_count }
tmux.send_keys :Enter
assert_equal '99', fzf_output
end
def test_floating_pane_killed
tmux.send_keys "seq 100 | #{FZF} --popup bottom,50% --margin 0; echo code:$?", :Enter
tmux.until { |lines| assert_equal 100, lines.item_count }
pane = floating_pane
refute_nil pane
assert system('tmux', 'kill-pane', '-t', pane)
tmux.until { |lines| assert lines.any_include?('code:130') }
end
def test_border_fzf_falls_back_to_popup
# display-popup requires an attached client, which the test environment
# may not have; intercept it with a tmux shim on PATH
dir = Dir.mktmpdir
real = `command -v tmux`.chomp
shim = File.join(dir, 'tmux')
File.write(shim, <<~SH)
#!/bin/sh
if [ "$1" = display-popup ]; then
echo popup-used >&2
exit 0
fi
exec #{real.shellescape} "$@"
SH
FileUtils.chmod(0o755, shim)
tmux.send_keys "seq 100 | PATH=#{dir.shellescape}:$PATH #{FZF} --popup center,border-fzf", :Enter
tmux.until { |lines| assert lines.any_include?('popup-used') }
refute floating_pane
ensure
FileUtils.remove_entry(dir) if dir
end
private
def floating_pane
format = "\#{pane_id} \#{pane_floating_flag}"
lines = IO.popen(['tmux', 'list-panes', '-t', tmux.win, '-F', format]) { |io| io.readlines(chomp: true) }
lines.filter_map { |line| line.split.first if line.end_with?(' 1') }.first
end
end