Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/next/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
- Experimental pane graphics now support bounded named layers, acknowledged full-RGBA primary-layer direct file frames on audited local terminals, owned BGRA fallback, exact pixel mouse input, and placement-only resize replay.

### Fixed
- Prefix-mode Tab, Shift+Tab, and Escape keybindings work when the host reports those keys as Kitty sequences with associated control text.
- Live handoff now preserves mouse forwarding for running pane applications. (#3000, thanks @xkrogen)
- Unix CLI commands now exit quietly when a downstream pipe closes instead of panicking with exit 101. (#2994)
- The terminal theme now keeps the active Space row fill visible when the Navigate cursor lands on it, in both expanded and collapsed sidebars. (#2987)
Expand Down
49 changes: 49 additions & 0 deletions src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5312,6 +5312,55 @@ last_pane = "prefix+tab"
assert_eq!(app.state.workspaces[1].focused_pane_id(), Some(second_root));
}

#[test]
fn route_client_input_prefix_tab_dispatches_with_kitty_associated_control_text() {
let config: Config = toml::from_str(
r#"
[keys]
next_tab = "prefix+tab"
previous_tab = "prefix+shift+tab"
"#,
)
.unwrap();
let mut app = test_app();
let mut workspace = Workspace::test_new("one");
let second_tab = workspace.test_add_tab(Some("logs"));
app.state.workspaces = vec![workspace];
app.state.active = Some(0);
app.state.selected = 0;
app.state.keybinds.next_tab = config.keybinds().next_tab;
app.state.keybinds.previous_tab = config.keybinds().previous_tab;
app.state.mode = Mode::Terminal;
app.state.switch_workspace_tab(0, 0);

app.route_client_input(vec![0x02]);
app.route_client_input(b"\x1b[9;;9u".to_vec());

assert_eq!(app.state.mode, Mode::Terminal);
assert_eq!(app.state.workspaces[0].active_tab, second_tab);

app.route_client_input(vec![0x02]);
app.route_client_input(b"\x1b[9;2;9u".to_vec());

assert_eq!(app.state.mode, Mode::Terminal);
assert_eq!(app.state.workspaces[0].active_tab, 0);
}

#[test]
fn route_client_input_prefix_escape_is_not_dropped_with_kitty_associated_control_text() {
let mut app = test_app();
app.state.workspaces = vec![Workspace::test_new("test")];
app.state.active = Some(0);
app.state.selected = 0;
app.state.mode = Mode::Terminal;

app.route_client_input(vec![0x02]);
assert_eq!(app.state.mode, Mode::Prefix);

app.route_client_input(b"\x1b[27;;27u".to_vec());
assert_eq!(app.state.mode, Mode::Terminal);
}

#[tokio::test]
async fn route_client_input_double_prefix_passes_prefix_through_to_focused_pane() {
let mut app = test_app();
Expand Down
50 changes: 43 additions & 7 deletions src/input/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ fn parse_kitty_key_sequence(data: &str) -> Option<TerminalKey> {
.filter(|field| !field.is_empty())
.unwrap_or("1");
let associated_text = match fields.next() {
Some(value) => Some(parse_kitty_associated_text(value)?),
Some(value) => parse_kitty_associated_text(value)?,
None => None,
};
if fields.next().is_some() {
Expand Down Expand Up @@ -56,16 +56,23 @@ fn parse_kitty_key_sequence(data: &str) -> Option<TerminalKey> {
Some(key.with_generated_text(associated_text))
}

fn parse_kitty_associated_text(value: &str) -> Option<String> {
fn parse_kitty_associated_text(value: &str) -> Option<Option<String>> {
let mut text = String::new();
let mut saw_control = false;
for codepoint in value.split(':') {
let ch = char::from_u32(codepoint.parse::<u32>().ok()?)?;
if ch.is_control() {
return None;
saw_control = true;
} else {
text.push(ch);
}
text.push(ch);
}
(!text.is_empty()).then_some(text)

if saw_control && !text.is_empty() {
None
} else {
Some((!text.is_empty()).then_some(text))
}
}

#[allow(dead_code)] // Reserved for the upcoming raw stdin parser.
Expand Down Expand Up @@ -700,8 +707,37 @@ mod tests {
fn reject_malformed_kitty_associated_text() {
assert_eq!(parse_terminal_key_sequence("\x1b[32;;1114112u"), None);
assert_eq!(parse_terminal_key_sequence("\x1b[32;;20320:bad:u"), None);
assert_eq!(parse_terminal_key_sequence("\x1b[32;;27u"), None);
assert_eq!(parse_terminal_key_sequence("\x1b[32;;133u"), None);
assert_eq!(parse_terminal_key_sequence("\x1b[32;;9:20320u"), None);
}

#[test]
fn parse_kitty_control_associated_text_keeps_the_key() {
let tab = parse_terminal_key_sequence("\x1b[9;;9u").unwrap();
assert_eq!(tab.code, KeyCode::Tab);
assert_eq!(tab.modifiers, KeyModifiers::empty());
assert_eq!(tab.generated_text, None);

let shift_tab = parse_terminal_key_sequence("\x1b[9;2;9u").unwrap();
assert_eq!(shift_tab.code, KeyCode::Tab);
assert_eq!(shift_tab.modifiers, KeyModifiers::SHIFT);
assert_eq!(shift_tab.generated_text, None);

let esc = parse_terminal_key_sequence("\x1b[27;;27u").unwrap();
assert_eq!(esc.code, KeyCode::Esc);
assert_eq!(esc.modifiers, KeyModifiers::empty());
assert_eq!(esc.generated_text, None);

let enter = parse_terminal_key_sequence("\x1b[13;;13u").unwrap();
assert_eq!(enter.code, KeyCode::Enter);
assert_eq!(enter.generated_text, None);

let space_with_esc_text = parse_terminal_key_sequence("\x1b[32;;27u").unwrap();
assert_eq!(space_with_esc_text.code, KeyCode::Char(' '));
assert_eq!(space_with_esc_text.generated_text, None);

let space_with_nel_text = parse_terminal_key_sequence("\x1b[32;;133u").unwrap();
assert_eq!(space_with_nel_text.code, KeyCode::Char(' '));
assert_eq!(space_with_nel_text.generated_text, None);
}

#[test]
Expand Down
Loading