Skip to content

feat(zsh): Handle multi-line history selection - #4595

Merged
junegunn merged 13 commits into
junegunn:masterfrom
LangLangBart:zsh-hist-multi-select
Nov 30, 2025
Merged

feat(zsh): Handle multi-line history selection#4595
junegunn merged 13 commits into
junegunn:masterfrom
LangLangBart:zsh-hist-multi-select

Conversation

@LangLangBart

@LangLangBart LangLangBart commented Nov 18, 2025

Copy link
Copy Markdown
Collaborator

Related discussion #4591

@drelephant

Related PR with implementation in fish: #4280


Add the ability to select multiple zsh history entries

@LangLangBart LangLangBart left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@junegunn Annotations were added to clarify the terse Zsh syntax and ease the burden of reviewing.

Comment thread shell/key-bindings.zsh Outdated
Comment thread shell/key-bindings.zsh Outdated
Comment thread shell/key-bindings.zsh Outdated
Comment thread shell/key-bindings.zsh Outdated
Comment thread shell/key-bindings.zsh
Comment thread shell/key-bindings.zsh Outdated
@LangLangBart
LangLangBart marked this pull request as ready for review November 18, 2025 07:33
Comment thread shell/key-bindings.zsh Outdated
@junegunn

Copy link
Copy Markdown
Owner

@drelephant Can you test this and see if it works as expected? You can clone this repo, make install, and run source <(bin/fzf --zsh).

@LangLangBart

LangLangBart commented Nov 20, 2025

Copy link
Copy Markdown
Collaborator Author

rebased onto master and triggered the CI again; tests now pass, whereas previously it failed:


@junegunn Is there an easy way to temporarily disable or hide Perl when running a certain test, so I can checkZsh shell integration on OS versions that don’t have Perl installed by default?

EDIT1 unset 'commands[perl]' does the trick

When Perl is not available on the system, fallback to using awk
Comment thread test/test_shell_integration.rb Outdated
@junegunn
junegunn requested a review from Copilot November 24, 2025 09:55
@junegunn junegunn added enhancement Enhancement to existing feature shell Shell scripts labels Nov 24, 2025
@junegunn

Copy link
Copy Markdown
Owner

Sorry, I haven't had the bandwidth to review this yet. I just requested Copilot's review, let's see if it can provide meaningful insights. But feel free to dismiss any comments from it that you consider irrelevant or inaccurate.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds multi-line history selection support to zsh's fzf integration, allowing users to select and insert multiple history entries (including multi-line commands) at once, similar to the fish shell implementation in PR #4280.

Key Changes:

  • Modified the fzf-history-widget to support multi-select mode (--multi flag) instead of single selection
  • Added logic to process multiple selected history entries and join them with newlines
  • Implemented handling for both perl-based and awk-based history extraction paths

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
shell/key-bindings.zsh Enhanced fzf-history-widget to enable multi-selection, extract multiple history entries, and handle both perl and awk code paths with proper multi-line command preservation
test/test_shell_integration.rb Added comprehensive test coverage including a helper function to test both perl and awk branches, with tests for multi-selection, single-line mode, and edge cases like multi-line entries with leading numbers

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread shell/key-bindings.zsh Outdated
Comment thread test/test_shell_integration.rb
Comment thread shell/key-bindings.zsh Outdated
Comment on lines +172 to +173
# Join by newline after stripping trailing whitespace from each command
BUFFER="${(pj:\n:)${(@)cmds%%[[:space:]]#}}"

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just wondering. If I'm not mistaken, in the previous version, this code would only strip trailing new lines, but now it strips [[:space:]]. Could you elaborate on the change?

#4595 (comment)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

During testing, a command had a trailing newline & space character, and since the
original command only removed trailing newlines, it failed to remove anything. For example:

zsh -o extendedglob -fc $'
ORIGINAL="1 foo\nbar\n "
O_WSPACE="${ORIGINAL%%[[:space:]]#}"
O_NEWLIN="${ORIGINAL%%\n#}"
typeset -p ORIGINAL O_WSPACE O_NEWLIN'
typeset ORIGINAL=$'1 foo\nbar\n '
typeset O_WSPACE=$'1 foo\nbar'
typeset O_NEWLIN=$'1 foo\nbar\n '

My thinking was nobody wants trailing whitespace on a command, right 😬 ?

(Though there is a chance a user may complain, as it affects their workflow – https://www.explainxkcd.com/wiki/index.php/1172:_Workflow.)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My thinking was nobody wants trailing whitespace on a command, right 😬 ?

Yeah. One concern is that when:

  • User accidentally types in a command with extra spaces.
  • User wants to repeat the command, so they use CTRL-R to select the previous command, and press enter.
  • Now when they press CTRL-R again, they see two duplicate commands in the list, only differ in the trailing spaces.
100   whoami<invisible spaces>
101   whoami

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, I was able to reproduce what you described. I am using
'romkatv/zsh4humans', and it has a mechanism1 built in by default that
automatically removes trailing spaces from a command when it is executed, that
is why it took me a bit to reproduce your issue and find out why its not
behaving the same way as you described and only did in 'zsh -f' mode.


Anyway, are you suggesting it’s better to keep the original trailing‑newline
removal?

Because even when reverting back, your original issue still remains: a user sees
two commands.

Footnotes

  1. https://github.com/romkatv/zsh4humans/blob/cd6c4770c802c3a17b4c43e5587adabb9a370a75/fn/-z4h-zle-line-finish#L15-L19

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Anyway, are you suggesting it’s better to keep the original trailing‑newline removal?

Hmm, yeah, I think it's preferable that we don't modify the original command.

Because even when reverting back, your original issue still remains: a user sees two commands.

I didn't test your previous version. I might be missing something, but on zsh, when I repeat the same command multiple times, there's no duplication in the list. Are we on the same page?

$ foo<space><space>
$ foo<space><space>
$ foo<space><space>

# CTRL-R shows only one "foo<space><space>"

$ foo<space>
$ foo

# CTRL-R shows "foo<space><space>", "foo<space>", and "foo"

@LangLangBart LangLangBart Nov 29, 2025

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we on the same page?

Yes, we are. When viewing these commands, they appear to be the same, with the trailing space being the only difference.

Hmm, yeah, I think it's preferable that we don't modify the original command.

ok, will adjust that, and adopt test BUFFER="${(pj:\n:)${(@)cmds%%$'\n'#}}"

@junegunn junegunn left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for your hard work and thanks for new tests. Is there anything you want to address?

@LangLangBart

Copy link
Copy Markdown
Collaborator Author

Is there anything you want to address?

There is the comment by Copilot.

When entering 1 foobar€ into the query input and having no results, and with
export FZF_CTRL_R_OPTS='--bind enter:accept-or-print-query' set, Perl
would not surface anything to the user, while Awk would see 1 as the
history event number and fetch the corresponding command.

Status quo with the latest release (0.67.0), both Perl and Awk
incorrectly retrieve the history value for 1 instead of printing 1 foobar€
to the command-line buffer.

Do you think the I should make the heuristic stricter? At least for Perl it
would then correctly print 1 foobar€ to the command line buffer, but Awk would
still be "fooled".

--- a/shell/key-bindings.zsh
+++ b/shell/key-bindings.zsh
@@ -151,5 +151,6 @@ fzf-history-widget() {
   if [ -n "$selected" ]; then
     # Heuristic to check if the selected value is from history or a custom query
-    if [[ $selected == [[:blank:]]#<->\*#[[:blank:]]* ]]; then
+    if (( extracted_with_perl )) && [[ $selected == <->$'\t'* ]] || \
+      (( ! extracted_with_perl )) && [[ $selected == [[:blank:]]#<->\*#[[:blank:]]* ]]; then
       # Split at newlines
       for line in ${(ps:\n:)selected}; do

Of course I could then also add a proper test, which only Perl would actually
pass, that is the reason I have simply dismissed this case?

Comment thread shell/key-bindings.zsh
@junegunn

Copy link
Copy Markdown
Owner

Status quo with the latest release (0.67.0), both Perl and Awk incorrectly retrieve the history value for 1 instead of printing 1 foobar€ to the command-line buffer.

That is unfortunate.

Do you think the I should make the heuristic stricter? At least for Perl it
would then correctly print 1 foobar€ to the command line buffer, but Awk would
still be "fooled".

I think it's understandable we can't "perfect" this. fzf can be configured in numerous ways and it's unrealistic for us to make these scripts compatible with every possible setup.

That said, if the added complexity is not too significant, we can try to cover more cases. I'll leave the decision up to you.

@LangLangBart

LangLangBart commented Nov 29, 2025

Copy link
Copy Markdown
Collaborator Author

I'll leave the decision up to you.

Ok, I think we can make the heuristic a bit stricter for Perl/$history, and technically for Awk/fc as well, since by default the history event number (using fc) and the actual command are separated by two spaces or a * for foreign commands and one space, and this has been the case for a long time1.

I will work on that and let you know later today, so that we can also cover the 1 foobar€ case and have a test with it.

Footnotes

  1. https://github.com/zsh-users/zsh/blame/5539bc3fd59a2577bf1f951430c20e2b1e7b4dce/Src/builtin.c#L1829

@drelephant

Copy link
Copy Markdown

@drelephant Can you test this and see if it works as expected? You can clone this repo, make install, and run source <(bin/fzf --zsh).

I did that (using ./install instead of make install which came up with errors) and now I can't multi-select using tab after pressing ctrl-r.

I was previously using the code from here in my .zshrc but I took that out. Should I still need that in my .zshrc?

Also, before I took that code out of the .zshrc, I noticed that if I do this:

  1. type something onto the command line, eg "vi"
  2. press ctrl-r
  3. go up to select a previous usage of vi and hit enter

Then the command line will now have "vivi file" (ie it kept what was on the line before entering fzf.

I don't think it did that before updating to the latest commit?

Anyway, I'm on the latest commit now without anything extra in my .zshrc except

export FZF_CTRL_R_OPTS="
  --preview 'echo {}' --preview-window down:3:hidden:wrap
  --bind 'ctrl-p:toggle-preview'
  --multi
  --no-height"

And I can't multi-select using tab key.

@junegunn

junegunn commented Nov 29, 2025

Copy link
Copy Markdown
Owner

@drelephant

I did that (using ./install instead of make install which came up with errors) and now I can't multi-select using tab after pressing ctrl-r.

./install will download the latest pre-built binary, it does not build a fzf binary from source. We use source <(fzf --zsh) these days so you're using the the shell integration scripts embedded in that binary.

If you can't build the binary from source, you can just source the script files from your zsh.

@LangLangBart

Copy link
Copy Markdown
Collaborator Author

Then the command line will now have "vivi file"

should be fixed in this version; the one from the discussion used the LBUFFER assignment instead of BUFFER

I did that (using ./install instead of make install which came up with errors)

thanks @junegunn

@drelephant If you still have trouble testing, one way would be:

  1. download the file: https://github.com/LangLangBart/fzf/blob/zsh-hist-multi-select/shell/key-bindings.zsh
  2. run source <path to your downloaded file>
  3. press ctrl‑r

I think I am done.

updated the test, checked older zsh versions with the command below:

Version Release Date AWK Perl
4.3.15 2011-12-17
5.0.2 2012-12-21
5.4 2017-08-07
5.8 2020-02-15
5.9 2022-05-14 (latest)
# Requires fzf repo directory and 'zsh-hist-multi-select' branch checked out
# https://hub.docker.com/r/ohmyzsh/zsh/tags
podman run -qit --rm -e ARCH=$(uname -m) -v $PWD:/repo -w /repo ohmyzsh/zsh:5.9 sh -uec '
apt update >/dev/null 2>&1
apt install -y curl jq >/dev/null 2>&1
mkdir /fzf-bin
download_url=$(curl -s https://api.github.com/repos/junegunn/fzf/releases/latest  | \
  jq --arg arch_string "linux_$ARCH" \
    -r '\''.assets[].browser_download_url | select(contains($arch_string))'\'')
curl -sfLo /tmp/fzf.tar.gz "$download_url"
tar -xf /tmp/fzf.tar.gz -C /fzf-bin
{
  echo "export PATH=\"/fzf-bin:\$PATH\""
  echo "source /repo/shell/key-bindings.zsh"
  echo "PS1=\"FZF \$(fzf --version) > \""
  echo "RPS1=\"ZSH \$(zsh --version)\""
  echo "print -s -- \"ls -la\""
  echo "print -s -- \"cat /etc/os-release\""
} > ~/.zshrc
zsh'

@junegunn
junegunn merged commit cfc37ca into junegunn:master Nov 30, 2025
5 checks passed
@junegunn

Copy link
Copy Markdown
Owner

Merged, thanks a ton for the great work!

@drelephant

Copy link
Copy Markdown

./install will download the latest pre-built binary, it does not build a fzf binary from source. We use source <(fzf --zsh) these days so you're using the the shell integration scripts embedded in that binary.

If you can't build the binary from source, you can just source the script files from your zsh.

I just did a git pull

remote: Enumerating objects: 16, done.
remote: Counting objects: 100% (15/15), done.
remote: Compressing objects: 100% (6/6), done.
remote: Total 6 (delta 5), reused 0 (delta 0), pack-reused 0 (from 0)
Unpacking objects: 100% (6/6), 2.59 KiB | 1.29 MiB/s, done.
From https://github.com/junegunn/fzf
   af2a81d..cfc37ca  master     -> origin/master
Updating af2a81d..cfc37ca
Fast-forward
 shell/key-bindings.zsh         | 39 +++++++++++++++++----
 test/test_shell_integration.rb | 78 ++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 111 insertions(+), 6 deletions(-)

So I'm on the latest now.

Then source <(fzf --zsh), source ~/.zshrc ; exec zsh

And I couldn't multi-select, but then I did source ~/.fzf/shell/key-bindings.zsh and then it worked.

I can add that line to my ~/.zshrc, but is it odd that source <(fzf --zsh) didn't work properly?

@junegunn

Copy link
Copy Markdown
Owner

fzf --zsh prints the script embedded in that binary, so updating the external script file doesn't affect its. That's why we told you to build the binary from the source.

@LangLangBart LangLangBart mentioned this pull request Dec 4, 2025
tmeijn pushed a commit to tmeijn/dotfiles that referenced this pull request Feb 25, 2026
This MR contains the following updates:

| Package | Update | Change |
|---|---|---|
| [junegunn/fzf](https://github.com/junegunn/fzf) | minor | `v0.67.0` → `v0.68.0` |

MR created with the help of [el-capitano/tools/renovate-bot](https://gitlab.com/el-capitano/tools/renovate-bot).

**Proposed changes to behavior should be submitted there as MRs.**

---

### Release Notes

<details>
<summary>junegunn/fzf (junegunn/fzf)</summary>

### [`v0.68.0`](https://github.com/junegunn/fzf/releases/tag/v0.68.0): 0.68.0

[Compare Source](junegunn/fzf@v0.67.0...v0.68.0)

<https://github.com/user-attachments/assets/8d9e6e29-1bde-49d3-b580-c560da394a5d>

- Implemented word wrapping in the list section
  - Added `--wrap=word` (or `--wrap-word`) option and `toggle-wrap-word` action for word-level line wrapping in the list section
  - Changed default binding of `ctrl-/` and `alt-/` from `toggle-wrap` to `toggle-wrap-word`
  ```sh
  fzf --wrap=word
  ```
- Implemented word wrapping in the preview window
  - Added `wrap-word` flag for `--preview-window` to enable word-level wrapping
  - Added `toggle-preview-wrap-word` action
  ```sh
  fzf --preview 'bat --style=plain --color=always {}' \
      --preview-window wrap-word \
      --bind space:toggle-preview-wrap-word
  ```
- Added support for underline style variants in `--color`: `underline-double`, `underline-curly`, `underline-dotted`, `underline-dashed`
  ```sh
  fzf --color 'fg:underline-curly,current-fg:underline-dashed'
  ```
- Added support for underline styles (`4:N`) and underline colors (SGR 58/59)
  ```sh
  # In the list section
  printf '\e[4:3;58;2;255;0;0mRed curly underline\e[0m\n' | fzf --ansi

  # In the preview window
  fzf --preview "printf '\e[4:3;58;2;255;0;0mRed curly underline\e[0m\n'"
  ```
- Added `--preview-wrap-sign` to set a different wrap indicator for the preview window
- Added `alt-gutter` color option ([#&#8203;4602](junegunn/fzf#4602)) ([@&#8203;hedgieinsocks](https://github.com/hedgieinsocks))
- Added `$FZF_WRAP` environment variable to child processes (`char` or `word` when wrapping is enabled) ([#&#8203;4672](junegunn/fzf#4672)) ([@&#8203;bitraid](https://github.com/bitraid))
- fish: Improved command history (CTRL-R) ([#&#8203;4672](junegunn/fzf#4672)) ([@&#8203;bitraid](https://github.com/bitraid))
  - Enabled syntax highlighting in the list on fish 4.3.3+
  - Added syntax-highlighted preview window that auto-shows for long or multi-line commands
  - Added `ALT-ENTER` to reformat and insert selected commands
  - Improved handling of bulk deletion of selected history entries (`SHIFT-DELETE`)
- Added fish completion support ([#&#8203;4605](junegunn/fzf#4605)) ([@&#8203;lalvarezt](https://github.com/lalvarezt))
- zsh: Handle multi-line history selection ([#&#8203;4595](junegunn/fzf#4595)) ([@&#8203;LangLangBart](https://github.com/LangLangBart))
- Bug fixes
  - Fixed `_fzf_compgen_{path,dir}` to respect `FZF_COMPLETION_{PATH,DIR}_OPTS` ([#&#8203;4592](junegunn/fzf#4592)) ([@&#8203;shtse8](https://github.com/shtse8), [@&#8203;LangLangBart](https://github.com/LangLangBart))
  - Fixed `--preview-window follow` not working correctly with wrapping ([#&#8203;3243](junegunn/fzf#3243), [#&#8203;4258](junegunn/fzf#4258))
  - Fixed symlinks to directories being returned as files ([#&#8203;4676](junegunn/fzf#4676)) ([@&#8203;skk64](https://github.com/skk64))
  - Fixed SIGHUP signal handling ([#&#8203;4668](junegunn/fzf#4668)) ([@&#8203;LangLangBart](https://github.com/LangLangBart))
  - Fixed preview process not killed on exit ([#&#8203;4667](junegunn/fzf#4667))
  - Fixed coloring of items with zero-width characters ([#&#8203;4620](junegunn/fzf#4620))
  - Fixed `track-current` unset after a combined movement action ([#&#8203;4649](junegunn/fzf#4649))
  - Fixed `--accept-nth` being ignored in filter mode ([#&#8203;4636](junegunn/fzf#4636)) ([@&#8203;charemma](https://github.com/charemma))
  - Fixed display width calculation with `maxWidth` ([#&#8203;4596](junegunn/fzf#4596)) ([@&#8203;LangLangBart](https://github.com/LangLangBart))
  - Fixed clearing of the rest of the current line on start ([#&#8203;4652](junegunn/fzf#4652))
  - Fixed `x-api-key` header not required for GET requests ([#&#8203;4627](junegunn/fzf#4627))
  - Fixed key reading not cancelled when `execute` triggered via a server request ([#&#8203;4653](junegunn/fzf#4653))
  - Fixed rebind of readline command `redraw-current-line` ([#&#8203;4635](junegunn/fzf#4635)) ([@&#8203;jameslazo](https://github.com/jameslazo))
  - Fixed `fzf-tmux` `TERM` quoting and added `mktemp` usage ([#&#8203;4664](junegunn/fzf#4664)) ([@&#8203;Goofygiraffe06](https://github.com/Goofygiraffe06))
  - Do not allow very long queries in `FuzzyMatchV2` ([#&#8203;4608](junegunn/fzf#4608))

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever MR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this MR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this MR, check this box

---

This MR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4yNS44IiwidXBkYXRlZEluVmVyIjoiNDMuMjYuNCIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiUmVub3ZhdGUgQm90IiwiYXV0b21hdGlvbjpib3QtYXV0aG9yZWQiLCJkZXBlbmRlbmN5LXR5cGU6Om1pbm9yIl19-->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement Enhancement to existing feature shell Shell scripts

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants