Separate floating pane border label from pane title

--border-label of a tmux floating pane was stored in the pane title and
read back by pane-border-format as '#{pane_title}', so any program in the
pane could replace the border text by setting the title. On Zellij the
name was only set when the pane was created. Either way the built-in
label actions could not touch it, as fzf draws no border of its own
there.

- tmux: hold the label in pane-scoped @fzf-border-label. Set the option
  even without a label, so a later change-border-label has somewhere to
  write. Drop select-pane -T; the pane title is left to the user
- Zellij: run 'zellij action rename-pane' on label change. 'zellij run'
  has no option to set the environment of the new pane, so the command
  exports the target itself
- change-border-label and transform-border-label now update the native
  border, through the setter nativeLabelSetter picks by multiplexer
- The pane is named in a __FZF_INTERNAL_ variable that runProxy withholds
  from the environment replay, so the same name in the outer environment
  cannot redirect the update to another pane
This commit is contained in:
Junegunn Choi
2026-07-28 21:20:53 +09:00
parent b5f2ba4fd4
commit 50e6c026ec
9 changed files with 279 additions and 57 deletions
+3
View File
@@ -3,6 +3,9 @@ CHANGELOG
0.74.2
------
- `change-border-label` and `transform-border-label` now update the label on the native border of a tmux or Zellij floating pane
- On tmux, the label no longer goes through the pane title, so a program running in the pane cannot replace it by setting the title
- Fixed a Kitty graphics sequence from a preview command being taken by tmux as a request to set the pane title; it is now passed through to the terminal
- Performance optimizations for short queries
- The most critical case: fzf narrows results as you type, so short queries scan the largest candidate sets and the first keystroke scans the whole input.
- Single-character queries are up to 2.4x faster
+7 -3
View File
@@ -426,9 +426,13 @@ On tmux 3.7 or above and on Zellij, the floating pane is not modal; you can
switch to other panes and windows while fzf is running, and move and resize
the pane with the mouse. The native border of the pane is the handle for
moving and resizing it, so it is used by default and \fBborder\-native\fR is
implied. On tmux, \fB\-\-border\-label\fR is set as the title of the pane,
and is displayed on the border if \fBpane\-border\-status\fR is enabled in
tmux (\fB\-\-border\-label\-pos\fR is ignored).
implied. \fB\-\-border\-label\fR is displayed on the native border, and
\fBchange\-border\-label\fR and \fBtransform\-border\-label\fR update it
(\fB\-\-border\-label\-pos\fR is ignored). On tmux, fzf holds the label in
the \fB@fzf\-border\-label\fR option of the pane and sets its
\fBpane\-border\-format\fR to read it back, so the label is displayed if
\fBpane\-border\-status\fR is enabled in tmux. The title of the pane is left
alone. On Zellij, the label is the name of the pane.
fzf draws its own border instead when a border style is explicitly specified
with \fB\-\-border\fR, so that it is the only border shown. \fBnone\fR and
+69
View File
@@ -11,6 +11,7 @@ import (
"path/filepath"
"regexp"
"strings"
"sync"
"time"
"github.com/junegunn/fzf/src/tui"
@@ -19,10 +20,75 @@ import (
const becomeSuffix = ".become"
// Withheld from the environment replay below, so an outer value cannot
// override what the floating pane set
const internalEnvPrefix = "__FZF_INTERNAL_"
func escapeSingleQuote(str string) string {
return "'" + strings.ReplaceAll(str, "'", "'\\''") + "'"
}
// Read and remove, so preview and execute commands do not inherit it
func takeEnv(name string) string {
value := os.Getenv(name)
os.Unsetenv(name)
return value
}
// Applies label updates off the event loop, which would block on the fork
type labelUpdater struct {
set func(string)
mu sync.Mutex
pending *string
running bool
}
// A queue of one: a burst collapses to the newest label. A goroutine per
// update could apply them out of order
func (u *labelUpdater) update(label string) {
u.mu.Lock()
defer u.mu.Unlock()
u.pending = &label
if u.running {
return
}
u.running = true
go u.run()
}
// Ends once caught up, so nothing needs cancelling when Run returns
func (u *labelUpdater) run() {
for {
u.mu.Lock()
label := u.pending
if label == nil {
u.running = false
u.mu.Unlock()
return
}
u.pending = nil
u.mu.Unlock()
u.set(*label)
}
}
// Updates the label on the native border, or nil when it is not fzf's to update
func nativeLabelSetter() func(string) {
// Read both, so neither is left behind
tmuxPane, zellijPane := takeEnv(tmuxBorderLabelEnv), takeEnv(zellijBorderLabelEnv)
var set func(string)
switch {
case tmuxPane != "":
set = func(label string) { setTmuxBorderLabel(tmuxPane, label) }
case zellijPane != "":
set = func(label string) { setZellijBorderLabel(zellijPane, label) }
default:
return nil
}
updater := &labelUpdater{set: set}
return updater.update
}
func popupArgStr(args []string, opts *Options) (string, string) {
fzf, rest := args[0], args[1:]
args = []string{"--bind=ctrl-z:ignore"}
@@ -125,6 +191,9 @@ func runProxy(commandPrefix string, cmdBuilder func(temp string, needBash bool)
validIdentifier := regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
for _, pairStr := range os.Environ() {
pair := strings.SplitN(pairStr, "=", 2)
if strings.HasPrefix(pair[0], internalEnvPrefix) {
continue
}
if validIdentifier.MatchString(pair[0]) {
exports = append(exports, fmt.Sprintf("export %s=%s", pair[0], escapeSingleQuote(pair[1])))
} else if strings.HasPrefix(pair[0], "BASH_FUNC_") && strings.HasSuffix(pair[0], "%%") {
+65
View File
@@ -0,0 +1,65 @@
package fzf
import (
"strconv"
"sync"
"testing"
"time"
)
func TestLabelUpdater(t *testing.T) {
var mu sync.Mutex
applied := []int{}
// The goroutine can still be running after the test function returns, so
// it records the label instead of reporting it, and -1 stands in for one
// that could not be parsed
updater := &labelUpdater{set: func(label string) {
n, err := strconv.Atoi(label)
if err != nil {
n = -1
}
mu.Lock()
applied = append(applied, n)
mu.Unlock()
// Long enough for the burst to pile up behind the first update
time.Sleep(time.Millisecond)
}}
const last = 99
for i := 0; i <= last; i++ {
updater.update(strconv.Itoa(i))
}
// However many updates are dropped, the final one is always applied
deadline := time.Now().Add(5 * time.Second)
for {
mu.Lock()
done := len(applied) > 0 && applied[len(applied)-1] == last
mu.Unlock()
if done {
break
}
if time.Now().After(deadline) {
t.Fatalf("last update was not applied: %v", applied)
}
time.Sleep(time.Millisecond)
}
mu.Lock()
defer mu.Unlock()
// Updates are never applied out of order, so the border cannot be left
// showing a label older than the last one requested
for i, n := range applied {
if i > 0 && n <= applied[i-1] {
t.Errorf("applied out of order: %v", applied)
break
}
}
// A burst collapses instead of running one command per update. The
// updates are queued in a tight loop while each one takes a millisecond
// to apply, so all of them landing would mean no coalescing at all
if len(applied) >= last+1 {
t.Errorf("expected updates to be coalesced, applied all %d", len(applied))
}
t.Logf("applied %d of %d updates", len(applied), last+1)
}
+6
View File
@@ -470,6 +470,7 @@ type Terminal struct {
clickFooterLine int
clickFooterColumn int
proxyScript string
setNativeLabel func(string)
numLinesCache map[int32]numLinesCacheValue
raw bool
lastActivity time.Time
@@ -1142,6 +1143,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
printer: opts.Printer,
printsep: opts.PrintSep,
proxyScript: opts.ProxyScript,
setNativeLabel: nativeLabelSetter(),
merger: em,
passMerger: em,
resultMerger: em,
@@ -7272,6 +7274,10 @@ func (t *Terminal) Loop() error {
if t.border != nil {
t.borderLabel, t.borderLabelLen = t.ansiLabelPrinter(label, &tui.ColBorderLabel, false)
req(reqRedrawBorderLabel)
} else if t.setNativeLabel != nil {
// fzf draws no border of its own; the label is on the
// native border of the floating pane
t.setNativeLabel(label)
}
})
case actChangePreviewLabel, actTransformPreviewLabel, actBgTransformPreviewLabel:
+44 -33
View File
@@ -55,12 +55,26 @@ func escapeTmuxSeparator(str string) string {
return str
}
// Escape a string for use as the pane title; select-pane -T expands format
// expressions denoted by '#', but not time conversion specifiers
func escapeTmuxTitle(str string) string {
// '#[...]' is still processed when a substituted value is drawn, so '#' is
// doubled. '#{...}' is already literal
func escapeTmuxFormat(str string) string {
return escapeTmuxSeparator(strings.ReplaceAll(str, "#", "##"))
}
// The option pane-border-format reads back, and the pane it is set on
const (
tmuxBorderLabelOption = "@fzf-border-label"
tmuxBorderLabelEnv = internalEnvPrefix + "TMUX_LABEL_PANE"
)
// Errors are ignored. The pane may be gone
func setTmuxBorderLabel(pane string, label string) {
// Strip ANSI sequences. tmux would draw the escape's remainder as text
label, _, _ = extractColor(label, nil, nil)
exec.Command("tmux", "set-option", "-p", "-t", pane,
tmuxBorderLabelOption, escapeTmuxFormat(label)).Run()
}
// 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 {
@@ -128,35 +142,28 @@ func runTmuxFloatingPane(argStr string, dir string, windowWidth int, windowHeigh
// remain-on-exit is on.
setup := `tmux set-option -p -t "$TMUX_PANE" remain-on-exit off 2> /dev/null; `
// Set --border-label as the title of the floating pane, and as its
// pane-border-format so that it is displayed on the border when
// pane-border-status is enabled. Without a label, the border text
// is cleared so that the default pane status content (e.g. the
// pane title) is not shown. pane-border-format is pane-scoped,
// but pane-border-status is a window option that only becomes
// pane-scoped in the next release of tmux, so it is left alone.
// --border-label lives in a pane-scoped user option that
// pane-border-format reads back, so a program in the pane cannot
// replace it by setting the title. Set even when empty, so
// change-border-label can fill it in and the default pane status is
// not shown. pane-border-status is a window option until the next
// tmux release, so it is left alone.
// https://github.com/tmux/tmux/commit/7a18fa281db3
// --border-label-pos is ignored.
// The label is left to fzf when it draws its own border with the
// label on it. '--border=none' is not the case; fzf would not
// display the label, but the native border of a floating pane
// cannot be removed, so display the label on it nonetheless.
format := ""
if opts.BorderLabel.label != "" &&
(opts.BorderShape == tui.BorderUndefined || opts.BorderShape == tui.BorderLine ||
opts.BorderShape == tui.BorderNone) {
// --border-label-pos is ignored. With an explicit --border fzf draws
// the label itself, but the native border cannot be removed.
ownsLabel := noBorderSpecified(opts)
label := ""
if ownsLabel {
// Strip ANSI sequences fzf would otherwise render itself
label, _, _ := extractColor(opts.BorderLabel.label, nil, nil)
if label != "" {
setup += fmt.Sprintf(`tmux select-pane -t "$TMUX_PANE" -T %s 2> /dev/null; `,
escapeSingleQuote(escapeTmuxTitle(label)))
// The title is displayed verbatim; substituted values are
// not expanded again
format = "#{pane_title}"
}
label, _, _ = extractColor(opts.BorderLabel.label, nil, nil)
}
setup += fmt.Sprintf(`tmux set-option -p -t "$TMUX_PANE" %s %s 2> /dev/null; `,
tmuxBorderLabelOption, escapeSingleQuote(escapeTmuxFormat(label)))
setup += fmt.Sprintf(`tmux set-option -p -t "$TMUX_PANE" pane-border-format %s 2> /dev/null; `,
escapeSingleQuote(format))
escapeSingleQuote("#{"+tmuxBorderLabelOption+"}"))
if ownsLabel {
setup += fmt.Sprintf(`export %s="$TMUX_PANE"; `, tmuxBorderLabelEnv)
}
paneCmd := fmt.Sprintf("%s%s %s; echo $? > %s; tmux wait-for -S %s",
setup, escapeSingleQuote(sh), escapeSingleQuote(temp), code, signal)
// Unzoom the window first; creating a floating pane over a zoomed
@@ -193,16 +200,20 @@ exit "$code"`, newPane, code, signal, signal, code, code, code)
}, opts, true)
}
// Whether no box border was asked for. 'none' and 'line' count as none.
// fzf draws no box for either
func noBorderSpecified(opts *Options) bool {
return opts.BorderShape == tui.BorderUndefined ||
opts.BorderShape == tui.BorderLine || opts.BorderShape == tui.BorderNone
}
// Whether to use the multiplexer's native border for the floating pane. Its
// native border is the handle that makes the pane movable and resizable with
// the mouse, so it is the default; 'border-native' forces it. It is not used
// when a border style is explicitly specified with --border, so that the
// fzf-drawn border is the only one shown. 'none' and 'line' are treated as no
// border; fzf draws no box for either, and 'line' only makes sense with
// --height.
// fzf-drawn border is the only one shown.
func nativeBorder(opts *Options) bool {
return opts.Tmux.border || opts.BorderShape == tui.BorderUndefined ||
opts.BorderShape == tui.BorderLine || opts.BorderShape == tui.BorderNone
return opts.Tmux.border || noBorderSpecified(opts)
}
func runTmux(args []string, opts *Options) (int, error) {
+18 -10
View File
@@ -2,37 +2,45 @@ package fzf
import "testing"
func TestEscapeTmuxTitle(t *testing.T) {
func TestEscapeTmuxFormat(t *testing.T) {
for _, tc := range []struct {
given string
expected string
}{
{"", ""},
{" fzf ", " fzf "},
{"#", "##"},
{"##", "####"},
{" C# notes #S ", " C## notes ##S "},
{"100%", "100%"},
// '#[' would be taken as a style directive when the label is drawn
{"#[fg=red]x", "##[fg=red]x"},
// '#{' is already literal in a substituted value, but doubling it
// keeps a label that fzf renders itself looking the same
{"#{pane_id}", "##{pane_id}"},
{" C# notes #S ", " C## notes ##S "},
{";", `\;`},
{"; rm", "; rm"},
{" ; ", " ; "},
} {
if actual := escapeTmuxTitle(tc.given); actual != tc.expected {
if actual := escapeTmuxFormat(tc.given); actual != tc.expected {
t.Errorf("expected %q, got %q", tc.expected, actual)
}
}
}
func TestEscapeTmuxTitleSeparator(t *testing.T) {
func TestEscapeTmuxSeparator(t *testing.T) {
for _, tc := range []struct {
given string
expected string
}{
{"#;", "##;"},
{";#", ";##"},
{"", ""},
{" fzf ", " fzf "},
{"#", "#"},
{"#{pane_id}", "#{pane_id}"},
{"100%", "100%"},
{";", `\;`},
{"; rm", "; rm"},
{" ; ", " ; "},
{";;", ";;"},
} {
if actual := escapeTmuxTitle(tc.given); actual != tc.expected {
if actual := escapeTmuxSeparator(tc.given); actual != tc.expected {
t.Errorf("expected %q, got %q", tc.expected, actual)
}
}
+22 -5
View File
@@ -1,11 +1,20 @@
package fzf
import (
"fmt"
"os/exec"
"github.com/junegunn/fzf/src/tui"
)
const zellijBorderLabelEnv = internalEnvPrefix + "ZELLIJ_LABEL_PANE"
// Errors are ignored. The pane may be gone
func setZellijBorderLabel(pane string, label string) {
// Strip ANSI sequences fzf would otherwise render itself
label, _, _ = extractColor(label, nil, nil)
// '--' so that a label starting with a hyphen is not parsed as a flag
exec.Command("zellij", "action", "rename-pane", "-p", pane, "--", label).Run()
}
func runZellij(args []string, opts *Options) (int, error) {
// Use the native Zellij border by default, consistent with tmux, so that
// the pane can be moved and resized with the mouse. Set before
@@ -20,6 +29,7 @@ func runZellij(args []string, opts *Options) (int, error) {
"run", "--floating", "--close-on-exit", "--block-until-exit",
"--cwd", dir,
}
ownsLabel := false
if !opts.Tmux.border {
zellijArgs = append(zellijArgs, "--borderless", "true")
} else {
@@ -33,8 +43,8 @@ func runZellij(args []string, opts *Options) (int, error) {
// stripping ANSI sequences fzf would otherwise render itself.
// --border-label-pos is ignored.
label := ""
if opts.BorderShape == tui.BorderUndefined || opts.BorderShape == tui.BorderLine ||
opts.BorderShape == tui.BorderNone {
ownsLabel = noBorderSpecified(opts)
if ownsLabel {
label, _, _ = extractColor(opts.BorderLabel.label, nil, nil)
}
zellijArgs = append(zellijArgs, "--name="+label)
@@ -60,7 +70,14 @@ func runZellij(args []string, opts *Options) (int, error) {
if err != nil {
return nil, err
}
zellijArgs = append(zellijArgs, sh, temp)
if ownsLabel {
// 'zellij run' cannot set the new pane's environment, so the
// command exports it itself. exec avoids a lingering shell
zellijArgs = append(zellijArgs, sh, "-c", fmt.Sprintf(`export %s="$ZELLIJ_PANE_ID"; exec %s %s`,
zellijBorderLabelEnv, escapeSingleQuote(sh), escapeSingleQuote(temp)))
} else {
zellijArgs = append(zellijArgs, sh, temp)
}
return exec.Command("zellij", zellijArgs...), nil
}, opts, true)
}
+45 -6
View File
@@ -17,9 +17,11 @@ class TestTmux < TestInteractive
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 }
# Border text is cleared when no label is given
format = IO.popen(['tmux', 'show-options', '-p', '-t', floating_pane, 'pane-border-format'], &:read)
assert_includes format, "''"
# Border text is empty when no label is given
pane = floating_pane
refute_nil pane
assert_equal "\#{@fzf-border-label}", pane_option(pane, 'pane-border-format')
assert_equal '', pane_option(pane, '@fzf-border-label')
tmux.send_keys '99'
tmux.until { |lines| assert_equal 1, lines.match_count }
tmux.send_keys :Enter
@@ -40,10 +42,41 @@ class TestTmux < TestInteractive
tmux.until { |lines| assert_equal 100, lines.item_count }
pane = floating_pane
refute_nil pane
# The label is held in a user option, not in the pane title, which is
# left to the user. '#' is doubled so that the label is drawn as it is
# written, instead of '#[...]' being taken as a style directive
assert_equal ' ##fzf-label 100% ', pane_option(pane, '@fzf-border-label')
assert_equal "\#{@fzf-border-label}", pane_option(pane, 'pane-border-format')
title = IO.popen(['tmux', 'display-message', '-p', '-t', pane, "\#{pane_title}"], &:read)
assert_equal ' #fzf-label 100% ', title.chomp
format = IO.popen(['tmux', 'show-options', '-p', '-t', pane, 'pane-border-format'], &:read)
assert_includes format, "\#{pane_title}"
refute_equal ' #fzf-label 100% ', title.chomp
tmux.send_keys :Enter
assert_equal '1', fzf_output
end
def test_floating_pane_change_border_label
tmux.send_keys "seq 100 | #{fzf(%(--popup center,80% --margin 0 --bind 'space:change-border-label( #[fg=red] 100% )'))}", :Enter
tmux.until { |lines| assert_equal 100, lines.item_count }
pane = floating_pane
refute_nil pane
assert_equal '', pane_option(pane, '@fzf-border-label')
tmux.send_keys :Space
wait { assert_equal ' ##[fg=red] 100% ', pane_option(pane, '@fzf-border-label') }
tmux.send_keys :Enter
assert_equal '1', fzf_output
end
# A program running in the pane owns the pane title, but not the label
def test_floating_pane_border_label_not_affected_by_title
tmux.send_keys "seq 100 | #{fzf(%(--popup center,80% --margin 0 --border-label ' label ' --bind 'space:execute-silent(printf "\\033]2;hijacked\\033\\\\" > /dev/tty)'))}", :Enter
tmux.until { |lines| assert_equal 100, lines.item_count }
pane = floating_pane
refute_nil pane
tmux.send_keys :Space
wait do
title = IO.popen(['tmux', 'display-message', '-p', '-t', pane, "\#{pane_title}"], &:read)
assert_equal 'hijacked', title.chomp
end
assert_equal ' label ', pane_option(pane, '@fzf-border-label')
tmux.send_keys :Enter
assert_equal '1', fzf_output
end
@@ -79,6 +112,12 @@ class TestTmux < TestInteractive
private
# stderr is merged in so that an unset option, which prints 'invalid
# option' to stderr and nothing to stdout, is not read as an empty value
def pane_option(pane, name)
IO.popen(['tmux', 'show-options', '-p', '-t', pane, '-v', name], err: %i[child out], &:read).chomp
end
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) }