From ada58e881896571dee64c65543aed2e44d3d80ea Mon Sep 17 00:00:00 2001 From: rootiest Date: Thu, 30 Apr 2026 01:03:13 -0400 Subject: [PATCH] feat(shell): add bash-style history expansions and interactive keybindings - Replicate bash bang-operations (!^, !*, !string, etc.) via abbreviations - Add Ctrl+G for previous path head insertion - Add Ctrl+F for interactive history substitution - Add Ctrl+Alt+U to quickly replace command tokens - Update README documentation for all new features --- README.md | 12 +++++ conf.d/abbr.fish | 17 ++++++- conf.d/fish_user_key_bindings.fish | 55 ++++++++++++++++++++++ functions/__insert_previous_path_head.fish | 11 +++++ functions/__interactive_history_sub.fish | 17 +++++++ functions/__substitute_typo.fish | 21 +++++++++ functions/expand_bang_all.fish | 11 +++++ functions/expand_bang_caret.fish | 8 ++++ functions/expand_bang_minus_n.fish | 17 +++++++ functions/expand_bang_search.fish | 21 +++++++++ functions/expand_bang_string.fish | 23 +++++++++ functions/expand_typo_sub.fish | 23 +++++++++ 12 files changed, 234 insertions(+), 2 deletions(-) create mode 100644 conf.d/fish_user_key_bindings.fish create mode 100644 functions/__insert_previous_path_head.fish create mode 100644 functions/__interactive_history_sub.fish create mode 100644 functions/__substitute_typo.fish create mode 100644 functions/expand_bang_all.fish create mode 100644 functions/expand_bang_caret.fish create mode 100644 functions/expand_bang_minus_n.fish create mode 100644 functions/expand_bang_search.fish create mode 100644 functions/expand_bang_string.fish create mode 100644 functions/expand_typo_sub.fish diff --git a/README.md b/README.md index 512c69e..06e2fc7 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,18 @@ Full tab completion for the `tailscale` CLI is provided via `conf.d/tailscale.fi --- +## Key Bindings + +Beyond standard shell and FZF bindings, these custom interactive shortcuts are available: + +| Binding | Action | Description | +|---|---|---| +| `Ctrl+G` | Previous Path Head | Behaves like `!$:h` in Bash. Inserts the directory part of the previous command's last argument. | +| `Ctrl+F` | Interactive History Sub | Behaves like `!!:s/old/new/` in Bash. Performs substitution on the previous command using `old/new` syntax. | +| `Ctrl+Alt+U` | Replace Command Token | Strips the first token (the command) from the current line. **If the line is empty**, it pulls the previous command and strips its first token, placing the cursor at the start for a quick replacement (e.g., changing `mkdir` to `cd` while keeping the paths). | + +--- + ## Functions ### Modern CLI Replacements diff --git a/conf.d/abbr.fish b/conf.d/abbr.fish index e05cb9e..a20ed32 100644 --- a/conf.d/abbr.fish +++ b/conf.d/abbr.fish @@ -1,7 +1,12 @@ # Copyright (C) 2026 Rootiest # SPDX-License-Identifier: AGPL-3.0-or-later - -### Abreviations ### +# +# ╭──────────────────────────────────────────────────────────╮ +# │ Abreviations │ +# ╰──────────────────────────────────────────────────────────╯ +# +# This file contains all the abbreviations for the terminal. +# It is sourced by Fish on startup. # Neovim abbr -a n nvim @@ -232,3 +237,11 @@ abbr -a scr 'systemctl restart' abbr -a ssct 'sudo systemctl status' abbr -a sscs 'sudo systemctl start' abbr -a sscr 'sudo systemctl restart' + +### History Expansions and Substitutions ### +abbr -a !^ --position anywhere --function expand_bang_caret +abbr -a '!*' --position anywhere --function expand_bang_all +abbr -a typo_sub --position anywhere --regex '\^([^^]+)\^([^^]*)' --function expand_typo_sub +abbr -a bang_string --position anywhere --regex '![\w.-]+' --function expand_bang_string +abbr -a bang_search --position anywhere --regex '!\?[\w.-]+\??' --function expand_bang_search +abbr -a bang_minus_n --position anywhere --regex '!-(\d+)' --function expand_bang_minus_n diff --git a/conf.d/fish_user_key_bindings.fish b/conf.d/fish_user_key_bindings.fish new file mode 100644 index 0000000..ada6578 --- /dev/null +++ b/conf.d/fish_user_key_bindings.fish @@ -0,0 +1,55 @@ +# Copyright (C) 2026 Rootiest +# SPDX-License-Identifier: AGPL-3.0-or-later +# +# ╭──────────────────────────────────────────────────────────╮ +# │ Fish User Key Bindings │ +# ╰──────────────────────────────────────────────────────────╯ +# +# This file defines custom key bindings for the Fish shell. +# It is sourced by Fish on startup. + +# ────────────────── Bind Prewious Path Head to Ctrl+G ───────────────── +# Bindings to insert the previous path head into the command line +# Behaves like `!$:h` does in bash +# +# Example: If the previous command was `cd /usr/local/bin` +# pressing Ctrl+G will insert `/usr/local` into the command line. +# ────────────────────────────────────────────────────────────────────── + +# ──────────── Bind Interactive History Substitution to Ctrl+F ───────── +# Bindings to perform substitution on the previous command in the history +# Behaves like `!!:s/old/new/` does in bash +# +# Example: If the previous command was `echo this is a test` +# typing `this is/that was` and pressing Ctrl+F +# will insert `echo that was a test` into the command line. +# ────────────────────────────────────────────────────────────────────── + +# ────────── Bind Replace First Command Token to Ctrl+Alt+U ──────────── +# Strips the first command token and places cursor at start to retype it +# +# Example: If the current command text is `mkdir new_folder` +# pressing Ctrl+Alt+U will change the command line to ` new_folder` +# with the cursor at the start, allowing you to quickly change the command +# while keeping the arguments intact. +# To produce `cd new_folder` or `rm new_folder` etc. +# If the current command text is empty, the previous command's first token will be used instead. +# ────────────────────────────────────────────────────────────────────── + +function fish_user_key_bindings + + # ───────────────────────────── Set Bindings ───────────────────────────── + # + # Set Emacs mode bindings: + bind ctrl-g __insert_previous_path_head + bind ctrl-f __interactive_history_sub + bind ctrl-alt-u _replace_command_token + + # Set bindings for all Vi modes: + # 'default' is Vi-Command, 'insert' is Vi-Insert, 'visual' is Vi-Visual + for mode in default insert visual + bind --mode $mode ctrl-g __insert_previous_path_head + bind --mode $mode ctrl-f __interactive_history_sub + bind --mode $mode ctrl-alt-u _replace_command_token + end +end diff --git a/functions/__insert_previous_path_head.fish b/functions/__insert_previous_path_head.fish new file mode 100644 index 0000000..88b7f96 --- /dev/null +++ b/functions/__insert_previous_path_head.fish @@ -0,0 +1,11 @@ +function __insert_previous_path_head + # Get the last command tokens + set -l tokens (string split -n " " -- $history[1]) + + # If there are tokens, take the last one and strip the 'tail' + if set -q tokens[-1] + set -l path_head (dirname -- $tokens[-1]) + # Insert it into the current command line + commandline -i -- $path_head + end +end diff --git a/functions/__interactive_history_sub.fish b/functions/__interactive_history_sub.fish new file mode 100644 index 0000000..fed0d64 --- /dev/null +++ b/functions/__interactive_history_sub.fish @@ -0,0 +1,17 @@ +function __interactive_history_sub + set -l current_line (commandline -b) + set -l last_cmd $history[1] + + if string match -qr '(.+)/(.+)' -- "$current_line" + set -l parts (string split '/' -- "$current_line") + set -l old $parts[1] + set -l new $parts[2] + set -l expanded (string replace -a -- "$old" "$new" "$last_cmd") + commandline -r "$expanded" + else + if test -z "$current_line" + commandline -r "sudo $last_cmd" + end + end + commandline -f repaint +end diff --git a/functions/__substitute_typo.fish b/functions/__substitute_typo.fish new file mode 100644 index 0000000..92c5fa5 --- /dev/null +++ b/functions/__substitute_typo.fish @@ -0,0 +1,21 @@ +function __substitute_typo + set -l cursor_pos (commandline -C) + set -l cmd (commandline) + + # Check if the current line matches the ^old^new pattern + if string match -qr '\^([^^]+)\^([^^]*)' -- "$cmd" + set -l last_cmd $history[1] + set -l captured (string match -r '\^([^^]+)\^([^^]*)' -- "$cmd") + set -l old $captured[2] + set -l new $captured[3] + + if test -n "$old" + set -l expanded (string replace -a -- "$old" "$new" "$last_cmd") + commandline -r "$expanded" + # No need to move cursor, it's a whole new line + end + else + # If it's just a normal caret (not part of a pattern), just insert it + commandline -i '^' + end +end diff --git a/functions/expand_bang_all.fish b/functions/expand_bang_all.fish new file mode 100644 index 0000000..f61de30 --- /dev/null +++ b/functions/expand_bang_all.fish @@ -0,0 +1,11 @@ +function expand_bang_all + set -l token $argv[1] + if test -z "$token"; set token (commandline -t); end + + set -l tokens (string split -n " " -- $history[1]) + if test (count $tokens) -gt 1 + echo -- (string join " " -- $tokens[2..-1]) + else + echo -- $token + end +end diff --git a/functions/expand_bang_caret.fish b/functions/expand_bang_caret.fish new file mode 100644 index 0000000..4405bec --- /dev/null +++ b/functions/expand_bang_caret.fish @@ -0,0 +1,8 @@ +function expand_bang_caret + # Split the last history item into a list + set -l tokens (string split -n ' ' -- $history[1]) + # tokens[1] is the command, tokens[2] is the first argument + if set -q tokens[2] + echo -- $tokens[2] + end +end diff --git a/functions/expand_bang_minus_n.fish b/functions/expand_bang_minus_n.fish new file mode 100644 index 0000000..a1b18e9 --- /dev/null +++ b/functions/expand_bang_minus_n.fish @@ -0,0 +1,17 @@ +function expand_bang_minus_n + set -l token $argv[1] + if test -z "$token"; set token (commandline -t); end + + # Extract the number from the regex match + if string match -qr '!-(\d+)' -- "$token" + set -l n (string match -r '!-(\d+)' -- "$token")[2] + + if test (count $history) -ge $n + echo -- $history[$n] + else + echo -- $token + end + else + echo -- $token + end +end diff --git a/functions/expand_bang_search.fish b/functions/expand_bang_search.fish new file mode 100644 index 0000000..96a4f8e --- /dev/null +++ b/functions/expand_bang_search.fish @@ -0,0 +1,21 @@ +function expand_bang_search + set -l token $argv[1] + if test -z "$token" + set token (commandline -t) + end + + # Extract query: looks for text after !? and before an optional ? + set -l query (string match -r '!\?([^?]+)' -- $token)[2] + + if test -n "$query" + # Search history for a match anywhere in the command + set -l match (builtin history search --contains --max=1 -- $query) + + if test -n "$match" + echo -- $match + return + end + end + + echo -- $token +end diff --git a/functions/expand_bang_string.fish b/functions/expand_bang_string.fish new file mode 100644 index 0000000..f506f33 --- /dev/null +++ b/functions/expand_bang_string.fish @@ -0,0 +1,23 @@ +function expand_bang_string + # Fish 4.x passes the matched token as argv[1] + set -l token $argv[1] + if test -z "$token" + set token (commandline -t) + end + + # Remove the '!' to get the search query + set -l query (string sub -s 2 -- $token) + + if test -n "$query" + # Search history for a prefix match + set -l match (builtin history search --prefix --max=1 -- $query) + + if test -n "$match" + echo -- $match + return + end + end + + # If no match or empty query, return the token so it doesn't vanish + echo -- $token +end diff --git a/functions/expand_typo_sub.fish b/functions/expand_typo_sub.fish new file mode 100644 index 0000000..b099c34 --- /dev/null +++ b/functions/expand_typo_sub.fish @@ -0,0 +1,23 @@ +function expand_typo_sub + # In newer Fish, the matched token is often passed as $argv[1] + # if the abbr is set up correctly. We'll fallback to commandline just in case. + set -l last_cmd $history[1] + set -l current_token $argv[1] + if test -z "$current_token" + set current_token (commandline -t) + end + + if string match -qr '\^([^^]+)\^([^^]*)' -- "$current_token" + set -l captured (string match -r '\^([^^]+)\^([^^]*)' -- "$current_token") + set -l old $captured[2] + set -l new $captured[3] + + if test -n "$old" + # Using -- to ensure strings starting with '-' aren't treated as flags + echo -- (string replace -a -- "$old" "$new" "$last_cmd") + end + else + # Return the token itself so it doesn't vanish + echo -- "$current_token" + end +end