Compare commits

...

4 Commits

Author SHA1 Message Date
Johannes Altmanninger
47a3757f73 update changelog 2026-04-14 16:56:43 +08:00
Johannes Altmanninger
f278c29733 key: address "non_upper_case_globals" lint on named key constants 2026-04-14 16:56:43 +08:00
Peter Ammon
2bab8b5830 prompt_pwd: strip control characters
If a directory has a control sequence in it, then prompt_pwd (used in
the default prompt) would emit it to the console, which could cause
the terminal to interpret the escape sequence.

Strip control sequences from within prompt_pwd, in the same way as
we do in __fish_paste.fish, to sanitize it.

Closes #12629
2026-04-14 16:56:43 +08:00
Johannes Altmanninger
1dac221684 doc terminal_compatibility: tab title is OSC 1 2026-04-14 16:56:43 +08:00
8 changed files with 116 additions and 107 deletions

View File

@@ -9,6 +9,8 @@ Deprecations and removed features
Interactive improvements
------------------------
- :doc:`prompt_pwd <cmds/prompt_pwd>` now strips control characters.
- Background color and underline color specified in :envvar:`fish_color_valid_path` are now respected (:issue:`12622`).
Improved terminal support
-------------------------
@@ -16,14 +18,16 @@ Improved terminal support
Other improvements
------------------
- History is no longer corrupted with NUL bytes when fish receives SIGTERM or SIGHUP (:issue:`10300`).
- ``fish_update_completions`` now handles groff ``\X'...'`` device control escapes, fixing completion generation for man pages produced by help2man 1.50 and later (such as coreutils 9.10).
- :doc:`fish_update_completions <cmds/fish_update_completions>` now handles groff ``\X'...'`` device control escapes, fixing completion generation for man pages produced by help2man 1.50 and later (such as coreutils 9.10).
For distributors and developers
-------------------------------
- When the default global config directory (``$PREFIX/etc/fish``) exists but has been overridden with ``-DCMAKE_INSTALL_SYSCONFDIR``, fish will now respect that override (:issue:`10748`).
- When the default global config directory (``$PREFIX/etc/fish``) exists but has been overridden via ``-DCMAKE_INSTALL_SYSCONFDIR``, fish will now respect that override (:issue:`10748`).
Regression fixes:
-----------------
- Vi mode ``dl`` (:issue:`12461`).
- (from 4.6) Backspace after newline (:issue:`12583`).
fish 4.6.0 (released March 28, 2026)
====================================

View File

@@ -237,7 +237,7 @@ Optional Commands
``\e]0; Pt \e\\``
- ts
- Set terminal window title (OSC 0). Used in :doc:`fish_title <cmds/fish_title>`.
* - ``\e]2; Pt \e\\``
* - ``\e]1; Pt \e\\``
- ts
- Set terminal tab title (OSC 1). Used in :doc:`fish_tab_title <cmds/fish_tab_title>`.
* - ``\e]7;file:// Pt / Pt \e\\``

View File

@@ -26,7 +26,8 @@ function prompt_pwd --description 'short CWD for the prompt'
or set -l fish_prompt_pwd_full_dirs 1
for path in $argv
set -l tmp (__fish_unexpand_tilde $path)
# Strip control characters to avoid injecting terminal escape sequences into the prompt.
set -l tmp (__fish_unexpand_tilde $path | string replace -ra '[[:cntrl:]]' '')
if test "$fish_prompt_pwd_dir_length" -eq 0
echo $tmp

View File

@@ -117,7 +117,7 @@ fn generate_output_string(seq: &[Key], user: bool, bind: &InputMapping) -> WStri
if key.modifiers == Modifiers::ALT {
out.push_utfstr(&char_to_symbol('\x1b', i == 0));
out.push_utfstr(&char_to_symbol(
if key.codepoint == key::Escape {
if key.codepoint == key::ESCAPE {
'\x1b'
} else {
key.codepoint

View File

@@ -361,19 +361,19 @@ pub fn init_input() {
};
add(vec![], "self-insert");
add(vec![Key::from_raw(key::Enter)], "execute");
add(vec![Key::from_raw(key::Tab)], "complete");
add(vec![Key::from_raw(key::ENTER)], "execute");
add(vec![Key::from_raw(key::TAB)], "complete");
add(vec![ctrl('c')], "cancel-commandline");
add(vec![ctrl('d')], "exit");
add(vec![ctrl('e')], "bind");
add(vec![ctrl('s')], "pager-toggle-search");
add(vec![ctrl('u')], "backward-kill-line");
add(vec![Key::from_raw(key::Backspace)], "backward-delete-char");
add(vec![Key::from_raw(key::BACKSPACE)], "backward-delete-char");
// Arrows - can't have functions, so *-or-search isn't available.
add(vec![Key::from_raw(key::Up)], "up-line");
add(vec![Key::from_raw(key::Down)], "down-line");
add(vec![Key::from_raw(key::Right)], "forward-char");
add(vec![Key::from_raw(key::Left)], "backward-char");
add(vec![Key::from_raw(key::UP)], "up-line");
add(vec![Key::from_raw(key::DOWN)], "down-line");
add(vec![Key::from_raw(key::RIGHT)], "forward-char");
add(vec![Key::from_raw(key::LEFT)], "backward-char");
// Emacs style
add(vec![ctrl('p')], "up-line");
add(vec![ctrl('n')], "down-line");
@@ -585,11 +585,11 @@ fn try_peek_sequence(
!seq.is_empty(),
"Empty sequence passed to try_peek_sequence"
);
let mut prev = Key::from_raw(key::Invalid);
let mut prev = Key::from_raw(key::INVALID);
for key in seq {
// If we just read an escape, we need to add a timeout for the next char,
// to distinguish between the actual escape key and an "alt"-modifier.
let escaped = *style != KeyNameStyle::Plain && prev == Key::from_raw(key::Escape);
let escaped = *style != KeyNameStyle::Plain && prev == Key::from_raw(key::ESCAPE);
let Some(spec) = self.next_is_char(style, *key, escaped) else {
return false;
};
@@ -650,7 +650,7 @@ struct MatchedMapping<'a> {
if self.try_peek_sequence(&m.key_name_style, &m.seq, &mut quality) {
// // A binding for just escape should also be deferred
// // so escape sequences take precedence.
let is_escape = m.seq == vec![Key::from_raw(key::Escape)];
let is_escape = m.seq == vec![Key::from_raw(key::ESCAPE)];
let is_perfect_match = quality
.iter()
.all(|key_match| *key_match == KeyMatchQuality::Exact);

View File

@@ -200,13 +200,13 @@ pub(crate) fn codepoint_text(&self) -> Option<char> {
if modifiers.is_some() {
return None;
}
if c == key::Space {
if c == key::SPACE {
return Some(' ');
}
if c == key::Enter {
if c == key::ENTER {
return Some('\n');
}
if c == key::Tab {
if c == key::TAB {
return Some('\t');
}
if fish_is_pua(c) || u32::from(c) <= 27 {
@@ -817,10 +817,10 @@ fn readch(&mut self) -> CharEvent {
continue;
}
let mut seq = WString::new();
if key.is_some_and(|key| key.key == Key::from_raw(key::Invalid)) {
if key.is_some_and(|key| key.key == Key::from_raw(key::INVALID)) {
continue;
}
assert!(key.is_none_or(|key| key.codepoint != key::Invalid));
assert!(key.is_none_or(|key| key.codepoint != key::INVALID));
// At this point, the bytes in `buffer` should be parsed as a UTF-8 sequence,
// or, if they are not valid UTF-8, ignored. On incomplete sequences, another
// byte is read and decoding is tried again in the next iteration.
@@ -953,15 +953,15 @@ fn parse_escape_sequence(
assert!(buffer.len() <= 2);
let recursive_invocation = buffer.len() == 2;
let Some(next) = self.read_sequence_byte(buffer) else {
return Some(KeyEvent::from_raw(key::Escape));
return Some(KeyEvent::from_raw(key::ESCAPE));
};
let invalid = KeyEvent::from_raw(key::Invalid);
let invalid = KeyEvent::from_raw(key::INVALID);
if recursive_invocation && next == b'\x1b' {
return Some(
match self.parse_escape_sequence(buffer, have_escape_prefix) {
Some(mut nested_sequence) => {
if nested_sequence.key == invalid.key {
return Some(KeyEvent::from_raw(key::Escape));
return Some(KeyEvent::from_raw(key::ESCAPE));
}
nested_sequence.modifiers.alt = true;
nested_sequence
@@ -1085,13 +1085,13 @@ fn parse_csi(&mut self, buffer: &mut Vec<u8>) -> Option<KeyEvent> {
_ => return None,
}
}
b'A' => masked_key(key::Up),
b'B' => masked_key(key::Down),
b'C' => masked_key(key::Right),
b'D' => masked_key(key::Left),
b'A' => masked_key(key::UP),
b'B' => masked_key(key::DOWN),
b'C' => masked_key(key::RIGHT),
b'D' => masked_key(key::LEFT),
b'E' => masked_key('5'), // Numeric keypad
b'F' => masked_key(key::End), // PC/xterm style
b'H' => masked_key(key::Home), // PC/xterm style
b'F' => masked_key(key::END), // PC/xterm style
b'H' => masked_key(key::HOME), // PC/xterm style
b'M' | b'm' => {
flog!(reader, "mouse event");
// Generic X10 or modified VT200 sequence, or extended (SGR/1006) mouse
@@ -1169,14 +1169,14 @@ fn parse_csi(&mut self, buffer: &mut Vec<u8>) -> Option<KeyEvent> {
}
b'S' => masked_key(function_key(4)),
b'~' => match params[0][0] {
1 => masked_key(key::Home), // VT220/tmux style
2 => masked_key(key::Insert),
3 => masked_key(key::Delete),
4 => masked_key(key::End), // VT220/tmux style
5 => masked_key(key::PageUp),
6 => masked_key(key::PageDown),
7 => masked_key(key::Home), // rxvt style
8 => masked_key(key::End), // rxvt style
1 => masked_key(key::HOME), // VT220/tmux style
2 => masked_key(key::INSERT),
3 => masked_key(key::DELETE),
4 => masked_key(key::END), // VT220/tmux style
5 => masked_key(key::PAGE_UP),
6 => masked_key(key::PAGE_DOWN),
7 => masked_key(key::HOME), // rxvt style
8 => masked_key(key::END), // rxvt style
11..=15 => masked_key(
char::from_u32(u32::from(function_key(1)) + params[0][0] - 11).unwrap(),
),
@@ -1236,8 +1236,8 @@ fn parse_csi(&mut self, buffer: &mut Vec<u8>) -> Option<KeyEvent> {
// Treat numpad keys the same as their non-numpad counterparts. Could add a numpad modifier here.
let key = match params[0][0] {
57361 => key::PrintScreen,
57363 => key::Menu,
57361 => key::PRINT_SCREEN,
57363 => key::MENU,
57399 => '0',
57400 => '1',
57401 => '2',
@@ -1253,18 +1253,18 @@ fn parse_csi(&mut self, buffer: &mut Vec<u8>) -> Option<KeyEvent> {
57411 => '*',
57412 => '-',
57413 => '+',
57414 => key::Enter,
57414 => key::ENTER,
57415 => '=',
57417 => key::Left,
57418 => key::Right,
57419 => key::Up,
57420 => key::Down,
57421 => key::PageUp,
57422 => key::PageDown,
57423 => key::Home,
57424 => key::End,
57425 => key::Insert,
57426 => key::Delete,
57417 => key::LEFT,
57418 => key::RIGHT,
57419 => key::UP,
57420 => key::DOWN,
57421 => key::PAGE_UP,
57422 => key::PAGE_DOWN,
57423 => key::HOME,
57424 => key::END,
57425 => key::INSERT,
57426 => key::DELETE,
cp => {
let Some(key) = char::from_u32(cp) else {
return invalid_sequence(buffer);
@@ -1284,7 +1284,7 @@ fn parse_csi(&mut self, buffer: &mut Vec<u8>) -> Option<KeyEvent> {
Some(base_layout_key),
)
}
b'Z' => KeyEvent::from(shift(key::Tab)),
b'Z' => KeyEvent::from(shift(key::TAB)),
b'I' => {
self.push_front(CharEvent::Implicit(ImplicitEvent::FocusIn));
return None;
@@ -1310,15 +1310,15 @@ fn parse_ss3(&mut self, buffer: &mut Vec<u8>) -> Option<KeyEvent> {
let (modifiers, _caps_lock) = parse_mask(raw_mask.saturating_sub(1));
#[rustfmt::skip]
let key = match code {
b' ' => KeyEvent::new(modifiers, key::Space),
b'A' => KeyEvent::new(modifiers, key::Up),
b'B' => KeyEvent::new(modifiers, key::Down),
b'C' => KeyEvent::new(modifiers, key::Right),
b'D' => KeyEvent::new(modifiers, key::Left),
b'F' => KeyEvent::new(modifiers, key::End),
b'H' => KeyEvent::new(modifiers, key::Home),
b'I' => KeyEvent::new(modifiers, key::Tab),
b'M' => KeyEvent::new(modifiers, key::Enter),
b' ' => KeyEvent::new(modifiers, key::SPACE),
b'A' => KeyEvent::new(modifiers, key::UP),
b'B' => KeyEvent::new(modifiers, key::DOWN),
b'C' => KeyEvent::new(modifiers, key::RIGHT),
b'D' => KeyEvent::new(modifiers, key::LEFT),
b'F' => KeyEvent::new(modifiers, key::END),
b'H' => KeyEvent::new(modifiers, key::HOME),
b'I' => KeyEvent::new(modifiers, key::TAB),
b'M' => KeyEvent::new(modifiers, key::ENTER),
b'P' => KeyEvent::new(modifiers, function_key(1)),
b'Q' => KeyEvent::new(modifiers, function_key(2)),
b'R' => KeyEvent::new(modifiers, function_key(3)),

View File

@@ -25,25 +25,25 @@ macro_rules! define_special_keys {
}
define_special_keys! {
Backspace: 0
Delete: 1
Escape: 2
Enter: 3
Up: 4
Down: 5
Left: 6
Right: 7
PageUp: 8
PageDown: 9
Home: 10
End: 11
Insert: 12
Tab: 13
Space: 14
Menu: 15
PrintScreen: 16
BACKSPACE: 0
DELETE: 1
ESCAPE: 2
ENTER: 3
UP: 4
DOWN: 5
LEFT: 6
RIGHT: 7
PAGE_UP: 8
PAGE_DOWN: 9
HOME: 10
END: 11
INSERT: 12
TAB: 13
SPACE: 14
MENU: 15
PRINT_SCREEN: 16
Invalid: 255
INVALID: 255
}
pub(crate) const MAX_FUNCTION_KEY: u8 = 12;
@@ -55,23 +55,23 @@ pub(crate) fn function_key(n: u8) -> char {
pub(crate) const KEY_NAMES: &[(char, &wstr)] = &[
('-', L!("minus")),
(',', L!("comma")),
(Backspace, L!("backspace")),
(Delete, L!("delete")),
(Escape, L!("escape")),
(Enter, L!("enter")),
(Up, L!("up")),
(Down, L!("down")),
(Left, L!("left")),
(Right, L!("right")),
(PageUp, L!("pageup")),
(PageDown, L!("pagedown")),
(Home, L!("home")),
(End, L!("end")),
(Insert, L!("insert")),
(Tab, L!("tab")),
(Space, L!("space")),
(Menu, L!("menu")),
(PrintScreen, L!("printscreen")),
(BACKSPACE, L!("backspace")),
(DELETE, L!("delete")),
(ESCAPE, L!("escape")),
(ENTER, L!("enter")),
(UP, L!("up")),
(DOWN, L!("down")),
(LEFT, L!("left")),
(RIGHT, L!("right")),
(PAGE_UP, L!("pageup")),
(PAGE_DOWN, L!("pagedown")),
(HOME, L!("home")),
(END, L!("end")),
(INSERT, L!("insert")),
(TAB, L!("tab")),
(SPACE, L!("space")),
(MENU, L!("menu")),
(PRINT_SCREEN, L!("printscreen")),
];
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
@@ -187,25 +187,25 @@ fn ascii_control(c: char) -> char {
pub(crate) fn canonicalize_keyed_control_char(c: char) -> char {
if c == ascii_control('m') {
return Enter;
return ENTER;
}
if c == ascii_control('i') {
return Tab;
return TAB;
}
if c == ' ' {
return Space;
return SPACE;
}
if let Some(tm) = get_terminal_mode_on_startup() {
if c == char::from(tm.c_cc[VERASE]) {
return Backspace;
return BACKSPACE;
}
}
if c == char::from(127) {
// when it's not backspace
return Delete;
return DELETE;
}
if c == '\x1b' {
return Escape;
return ESCAPE;
}
c
}
@@ -214,7 +214,7 @@ pub(crate) fn canonicalize_unkeyed_control_char(c: u8) -> char {
if c == 0 {
// For legacy terminals we have to make a decision here; they send NUL on Ctrl-2,
// Ctrl-Shift-2 or Ctrl-Backtick, but the most straightforward way is Ctrl-Space.
return Space;
return SPACE;
}
// Represent Ctrl-letter combinations in lower-case, to be clear
// that Shift is not involved.
@@ -350,11 +350,11 @@ pub(crate) fn canonicalize_raw_escapes(keys: Vec<Key>) -> Vec<Key> {
if had_literal_escape {
had_literal_escape = false;
if key.modifiers.alt {
canonical.push(Key::from_raw(Escape));
canonical.push(Key::from_raw(ESCAPE));
} else {
key.modifiers.alt = true;
if key.codepoint == '\x1b' {
key.codepoint = Escape;
key.codepoint = ESCAPE;
}
}
} else if key.codepoint == '\x1b' {
@@ -364,7 +364,7 @@ pub(crate) fn canonicalize_raw_escapes(keys: Vec<Key>) -> Vec<Key> {
canonical.push(key);
}
if had_literal_escape {
canonical.push(Key::from_raw(Escape));
canonical.push(Key::from_raw(ESCAPE));
}
canonical
}
@@ -482,9 +482,9 @@ mod tests {
fn test_parse_key() {
assert_eq!(
parse_keys(L!("escape")),
Ok(vec![Key::from_raw(key::Escape)])
Ok(vec![Key::from_raw(key::ESCAPE)])
);
assert_eq!(parse_keys(L!("\x1b")), Ok(vec![Key::from_raw(key::Escape)]));
assert_eq!(parse_keys(L!("\x1b")), Ok(vec![Key::from_raw(key::ESCAPE)]));
assert_eq!(parse_keys(L!("ctrl-a")), Ok(vec![ctrl('a')]));
assert_eq!(parse_keys(L!("\x01")), Ok(vec![ctrl('a')]));
assert!(parse_keys(L!("f0")).is_err());

View File

@@ -14,3 +14,7 @@ prompt_pwd -D 0 /usr/share/fish/prompts
prompt_pwd -d1 -D 3 /usr/local/share/fish/prompts
# CHECK: /u/l/share/fish/prompts
# Ensure control characters in paths are stripped
prompt_pwd -d 0 /foo/(printf '\e]0;OHNO\a')bar
# CHECK: /foo/]0;OHNObar