Vi mode: hack in support for df and friends

Commit 38e633d49b (fish_vi_key_bindings: add support for count,
2025-12-16) introduced an operator mode which kind of makes a lot of
sense for today's fish.  If we end up needing more flexibility and
tighter integration, we might want to move some of this into core.

Unfortunately the change is at odds with our cursed forward-jump
implementation.  The forward-jump special input function works by
magically reading the next key from stdin, which causes problems when
we are executing a script:

	commandline -f begin-selection
	commandline -f forward-jump
	commandline -f end-selection

here end-selection will be executed immediately
and forward-jump fails to wait for a keystroke.

We should get rid of forward-jump implementation.

For now, replace only the broken thing with a dedicated bind mode
for each of f/F/t/T.

Fixes #12417
This commit is contained in:
Johannes Altmanninger
2026-02-06 13:17:36 +11:00
parent 6f895935a9
commit d25965afba
10 changed files with 138 additions and 38 deletions

View File

@@ -401,6 +401,13 @@ The following special input functions are available:
``self-insert-notfirst``
inserts the matching sequence into the command line, unless the cursor is at the beginning
``get-key``
sets :envvar:`fish_key` to the key that was pressed to trigger this binding. Example use::
for i in (seq 0 9)
bind $i get-key 'commandline -i "#$fish_key"' 'set -eg fish_key'
end
``suppress-autosuggestion``
remove the current autosuggestion. Returns true if there was a suggestion to remove.

View File

@@ -55,7 +55,7 @@ Example
case visual
set_color --bold brmagenta
echo 'V'
case operator
case operator f F t T
set_color --bold cyan
echo 'N'
case '*'

View File

@@ -18,7 +18,7 @@ function fish_default_mode_prompt --description "Display vi prompt mode"
case visual
set_color --bold magenta
echo '[V]'
case operator
case operator f F t T
set_color --bold cyan
echo '[N]'
end

View File

@@ -53,7 +53,7 @@ function fish_vi_yank_selection
end
function fish_vi_exec_motion
argparse linewise -- $argv
argparse --stop-nonopt linewise -- $argv
or return
set -l motion $argv
@@ -83,7 +83,7 @@ function fish_vi_exec_motion
else
set -l use_selection true
set -l swap_case_hack
switch $motion
switch $motion[1]
case forward-word-vi forward-bigword-vi
if test $__fish_vi_operator = swap-case
set swap_case_hack (string replace -r -- '^forward-((?:big)?word)-vi$' '$1' $motion)
@@ -93,50 +93,62 @@ function fish_vi_exec_motion
set motion (string replace -- forward kill $motion)
end
end
switch $motion[1]
case commandline
case '*'
set motion commandline -f $motion
end
if $use_selection
commandline -f begin-selection
else
commandline -f begin-undo-group
end
set -l ok true
switch $__fish_vi_operator
case delete
for i in (seq $total)
commandline -f $motion
$motion || { set ok false; break }
end
if $use_selection
if $ok && $use_selection
commandline -f kill-selection
end
case change
for i in (seq $total)
commandline -f $motion
$motion || { set ok false; break }
end
if $use_selection
commandline -f kill-selection
if $ok
if $use_selection
commandline -f kill-selection
end
set fish_bind_mode insert
end
set fish_bind_mode insert
case yank
for i in (seq $total)
commandline -f $motion
$motion || { set ok false; break }
end
if $use_selection
fish_vi_yank_selection
else
commandline -f yank
if $ok
if $use_selection
fish_vi_yank_selection
else
commandline -f yank
end
end
case swap-case
for i in (seq $total)
commandline -f $motion
$motion || { set ok false; break }
end
if set -q swap_case_hack[1]
set -l word $swap_case_hack
commandline -f \
backward-$word \
forward-$word-end \
togglecase-selection \
backward-$word \
forward-$word-vi
else
commandline -f togglecase-selection
if $ok
if set -q swap_case_hack[1]
set -l word $swap_case_hack
commandline -f \
backward-$word \
forward-$word-end \
togglecase-selection \
backward-$word \
forward-$word-vi
else
commandline -f togglecase-selection
end
end
end
if $use_selection
@@ -400,10 +412,16 @@ function fish_vi_key_bindings --description 'vi-like key bindings for fish'
bind --preset -M operator \^ 'fish_vi_exec_motion beginning-of-line'
bind --preset -M operator \$ 'fish_vi_exec_motion end-of-line'
bind --preset -M operator f 'fish_vi_exec_motion forward-jump'
bind --preset -M operator F 'fish_vi_exec_motion backward-jump'
bind --preset -M operator t 'fish_vi_exec_motion forward-jump-till'
bind --preset -M operator T 'fish_vi_exec_motion backward-jump-till'
bind --preset -M operator f --sets-mode f ''
bind --preset -M operator F --sets-mode F ''
bind --preset -M operator t --sets-mode t ''
bind --preset -M operator T --sets-mode T ''
bind --preset -M f '' get-key 'fish_vi_exec_motion commandline --forward-jump=$fish_key' 'set -eg fish_key'
bind --preset -M F '' get-key 'fish_vi_exec_motion commandline --backward-jump=$fish_key' 'set -eg fish_key'
bind --preset -M t '' get-key 'fish_vi_exec_motion commandline --forward-jump-till=$fish_key' 'set -eg fish_key'
bind --preset -M T '' get-key 'fish_vi_exec_motion commandline --backward-jump-till=$fish_key' 'set -eg fish_key'
bind --preset -M operator ';' 'fish_vi_exec_motion repeat-jump'
bind --preset -M operator , 'fish_vi_exec_motion repeat-jump-reverse'

View File

@@ -86,7 +86,7 @@ function fish_prompt
switch $fish_bind_mode
case default
set mode (set_color --bold red)N
case operator
case operator f F t T
set mode (set_color --bold cyan)N
case insert
set mode (set_color --bold green)I

View File

@@ -15,8 +15,9 @@
use crate::prelude::*;
use crate::proc::is_interactive_session;
use crate::reader::{
commandline_get_state, commandline_set_buffer, commandline_set_search_field,
reader_execute_readline_cmd, reader_showing_suggestion,
JumpDirection, JumpPrecision, commandline_get_state, commandline_set_buffer,
commandline_set_search_field, reader_execute_readline_cmd, reader_jump,
reader_showing_suggestion,
};
use crate::tokenizer::{TOK_ACCEPT_UNFINISHED, TokenType, Tokenizer};
use fish_wcstringutil::join_strings;
@@ -262,6 +263,11 @@ pub fn commandline(parser: &Parser, streams: &mut IoStreams, args: &mut [&wstr])
let mut showing_suggestion = false;
let mut override_buffer = None;
let mut forward_jump = false;
let mut backward_jump = false;
let mut forward_jump_till = false;
let mut backward_jump_till = false;
let mut jump_target = None;
let short_options = L!("abijpctfxorhI:CBELSsP");
let long_options: &[WOption] = &[
@@ -292,6 +298,10 @@ pub fn commandline(parser: &Parser, streams: &mut IoStreams, args: &mut [&wstr])
wopt(L!("search-field"), ArgType::NoArgument, '\x03'),
wopt(L!("is-valid"), ArgType::NoArgument, '\x01'),
wopt(L!("showing-suggestion"), ArgType::NoArgument, '\x04'),
wopt(L!("forward-jump"), ArgType::RequiredArgument, '\x07'),
wopt(L!("backward-jump"), ArgType::RequiredArgument, '\x08'),
wopt(L!("forward-jump-till"), ArgType::RequiredArgument, '\x09'),
wopt(L!("backward-jump-till"), ArgType::RequiredArgument, '\x0a'),
];
let mut w = WGetopter::new(short_options, long_options, args);
@@ -341,6 +351,22 @@ pub fn commandline(parser: &Parser, streams: &mut IoStreams, args: &mut [&wstr])
'\x03' => search_field_mode = true,
'\x01' => is_valid = true,
'\x04' => showing_suggestion = true,
'\x07' => {
forward_jump = true;
jump_target = Some(w.woptarg.unwrap().to_owned());
}
'\x08' => {
backward_jump = true;
jump_target = Some(w.woptarg.unwrap().to_owned());
}
'\x09' => {
forward_jump_till = true;
jump_target = Some(w.woptarg.unwrap().to_owned());
}
'\x0a' => {
backward_jump_till = true;
jump_target = Some(w.woptarg.unwrap().to_owned());
}
'h' => {
builtin_print_help(parser, streams, cmd);
return Ok(SUCCESS);
@@ -360,6 +386,27 @@ pub fn commandline(parser: &Parser, streams: &mut IoStreams, args: &mut [&wstr])
_ => panic!(),
}
}
if forward_jump || forward_jump_till || backward_jump || backward_jump_till {
let direction = if forward_jump || forward_jump_till {
JumpDirection::Forward
} else {
JumpDirection::Backward
};
let precision = if forward_jump || backward_jump {
JumpPrecision::To
} else {
JumpPrecision::Till
};
let target = jump_target.unwrap();
let Some(target) = target.chars().next() else {
return Err(STATUS_INVALID_ARGS);
};
return if reader_jump(direction, precision, target) {
Ok(SUCCESS)
} else {
Err(STATUS_CMD_ERROR)
};
}
let positional_args = w.argv.len() - w.wopt_index;

View File

@@ -162,6 +162,7 @@ const fn make_md(name: &'static wstr, code: ReadlineCmd) -> InputFunctionMetadat
make_md(L!("forward-word"), ReadlineCmd::ForwardWordEmacs),
make_md(L!("forward-word-end"), ReadlineCmd::ForwardWordEnd),
make_md(L!("forward-word-vi"), ReadlineCmd::ForwardWordVi),
make_md(L!("get-key"), ReadlineCmd::GetKey),
make_md(L!("history-delete"), ReadlineCmd::HistoryDelete),
make_md(L!("history-last-token-search-backward"), ReadlineCmd::HistoryLastTokenSearchBackward),
make_md(L!("history-last-token-search-forward"), ReadlineCmd::HistoryLastTokenSearchForward),
@@ -698,7 +699,9 @@ pub fn read_char(&mut self) -> CharEvent {
let evt = self.readch();
match evt {
CharEvent::Readline(ref readline_event) => match readline_event.cmd {
ReadlineCmd::SelfInsert | ReadlineCmd::SelfInsertNotFirst => {
ReadlineCmd::SelfInsert
| ReadlineCmd::SelfInsertNotFirst
| ReadlineCmd::GetKey => {
// Typically self-insert is generated by the generic (empty) binding.
// However if it is generated by a real sequence, then insert that sequence.
let seq = readline_event.seq.chars().map(CharEvent::from_char);
@@ -721,6 +724,20 @@ pub fn read_char(&mut self) -> CharEvent {
kevt.input_style = CharInputStyle::NotFirst;
}
}
if readline_event.cmd == ReadlineCmd::GetKey {
if let CharEvent::Key(kevt) = res {
return CharEvent::Command(sprintf!(
"set -g fish_key %s",
escape(
&kevt
.key
.codepoint_text()
.map(|c| WString::from_chars(vec![c]))
.unwrap_or_default()
)
));
}
}
return res;
}
ReadlineCmd::FuncAnd | ReadlineCmd::FuncOr => {

View File

@@ -107,6 +107,7 @@ pub enum ReadlineCmd {
HistoryLastTokenSearchForward,
SelfInsert,
SelfInsertNotFirst,
GetKey,
TransposeChars,
TransposeWords,
UpcaseWord,

View File

@@ -546,13 +546,13 @@ enum Kill {
}
#[derive(Clone, Copy, Eq, PartialEq)]
enum JumpDirection {
pub enum JumpDirection {
Forward,
Backward,
}
#[derive(Clone, Copy, Eq, PartialEq)]
enum JumpPrecision {
pub enum JumpPrecision {
Till,
To,
}
@@ -1175,6 +1175,15 @@ pub fn reader_execute_readline_cmd(parser: &Parser, ch: CharEvent) {
let _ = data.handle_char_event(Some(ch));
}
pub fn reader_jump(direction: JumpDirection, precision: JumpPrecision, target: char) -> bool {
let Some(data) = current_data() else {
return false;
};
data.save_screen_state();
let elt = data.active_edit_line_tag();
data.jump_and_remember_last_jump(direction, precision, elt, target, false)
}
pub fn reader_showing_suggestion(parser: &Parser) -> bool {
if !is_interactive_session() {
return false;
@@ -4346,7 +4355,7 @@ fn handle_readline_command(&mut self, c: ReadlineCmd) {
rl::ScrollbackPush => {
self.screen.push_to_scrollback();
}
rl::SelfInsert | rl::SelfInsertNotFirst | rl::FuncAnd | rl::FuncOr => {
rl::SelfInsert | rl::SelfInsertNotFirst | rl::GetKey | rl::FuncAnd | rl::FuncOr => {
// This can be reached via `commandline -f and` etc
// panic!("should have been handled by inputter_t::readch");
}
@@ -6220,6 +6229,7 @@ fn command_ends_paging(c: ReadlineCmd, focused_on_search_field: bool) -> bool {
| rl::BackwardKillBigword
| rl::BackwardKillToken
| rl::SelfInsert
| rl::GetKey
| rl::SelfInsertNotFirst
| rl::TransposeChars
| rl::TransposeWords

View File

@@ -321,7 +321,7 @@ expect_prompt("echo two three")
# Now test that exactly the expected bind modes are defined
sendline("bind --list-modes")
expect_prompt(
"default\r\ninsert\r\noperator\r\nreplace\r\nreplace_one\r\nvisual\r\n",
"F\r\nT\r\ndefault\r\nf\r\ninsert\r\noperator\r\nreplace\r\nreplace_one\r\nt\r\nvisual\r\n",
unmatched="Unexpected vi bind modes",
)