Repository: sxyazi/yazi (language: rust)

Issue:
Support more vim keybinds
### `yazi --debug` output

```Shell
Yazi
    Version: 0.4.2 (Arch Linux 2024-12-28)
    Debug  : false
    Triple : x86_64-unknown-linux-gnu (linux-x86_64)
    Rustc  : 1.83.0 (90b35a62 2024-11-26)

Ya
    Version: 0.4.2 (Arch Linux 2024-12-28)

Emulator
    Brand.from_env      : Some(Kitty)
    Emulator.detect     : Emulator { kind: Left(Kitty), light: false, cell_size: Some((14, 29)) }
    Emulator.detect_full: Ok(Emulator { kind: Left(Kitty), light: false, cell_size: Some((14, 29)) })

Adapter
    Adapter.matches: Kgp

Desktop
    XDG_SESSION_TYPE           : Some("wayland")
    WAYLAND_DISPLAY            : Some("wayland-1")
    DISPLAY                    : Some(":0")
    SWAYSOCK                   : None
    HYPRLAND_INSTANCE_SIGNATURE: Some("0bd541f2fd902dbfa04c3ea2ccf679395e316887_1735515382_248783120")
    WAYFIRE_SOCKET             : None

SSH
    shared.in_ssh_connection: false

WSL
    WSL: false

NVIM
    NVIM          : false
    Neovim version: 0.10.3

Variables
    SHELL           : Some("/usr/bin/zsh")
    EDITOR          : Some("nvim")
    VISUAL          : None
    YAZI_FILE_ONE   : None
    YAZI_CONFIG_HOME: None
    YAZI_ZOXIDE_OPTS: None
    FZF_DEFAULT_OPTS: None

Text Opener
    default     : Some(Opener { run: "${EDITOR:-vi} \"$@\"", block: true, orphan: false, desc: "$EDITOR", for_: None, spread: true })
    block-create: Some(Opener { run: "${EDITOR:-vi} \"$@\"", block: true, orphan: false, desc: "$EDITOR", for_: None, spread: true })
    block-rename: Some(Opener { run: "${EDITOR:-vi} \"$@\"", block: true, orphan: false, desc: "$EDITOR", for_: None, spread: true })

Multiplexers
    TMUX               : 2
    tmux version       : tmux 3.5a
    tmux build flags   : enable-sixel=Unknown
    ZELLIJ_SESSION_NAME: None
    Zellij version     : No such file or directory (os error 2)

Dependencies
    file          : 5.46
    ueberzugpp    : No such file or directory (os error 2)
    ffmpeg/ffprobe: 7.1 / 7.1
    pdftoppm      : 24.12.0
    magick        : 7.1.1-43
    fzf           : 0.57.0
    fd/fdfind     : 10.2.0 / No such file or directory (os error 2)
    rg            : 14.1.1
    chafa         : No such file or directory (os error 2)
    zoxide        : 0.9.6
    7z/7zz        : 24.09 / No such file or directory (os error 2)
    jq            : 1.7.1

Clipboard
    wl-copy/paste: 2.2.1 / 2.2.1
    xclip        : 0.13
    xsel         : No such file or directory (os error 2)
```


### Please describe the problem you're trying to solve

Missing keybinds, for dialog inputs:

- `r` to replace a single character.
- `_` and `^`  to jump to the first non-whitespace character in the line. 
- `W` to jump to the next WORD start.
- `B` to jump to the previous WORD start.
- `E` to jump to the end of the next WORD end.

'WORD' here meaning a sequence of characters delimited only by whitespace, not punctuation.

### Would you be willing to contribute this feature?

- [X] Yes, I'll give it a shot

### Describe the solution you'd like

Implement these keybinds for dialog inputs.

### Additional context

Similar to #770.

Additionally, `I` jumps to the first character in the line, instead of the first *non-whitespace* character. This is not consistent with vim, so I will open a separate bug report for this.

### Checklist

- [X] I have searched the existing issues/discussions
- [X] The [latest nightly build](https://yazi-rs.github.io/docs/installation/#official-binaries) doesn't already have this feature


Discussion / hints:
Sounds good to me, it would be a great enhancement for the user experience and shouldn't add too much maintenance overhead:

- For `r`, we can add a new `replace` command and introduce a new `Replace` mode here:

  https://github.com/sxyazi/yazi/blob/2770e0259cc83d91b7877b412a27db2dcb1c9427/yazi-core/src/input/mode.rs#L2-L6

- For `_` and `^`, we could add a new parameter `move first-char` to the `move` command:

  https://github.com/sxyazi/yazi/blob/2770e0259cc83d91b7877b412a27db2dcb1c9427/yazi-core/src/input/commands/move_.rs#L8

- For `W` and `E`, we could add a `--big` parameter to the [`forward` command](https://github.com/sxyazi/yazi/blob/2770e0259cc83d91b7877b412a27db2dcb1c9427/yazi-core/src/input/commands/forward.rs#L15) so that it moves only by spaces, not punctuation, i.e.:
  - `W` -> `forward --big`
  - `E` -> `forward --big --end-of-word`

- For `B`, we could add a `--big` parameter to the [`backward` command](https://github.com/sxyazi/yazi/blob/2770e0259cc83d91b7877b412a27db2dcb1c9427/yazi-core/src/input/commands/backward.rs#L6) so that it moves only by spaces, not punctuation

Would you like to raise a PR for it?


Fix patch:
diff --git a/yazi-config/preset/keymap-default.toml b/yazi-config/preset/keymap-default.toml
index bb4b6a470..be9916ea3 100644
--- a/yazi-config/preset/keymap-default.toml
+++ b/yazi-config/preset/keymap-default.toml
@@ -232,12 +232,13 @@ keymap = [
 	{ on = "<C-[>",   run = "escape",         desc = "Go back the normal mode, or cancel input" },
 
 	# Mode
-	{ on = "i", run = "insert",                              desc = "Enter insert mode" },
-	{ on = "a", run = "insert --append",                     desc = "Enter append mode" },
-	{ on = "I", run = [ "move -999", "insert" ],             desc = "Move to the BOL, and enter insert mode" },
-	{ on = "A", run = [ "move 999", "insert --append" ],     desc = "Move to the EOL, and enter append mode" },
-	{ on = "v", run = "visual",                              desc = "Enter visual mode" },
-	{ on = "V", run = [ "move -999", "visual", "move 999" ], desc = "Enter visual mode and select all" },
+	{ on = "i", run = "insert",                             desc = "Enter insert mode" },
+	{ on = "I", run = [ "move first-char", "insert" ],      desc = "Move to the BOL, and enter insert mode" },
+	{ on = "a", run = "insert --append",                    desc = "Enter append mode" },
+	{ on = "A", run = [ "move eol", "insert --append" ],    desc = "Move to the EOL, and enter append mode" },
+	{ on = "v", run = "visual",                             desc = "Enter visual mode" },
+	{ on = "V", run = [ "move bol", "visual", "move eol" ], desc = "Enter visual mode and select all" },
+	{ on = "r", run = "replace",                            desc = "Replace a single character" },
 
 	# Character-wise movement
 	{ on = "h",       run = "move -1", desc = "Move back a character" },
@@ -248,19 +249,24 @@ keymap = [
 	{ on = "<C-f>",   run = "move 1",  desc = "Move forward a character" },
 
 	# Word-wise movement
-	{ on = "b",     run = "backward",              desc = "Move back to the start of the current or previous word" },
-	{ on = "w",     run = "forward",               desc = "Move forward to the start of the next word" },
-	{ on = "e",     run = "forward --end-of-word", desc = "Move forward to the end of the current or next word" },
-	{ on = "<A-b>", run = "backward",              desc = "Move back to the start of the current or previous word" },
-	{ on = "<A-f>", run = "forward --end-of-word", desc = "Move forward to the end of the current or next word" },
+	{ on = "b",     run = "backward",                    desc = "Move back to the start of the current or previous word" },
+	{ on = "B",     run = "backward --far",              desc = "Move back to the start of the current or previous WORD" },
+	{ on = "w",     run = "forward",                     desc = "Move forward to the start of the next word" },
+	{ on = "W",     run = "forward --far",               desc = "Move forward to the start of the next WORD" },
+	{ on = "e",     run = "forward --end-of-word",       desc = "Move forward to the end of the current or next word" },
+	{ on = "E",     run = "forward --far --end-of-word", desc = "Move forward to the end of the current or next WORD" },
+	{ on = "<A-b>", run = "backward",                    desc = "Move back to the start of the current or previous word" },
+	{ on = "<A-f>", run = "forward --end-of-word",       desc = "Move forward to the end of the current or next word" },
 
 	# Line-wise movement
-	{ on = "0",      run = "move -999", desc = "Move to the BOL" },
-	{ on = "$",      run = "move 999",  desc = "Move to the EOL" },
-	{ on = "<C-a>",  run = "move -999", desc = "Move to the BOL" },
-	{ on = "<C-e>",  run = "move 999",  desc = "Move to the EOL" },
-	{ on = "<Home>", run = "move -999", desc = "Move to the BOL" },
-	{ on = "<End>",  run = "move 999",  desc = "Move to the EOL" },
+	{ on = "0",      run = "move bol",        desc = "Move to the BOL" },
+	{ on = "$",      run = "move eol",        desc = "Move to the EOL" },
+	{ on = "_",      run = "move first-char", desc = "Move to the first non-whitespace character" },
+	{ on = "^",      run = "move first-char", desc = "Move to the first non-whitespace character" },
+	{ on = "<C-a>",  run = "move bol",        desc = "Move to the BOL" },
+	{ on = "<C-e>",  run = "move eol",        desc = "Move to the EOL" },
+	{ on = "<Home>", run = "move bol",        desc = "Move to the BOL" },
+	{ on = "<End>",  run = "move eol",        desc = "Move to the EOL" },
 
 	# Delete
 	{ on = "<Backspace>", run = "backspace",         desc = "Delete the character before the cursor" },
@@ -276,9 +282,9 @@ keymap = [
 
 	# Cut/Yank/Paste
 	{ on = "d", run = "delete --cut",                              desc = "Cut the selected characters" },
-	{ on = "D", run = [ "delete --cut", "move 999" ],              desc = "Cut until the EOL" },
+	{ on = "D", run = [ "delete --cut", "move eol" ],              desc = "Cut until the EOL" },
 	{ on = "c", run = "delete --cut --insert",                     desc = "Cut the selected characters, and enter insert mode" },
-	{ on = "C", run = [ "delete --cut --insert", "move 999" ],     desc = "Cut until the EOL, and enter insert mode" },
+	{ on = "C", run = [ "delete --cut --insert", "move eol" ],     desc = "Cut until the EOL, and enter insert mode" },
 	{ on = "x", run = [ "delete --cut", "move 1 --in-operating" ], desc = "Cut the current character" },
 	{ on = "y", run = "yank",                                      desc = "Copy the selected characters" },
 	{ on = "p", run = "paste",                                     desc = "Paste the copied characters after the cursor" },
diff --git a/yazi-core/src/input/commands/backspace.rs b/yazi-core/src/input/commands/backspace.rs
index cc7d91620..c25b557a4 100644
--- a/yazi-core/src/input/commands/backspace.rs
+++ b/yazi-core/src/input/commands/backspace.rs
@@ -17,7 +17,7 @@ impl From<bool> for Opt {
 impl Input {
 	#[yazi_codegen::command]
 	pub fn backspace(&mut self, opt: Opt) {
-		let snap = self.snaps.current_mut();
+		let snap = self.snap_mut();
 		if !opt.under && snap.cursor < 1 {
 			return;
 		} else if opt.under && snap.cursor >= snap.count() {
diff --git a/yazi-core/src/input/commands/backward.rs b/yazi-core/src/input/commands/backward.rs
index 3a69c88bf..35f3d65b3 100644
--- a/yazi-core/src/input/commands/backward.rs
+++ b/yazi-core/src/input/commands/backward.rs
@@ -2,8 +2,17 @@ use yazi_shared::{CharKind, event::CmdCow};
 
 use crate::input::Input;
 
+struct Opt {
+	far: bool,
+}
+
+impl From<CmdCow> for Opt {
+	fn from(c: CmdCow) -> Self { Self { far: c.bool("far") } }
+}
+
 impl Input {
-	pub fn backward(&mut self, _: CmdCow) {
+	#[yazi_codegen::command]
+	pub fn backward(&mut self, opt: Opt) {
 		let snap = self.snap();
 		if snap.cursor == 0 {
 			return self.move_(0);
@@ -13,11 +22,11 @@ impl Input {
 		let mut it = snap.value[..idx].chars().rev().enumerate();
 		let mut prev = CharKind::new(it.next().unwrap().1);
 		for (i, c) in it {
-			let c = CharKind::new(c);
-			if prev != CharKind::Space && prev != c {
+			let k = CharKind::new(c);
+			if prev != CharKind::Space && prev.vary(k, opt.far) {
 				return self.move_(-(i as isize));
 			}
-			prev = c;
+			prev = k;
 		}
 
 		if prev != CharKind::Space {
diff --git a/yazi-core/src/input/commands/complete.rs b/yazi-core/src/input/commands/complete.rs
index 872867af6..065557ab4 100644
--- a/yazi-core/src/input/commands/complete.rs
+++ b/yazi-core/src/input/commands/complete.rs
@@ -39,7 +39,7 @@ impl Input {
 			format!("{}{after}", opt.word).replace(SEPARATOR, MAIN_SEPARATOR_STR)
 		};
 
-		let snap = self.snaps.current_mut();
+		let snap = self.snap_mut();
 		if new == snap.value {
 			return;
 		}
diff --git a/yazi-core/src/input/commands/escape.rs b/yazi-core/src/input/commands/escape.rs
index 6c8cbadbc..72bb062d1 100644
--- a/yazi-core/src/input/commands/escape.rs
+++ b/yazi-core/src/input/commands/escape.rs
@@ -32,6 +32,9 @@ impl Input {
 					CompletionProxy::close();
 				}
 			}
+			InputMode::Replace => {
+				snap.mode = InputMode::Normal;
+			}
 		}
 
 		self.snaps.tag(self.limit());
diff --git a/yazi-core/src/input/commands/forward.rs b/yazi-core/src/input/commands/forward.rs
index f27b25206..97b08e43b 100644
--- a/yazi-core/src/input/commands/forward.rs
+++ b/yazi-core/src/input/commands/forward.rs
@@ -3,11 +3,12 @@ use yazi_shared::{CharKind, event::CmdCow};
 use crate::input::{Input, op::InputOp};
 
 struct Opt {
+	far:         bool,
 	end_of_word: bool,
 }
 
 impl From<CmdCow> for Opt {
-	fn from(c: CmdCow) -> Self { Self { end_of_word: c.bool("end-of-word") } }
+	fn from(c: CmdCow) -> Self { Self { far: c.bool("far"), end_of_word: c.bool("end-of-word") } }
 }
 
 impl Input {
@@ -21,18 +22,18 @@ impl Input {
 		};
 
 		for (i, c) in it {
-			let c = CharKind::new(c);
+			let k = CharKind::new(c);
 			let b = if opt.end_of_word {
-				prev != CharKind::Space && prev != c && i != 1
+				prev != CharKind::Space && prev.vary(k, opt.far) && i != 1
 			} else {
-				c != CharKind::Space && c != prev
+				k != CharKind::Space && k.vary(prev, opt.far)
 			};
 			if b && !matches!(snap.op, InputOp::None | InputOp::Select(_)) {
 				return self.move_(i as isize);
 			} else if b {
 				return self.move_(if opt.end_of_word { i - 1 } else { i } as isize);
 			}
-			prev = c;
+			prev = k;
 		}
 
 		self.move_(snap.len() as isize)
diff --git a/yazi-core/src/input/commands/mod.rs b/yazi-core/src/input/commands/mod.rs
index 6648217c8..87017d3f2 100644
--- a/yazi-core/src/input/commands/mod.rs
+++ b/yazi-core/src/input/commands/mod.rs
@@ -1,4 +1,1 @@
-yazi_macro::mod_flat!(
-	backspace backward close complete delete escape forward insert kill move_ paste redo
-	show type_ undo visual yank
-);
+yazi_macro::mod_flat!(backspace backward close complete delete escape forward insert kill move_ paste redo replace show type_ undo visual yank);
diff --git a/yazi-core/src/input/commands/move_.rs b/yazi-core/src/input/commands/move_.rs
index 663319365..10cc1e781 100644
--- a/yazi-core/src/input/commands/move_.rs
+++ b/yazi-core/src/input/commands/move_.rs
@@ -1,3 +1,5 @@
+use std::str::FromStr;
+
 use unicode_width::UnicodeWidthStr;
 use yazi_macro::render;
 use yazi_shared::event::{CmdCow, Data};
@@ -5,20 +7,20 @@ use yazi_shared::event::{CmdCow, Data};
 use crate::input::{Input, op::InputOp, snap::InputSnap};
 
 struct Opt {
-	step:         isize,
+	step:         OptStep,
 	in_operating: bool,
 }
 
 impl From<CmdCow> for Opt {
 	fn from(c: CmdCow) -> Self {
 		Self {
-			step:         c.first().and_then(Data::as_isize).unwrap_or(0),
+			step:         c.first().and_then(|d| d.try_into().ok()).unwrap_or_default(),
 			in_operating: c.bool("in-operating"),
 		}
 	}
 }
 impl From<isize> for Opt {
-	fn from(step: isize) -> Self { Self { step, in_operating: false } }
+	fn from(step: isize) -> Self { Self { step: step.into(), in_operating: false } }
 }
 
 impl Input {
@@ -29,14 +31,7 @@ impl Input {
 			return;
 		}
 
-		render!(self.handle_op(
-			if opt.step <= 0 {
-				snap.cursor.saturating_sub(opt.step.unsigned_abs())
-			} else {
-				snap.count().min(snap.cursor + opt.step as usize)
-			},
-			false,
-		));
+		render!(self.handle_op(opt.step.cursor(snap), false));
 
 		let (limit, snap) = (self.limit(), self.snap_mut());
 		if snap.offset > snap.cursor {
@@ -53,3 +48,58 @@ impl Input {
 		}
 	}
 }
+
+// --- Step
+enum OptStep {
+	Offset(isize),
+	Bol,
+	Eol,
+	FirstChar,
+}
+
+impl OptStep {
+	fn cursor(self, snap: &InputSnap) -> usize {
+		match self {
+			Self::Offset(n) if n <= 0 => snap.cursor.saturating_add_signed(n),
+			Self::Offset(n) => snap.count().min(snap.cursor + n as usize),
+			Self::Bol => 0,
+			Self::Eol => snap.count(),
+			Self::FirstChar => {
+				snap.value.chars().enumerate().find(|(_, c)| !c.is_whitespace()).map_or(0, |(i, _)| i)
+			}
+		}
+	}
+}
+
+impl Default for OptStep {
+	fn default() -> Self { 0.into() }
+}
+
+impl FromStr for OptStep {
+	type Err = ();
+
+	fn from_str(s: &str) -> Result<Self, Self::Err> {
+		Ok(match s {
+			"bol" => Self::Bol,
+			"eol" => Self::Eol,
+			"first-char" => Self::FirstChar,
+			s => Self::Offset(s.parse().map_err(|_| ())?),
+		})
+	}
+}
+
+impl From<isize> for OptStep {
+	fn from(value: isize) -> Self { Self::Offset(value) }
+}
+
+impl TryFrom<&Data> for OptStep {
+	type Error = ();
+
+	fn try_from(value: &Data) -> Result<Self, Self::Error> {
+		match value {
+			Data::String(s) => s.parse().map_err(|_| ()),
+			Data::Integer(i) => Ok(Self::from(*i as isize)),
+			_ => Err(()),
+		}
+	}
+}
diff --git a/yazi-core/src/input/commands/replace.rs b/yazi-core/src/input/commands/replace.rs
new file mode 100644
index 000000000..f028ebb20
--- /dev/null
+++ b/yazi-core/src/input/commands/replace.rs
@@ -0,0 +1,32 @@
+use yazi_macro::render;
+use yazi_shared::event::CmdCow;
+
+use crate::input::{Input, InputMode, op::InputOp};
+
+impl Input {
+	#[yazi_codegen::command]
+	pub fn replace(&mut self, _: CmdCow) {
+		let snap = self.snap_mut();
+		if snap.mode == InputMode::Normal {
+			snap.op = InputOp::None;
+			snap.mode = InputMode::Replace;
+			render!();
+		}
+	}
+
+	pub fn replace_str(&mut self, s: &str) {
+		let snap = self.snap_mut();
+		snap.mode = InputMode::Normal;
+
+		let start = snap.idx(snap.cursor).unwrap();
+		let mut it = snap.value[start..].char_indices();
+		match (it.next(), it.next()) {
+			(None, _) => {}
+			(Some(_), None) => snap.value.replace_range(start..snap.len(), s),
+			(Some(_), Some((len, _))) => snap.value.replace_range(start..start + len, s),
+		}
+
+		render!();
+		self.snaps.tag(self.limit()).then(|| self.flush_value());
+	}
+}
diff --git a/yazi-core/src/input/commands/show.rs b/yazi-core/src/input/commands/show.rs
index b3128fe79..ac4e38790 100644
--- a/yazi-core/src/input/commands/show.rs
+++ b/yazi-core/src/input/commands/show.rs
@@ -40,7 +40,7 @@ impl Input {
 
 		// Set cursor after reset
 		if let Some(cursor) = opt.cfg.cursor {
-			self.snaps.current_mut().cursor = cursor;
+			self.snap_mut().cursor = cursor;
 			self.move_(0);
 		}
 
diff --git a/yazi-core/src/input/commands/type_.rs b/yazi-core/src/input/commands/type_.rs
index 724889981..09d596228 100644
--- a/yazi-core/src/input/commands/type_.rs
+++ b/yazi-core/src/input/commands/type_.rs
@@ -1,4 +1,5 @@
 use yazi_config::keymap::Key;
+use yazi_macro::render;
 use yazi_shared::event::CmdCow;
 
 use crate::input::{Input, InputMode};
@@ -11,15 +12,29 @@ impl From<CmdCow> for Opt {
 
 impl Input {
 	pub fn type_(&mut self, key: &Key) -> bool {
-		if self.mode() != InputMode::Insert {
-			return false;
-		}
+		let Some(c) = key.plain() else { return false };
 
-		if let Some(c) = key.plain() {
+		if self.mode() == InputMode::Insert {
 			self.type_str(c.encode_utf8(&mut [0; 4]));
 			return true;
+		} else if self.mode() == InputMode::Replace {
+			self.replace_str(c.encode_utf8(&mut [0; 4]));
+			return true;
 		}
 
 		false
 	}
+
+	pub fn type_str(&mut self, s: &str) {
+		let snap = self.snap_mut();
+		if snap.cursor < 1 {
+			snap.value.insert_str(0, s);
+		} else {
+			snap.value.insert_str(snap.idx(snap.cursor).unwrap(), s);
+		}
+
+		self.move_(s.chars().count() as isize);
+		self.flush_value();
+		render!();
+	}
 }
diff --git a/yazi-core/src/input/input.rs b/yazi-core/src/input/input.rs
index 594864429..27dff3137 100644
--- a/yazi-core/src/input/input.rs
+++ b/yazi-core/src/input/input.rs
@@ -3,7 +3,6 @@ use std::ops::Range;
 use tokio::sync::mpsc::UnboundedSender;
 use unicode_width::UnicodeWidthStr;
 use yazi_config::{INPUT, popup::Position};
-use yazi_macro::render;
 use yazi_plugin::CLIPBOARD;
 use yazi_shared::errors::InputError;
 
@@ -33,22 +32,9 @@ impl Input {
 		self.position.offset.width.saturating_sub(INPUT.border()) as usize
 	}
 
-	pub fn type_str(&mut self, s: &str) {
-		let snap = self.snaps.current_mut();
-		if snap.cursor < 1 {
-			snap.value.insert_str(0, s);
-		} else {
-			snap.value.insert_str(snap.idx(snap.cursor).unwrap(), s);
-		}
-
-		self.move_(s.chars().count() as isize);
-		self.flush_value();
-		render!();
-	}
-
 	pub(super) fn handle_op(&mut self, cursor: usize, include: bool) -> bool {
 		let old = self.snap().clone();
-		let snap = self.snaps.current_mut();
+		let snap = self.snap_mut();
 
 		match snap.op {
 			InputOp::None | InputOp::Select(_) => {
diff --git a/yazi-core/src/input/mode.rs b/yazi-core/src/input/mode.rs
index 4668143ba..67fa7458a 100644
--- a/yazi-core/src/input/mode.rs
+++ b/yazi-core/src/input/mode.rs
@@ -3,6 +3,7 @@ pub enum InputMode {
 	Normal,
 	#[default]
 	Insert,
+	Replace,
 }
 
 impl InputMode {
diff --git a/yazi-fm/src/app/app.rs b/yazi-fm/src/app/app.rs
index 1d08e5aa0..2a8fb1062 100644
--- a/yazi-fm/src/app/app.rs
+++ b/yazi-fm/src/app/app.rs
@@ -92,6 +92,8 @@ impl App {
 			let input = &mut self.cx.input;
 			if input.mode() == InputMode::Insert {
 				input.type_str(&str);
+			} else if input.mode() == InputMode::Replace {
+				input.replace_str(&str);
 			}
 		}
 	}
diff --git a/yazi-fm/src/executor.rs b/yazi-fm/src/executor.rs
index 41a34f6b6..769a44093 100644
--- a/yazi-fm/src/executor.rs
+++ b/yazi-fm/src/executor.rs
@@ -259,6 +259,7 @@ impl<'a> Executor<'a> {
 			InputMode::Normal => {
 				on!(insert);
 				on!(visual);
+				on!(replace);
 
 				on!(delete);
 				on!(yank);
@@ -279,6 +280,7 @@ impl<'a> Executor<'a> {
 				on!(backspace);
 				on!(kill);
 			}
+			InputMode::Replace => {}
 		}
 	}
 
diff --git a/yazi-fm/src/input/input.rs b/yazi-fm/src/input/input.rs
index eb17a3b15..1174e7abf 100644
--- a/yazi-fm/src/input/input.rs
+++ b/yazi-fm/src/input/input.rs
@@ -59,6 +59,7 @@ impl Widget for Input<'_> {
 
 		_ = match input.mode() {
 			InputMode::Insert => Term::set_cursor_bar(),
+			InputMode::Replace => Term::set_cursor_underscore(),
 			_ => Term::set_cursor_block(),
 		};
 	}
diff --git a/yazi-fm/src/term.rs b/yazi-fm/src/term.rs
index 8598ad619..b50508556 100644
--- a/yazi-fm/src/term.rs
+++ b/yazi-fm/src/term.rs
@@ -159,6 +159,16 @@ impl Term {
 			queue!(stderr(), SetCursorStyle::SteadyBar)?
 		})
 	}
+
+	#[inline]
+	pub(super) fn set_cursor_underscore() -> Result<()> {
+		use crossterm::cursor::SetCursorStyle;
+		Ok(if INPUT.cursor_blink {
+			queue!(stderr(), SetCursorStyle::BlinkingUnderScore)?
+		} else {
+			queue!(stderr(), SetCursorStyle::SteadyUnderScore)?
+		})
+	}
 }
 
 impl Drop for Term {
diff --git a/yazi-shared/src/chars.rs b/yazi-shared/src/chars.rs
index 491389c21..e234b03cc 100644
--- a/yazi-shared/src/chars.rs
+++ b/yazi-shared/src/chars.rs
@@ -20,6 +20,10 @@ impl CharKind {
 			Self::Other
 		}
 	}
+
+	pub fn vary(self, other: Self, far: bool) -> bool {
+		if far { (self == Self::Space) != (other == Self::Space) } else { self != other }
+	}
 }
 
 pub fn strip_trailing_newline(mut s: String) -> String {


Test patch: