diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index dba64077b6..5010eafbda 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -15,7 +15,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 -- Fish `Ctrl+Alt` keybindings now work in panes after legacy Alt-prefixed control bytes are decoded with both modifiers. (#2514) +- Pane key forwarding now uses one state-aware encoder, fixing Fish `Ctrl+Alt` bindings and preserving Kitty alternate and associated text, extended modifiers, repeats and releases, F13–F25, and `modifyOtherKeys` state through handoff. (#2514) - `herdr config check` now reports unknown built-in theme names instead of silently accepting them. (#2452) - macOS `herdr --remote` clients now keep the accepted bridge socket blocking, preventing an immediate disconnect after the protocol handshake. (#2478, thanks @mathijshenquet) - Prefix keybindings now preserve Shift in WezTerm Kitty keyboard mode, so commands such as config reload no longer trigger their unshifted action. (#2435) diff --git a/scripts/test_vendor_libghostty_vt.py b/scripts/test_vendor_libghostty_vt.py index ad5c8d5e2f..b0d6b56b49 100644 --- a/scripts/test_vendor_libghostty_vt.py +++ b/scripts/test_vendor_libghostty_vt.py @@ -1,6 +1,7 @@ from __future__ import annotations import io +import shutil import subprocess import tarfile import tempfile @@ -9,13 +10,33 @@ from unittest import mock from scripts.vendor_libghostty_vt import ( + apply_patch_series, ensure_dist_archive, + load_patch_series, parse_archive_root, require_clean_checkout, + vendor_libghostty_vt, ) class VendorLibghosttyVtTests(unittest.TestCase): + @staticmethod + def make_vendor_fixture(root: Path, marker: str = "keep\n") -> tuple[Path, Path, Path]: + archive = root / "libghostty-vt.tar.gz" + with tarfile.open(archive, "w:gz") as tar: + data = b"upstream\n" + info = tarfile.TarInfo("libghostty-vt-test/value.txt") + info.size = len(data) + tar.addfile(info, io.BytesIO(data)) + + destination = root / "destination" + destination.mkdir() + (destination / "marker.txt").write_text(marker) + patch_dir = root / "patches" + patch_dir.mkdir() + (patch_dir / "series").write_text("") + return archive, destination, patch_dir + def test_parse_archive_root_returns_single_top_level_directory(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: archive = Path(temp_dir) / "libghostty-vt.tar.gz" @@ -72,6 +93,136 @@ def test_ensure_dist_archive_rejects_checkout_dirtied_by_build(self) -> None: with self.assertRaisesRegex(ValueError, "refusing to vendor from dirty checkout"): ensure_dist_archive(repo) + def test_patch_series_is_ordered_and_complete(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + patch_dir = Path(temp_dir) + (patch_dir / "series").write_text("0002-second.patch\n0001-first.patch\n") + (patch_dir / "0001-first.patch").write_text("first") + (patch_dir / "0002-second.patch").write_text("second") + + self.assertEqual( + [path.name for path in load_patch_series(patch_dir)], + ["0002-second.patch", "0001-first.patch"], + ) + + (patch_dir / "0003-unlisted.patch").write_text("unlisted") + with self.assertRaisesRegex(ValueError, "unlisted patch"): + load_patch_series(patch_dir) + + def test_apply_patch_series_replays_patches_in_declared_order(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + project_root = Path(temp_dir) + vendor = project_root / "vendor" / "libghostty-vt" + patch_dir = project_root / "patches" + vendor.mkdir(parents=True) + patch_dir.mkdir() + (vendor / "value.txt").write_text("zero\n") + (patch_dir / "series").write_text("0001-one.patch\n0002-two.patch\n") + (patch_dir / "0001-one.patch").write_text( + "--- a/vendor/libghostty-vt/value.txt\n" + "+++ b/vendor/libghostty-vt/value.txt\n" + "@@ -1 +1 @@\n-zero\n+one\n" + ) + (patch_dir / "0002-two.patch").write_text( + "--- a/vendor/libghostty-vt/value.txt\n" + "+++ b/vendor/libghostty-vt/value.txt\n" + "@@ -1 +1 @@\n-one\n+two\n" + ) + + apply_patch_series(project_root, patch_dir) + + self.assertEqual((vendor / "value.txt").read_text(), "two\n") + + def test_failed_patch_replay_does_not_replace_existing_vendor_tree(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + archive, destination, patch_dir = self.make_vendor_fixture(root) + (patch_dir / "series").write_text("0001-broken.patch\n") + (patch_dir / "0001-broken.patch").write_text("not a patch\n") + + with ( + mock.patch( + "scripts.vendor_libghostty_vt.ensure_dist_archive", + return_value=archive, + ), + mock.patch( + "scripts.vendor_libghostty_vt.git_head", + return_value="0123456789abcdef", + ), + ): + with self.assertRaises(subprocess.CalledProcessError): + vendor_libghostty_vt(root, destination, patch_dir) + + self.assertEqual((destination / "marker.txt").read_text(), "keep\n") + + def test_failed_install_and_rollback_preserve_the_previous_vendor_backup(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + archive, destination, patch_dir = self.make_vendor_fixture(root) + original_rename = Path.rename + + def fail_install_and_rollback(path: Path, target: Path) -> Path: + if path == destination: + return original_rename(path, target) + if path.parent.name.startswith(f".{destination.name}.new-"): + raise OSError("install failed") + if path.parent.name.startswith(f".{destination.name}.old-"): + raise OSError("rollback failed") + return original_rename(path, target) + + with ( + mock.patch( + "scripts.vendor_libghostty_vt.ensure_dist_archive", + return_value=archive, + ), + mock.patch( + "scripts.vendor_libghostty_vt.git_head", + return_value="0123456789abcdef", + ), + mock.patch.object(Path, "rename", autospec=True, side_effect=fail_install_and_rollback), + ): + with self.assertRaisesRegex(RuntimeError, "backup preserved"): + vendor_libghostty_vt(root, destination, patch_dir) + + backups = list(root.glob(f".{destination.name}.old-*/{destination.name}/marker.txt")) + self.assertEqual(len(backups), 1) + self.assertEqual(backups[0].read_text(), "keep\n") + + def test_post_install_backup_cleanup_failure_keeps_vendor_and_returns_success(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + archive, destination, patch_dir = self.make_vendor_fixture(root, "old\n") + original_rmtree = shutil.rmtree + + def fail_backup_cleanup(path: Path, *args: object, **kwargs: object) -> None: + path = Path(path) + if path.name == destination.name and path.parent.name.startswith( + f".{destination.name}.old-" + ): + raise OSError("cleanup failed") + original_rmtree(path, *args, **kwargs) + + with ( + mock.patch( + "scripts.vendor_libghostty_vt.ensure_dist_archive", + return_value=archive, + ), + mock.patch( + "scripts.vendor_libghostty_vt.git_head", + return_value="0123456789abcdef", + ), + mock.patch( + "scripts.vendor_libghostty_vt.shutil.rmtree", + side_effect=fail_backup_cleanup, + ), + ): + vendor_libghostty_vt(root, destination, patch_dir) + + self.assertEqual((destination / "value.txt").read_text(), "upstream\n") + backups = list(root.glob(f".{destination.name}.old-*/{destination.name}/marker.txt")) + self.assertEqual(len(backups), 1) + self.assertEqual(backups[0].read_text(), "old\n") + def test_vendored_tree_contains_required_upstream_files(self) -> None: root = Path(__file__).resolve().parent.parent / "vendor" / "libghostty-vt" required = [ @@ -114,24 +265,38 @@ def test_local_vendor_patches_are_listed_in_patch_index(self) -> None: ] self.assertEqual(missing, []) - def test_local_vendor_patches_are_applied_to_vendored_tree(self) -> None: + def test_patch_series_reconstructs_the_checked_in_vendor_tree(self) -> None: project_root = Path(__file__).resolve().parent.parent patch_dir = project_root / "vendor" / "patches" / "libghostty-vt" + tracked = subprocess.check_output( + ["git", "ls-files", "vendor/libghostty-vt"], + cwd=project_root, + text=True, + ).splitlines() - for patch in sorted(patch_dir.glob("*.patch")): - result = subprocess.run( - ["git", "apply", "--check", "--reverse", str(patch.relative_to(project_root))], - cwd=project_root, - text=True, - capture_output=True, - ) - self.assertEqual( - result.returncode, - 0, - f"{patch.relative_to(project_root)} is not applied cleanly:\n" - f"stdout:\n{result.stdout}\n" - f"stderr:\n{result.stderr}", - ) + with tempfile.TemporaryDirectory() as temp_dir: + replay_root = Path(temp_dir) + for relative in tracked: + source = project_root / relative + target = replay_root / relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) + + for patch in reversed(load_patch_series(patch_dir)): + subprocess.run( + ["git", "apply", "--reverse", str(patch)], + cwd=replay_root, + check=True, + ) + apply_patch_series(replay_root, patch_dir) + + changed = [ + relative + for relative in tracked + if (project_root / relative).read_bytes() + != (replay_root / relative).read_bytes() + ] + self.assertEqual(changed, []) def test_embedded_libghostty_logging_is_silenced(self) -> None: root = Path(__file__).resolve().parent.parent / "vendor" / "libghostty-vt" diff --git a/scripts/vendor_libghostty_vt.py b/scripts/vendor_libghostty_vt.py index 7bd234d44e..645fa1445a 100644 --- a/scripts/vendor_libghostty_vt.py +++ b/scripts/vendor_libghostty_vt.py @@ -4,6 +4,7 @@ import json import shutil import subprocess +import sys import tarfile import tempfile from dataclasses import asdict, dataclass @@ -17,6 +18,42 @@ class VendorMetadata: extracted_dir: str +def load_patch_series(patch_dir: Path) -> list[Path]: + series_path = patch_dir / "series" + if not series_path.exists(): + raise FileNotFoundError(f"missing patch series {series_path}") + + names = [ + line.strip() + for line in series_path.read_text().splitlines() + if line.strip() and not line.lstrip().startswith("#") + ] + if len(names) != len(set(names)): + raise ValueError(f"duplicate patch in {series_path}") + for name in names: + if Path(name).name != name or not name.endswith(".patch"): + raise ValueError(f"invalid patch name in {series_path}: {name}") + + listed = {patch_dir / name for name in names} + available = set(patch_dir.glob("*.patch")) + unlisted = sorted(path.name for path in available - listed) + missing = sorted(path.name for path in listed - available) + if unlisted: + raise ValueError(f"unlisted patch in {patch_dir}: {', '.join(unlisted)}") + if missing: + raise ValueError(f"missing patch in {patch_dir}: {', '.join(missing)}") + return [patch_dir / name for name in names] + + +def apply_patch_series(project_root: Path, patch_dir: Path) -> None: + for patch in load_patch_series(patch_dir): + subprocess.run( + ["git", "apply", "--whitespace=nowarn", str(patch.resolve())], + cwd=project_root, + check=True, + ) + + def parse_archive_root(archive: Path) -> str: with tarfile.open(archive, "r:gz") as tar: roots = { @@ -61,7 +98,11 @@ def ensure_dist_archive(source_repo: Path) -> Path: return archives[-1] -def vendor_libghostty_vt(source_repo: Path, destination: Path) -> VendorMetadata: +def vendor_libghostty_vt( + source_repo: Path, + destination: Path, + patch_dir: Path, +) -> VendorMetadata: archive = ensure_dist_archive(source_repo) root = parse_archive_root(archive) @@ -74,10 +115,53 @@ def vendor_libghostty_vt(source_repo: Path, destination: Path) -> VendorMetadata if not extracted.exists(): raise FileNotFoundError(f"expected extracted root {extracted}") - if destination.exists(): - shutil.rmtree(destination) + staged_project = temp_dir_path / "staged-project" + staged_vendor = staged_project / "vendor" / "libghostty-vt" + staged_vendor.parent.mkdir(parents=True) + shutil.copytree(extracted, staged_vendor) + apply_patch_series(staged_project, patch_dir) + destination.parent.mkdir(parents=True, exist_ok=True) - shutil.copytree(extracted, destination) + replacement_parent = Path( + tempfile.mkdtemp(prefix=f".{destination.name}.new-", dir=destination.parent) + ) + backup_parent = Path( + tempfile.mkdtemp(prefix=f".{destination.name}.old-", dir=destination.parent) + ) + replacement = replacement_parent / destination.name + backup = backup_parent / destination.name + preserve_backup = False + try: + shutil.copytree(staged_vendor, replacement) + if destination.exists(): + destination.rename(backup) + try: + replacement.rename(destination) + except Exception as install_error: + if backup.exists(): + try: + backup.rename(destination) + except Exception as rollback_error: + preserve_backup = True + raise RuntimeError( + f"failed to install vendor tree ({install_error}); rollback failed; " + f"backup preserved at {backup}" + ) from rollback_error + raise + if backup.exists(): + try: + shutil.rmtree(backup) + except OSError as cleanup_error: + preserve_backup = True + print( + f"warning: could not remove previous vendor tree at {backup}: " + f"{cleanup_error}", + file=sys.stderr, + ) + finally: + shutil.rmtree(replacement_parent, ignore_errors=True) + if not preserve_backup: + shutil.rmtree(backup_parent, ignore_errors=True) return VendorMetadata( source_commit=git_head(source_repo), @@ -103,13 +187,19 @@ def main() -> None: default="vendor/libghostty-vt.vendor.json", help="Path to write vendoring metadata JSON", ) + parser.add_argument( + "--patch-dir", + default="vendor/patches/libghostty-vt", + help="Directory containing the ordered local patch series", + ) args = parser.parse_args() repo = Path(args.source_repo).resolve() destination = Path(args.destination).resolve() metadata_path = Path(args.metadata).resolve() + patch_dir = Path(args.patch_dir).resolve() - metadata = vendor_libghostty_vt(repo, destination) + metadata = vendor_libghostty_vt(repo, destination, patch_dir) metadata_path.parent.mkdir(parents=True, exist_ok=True) metadata_path.write_text(json.dumps(asdict(metadata), indent=2) + "\n") diff --git a/scripts/windows_conpty_enhanced_input_probe.ps1 b/scripts/windows_conpty_enhanced_input_probe.ps1 index 1e45252103..4f3146905c 100644 --- a/scripts/windows_conpty_enhanced_input_probe.ps1 +++ b/scripts/windows_conpty_enhanced_input_probe.ps1 @@ -399,9 +399,9 @@ fn main() { $kittyInitialHex = Get-LatestProbeHex -PaneText $report.kitty_initial $report.device_attributes_response = $kittyInitialHex -match "1b5b3f(?:3[0-9]|3b)+63" $report.kitty_query_response = $kittyInitialHex.Contains("1b5b3f3775") - $report.kitty_alt_v = Send-KeyAndObserve -PaneId $kittyPane -Key "alt+v" -ExpectedHex "1b5b3131383b333a3175" - $report.kitty_ctrl_u = Send-KeyAndObserve -PaneId $kittyPane -Key "ctrl+u" -ExpectedHex "1b5b3131373b353a3175" - $report.kitty_ctrl_v = Send-KeyAndObserve -PaneId $kittyPane -Key "ctrl+v" -ExpectedHex "1b5b3131383b353a3175" + $report.kitty_alt_v = Send-KeyAndObserve -PaneId $kittyPane -Key "alt+v" -ExpectedHex "1b5b3131383b3375" + $report.kitty_ctrl_u = Send-KeyAndObserve -PaneId $kittyPane -Key "ctrl+u" -ExpectedHex "1b5b3131373b3575" + $report.kitty_ctrl_v = Send-KeyAndObserve -PaneId $kittyPane -Key "ctrl+v" -ExpectedHex "1b5b3131383b3575" $report.kitty_shift_enter = Send-KeyAndObserve -PaneId $kittyPane -Key "shift+enter" -ExpectedHex "1b5b31333b3275" $report.kitty_ctrl_backspace = Send-KeyAndObserve -PaneId $kittyPane -Key "ctrl+backspace" -ExpectedHex "1b5b3132373b3575" $report.kitty_up = Send-KeyAndObserve -PaneId $kittyPane -Key "up" -ExpectedHex "1b5b313b313a3141" diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index 6615fa0a19..18553a73d9 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -2746,7 +2746,7 @@ mod tests { assert_eq!( rx.try_recv().expect("forwarded press"), - bytes::Bytes::from_static(b"\x1b[106;1:1u") + bytes::Bytes::from_static(b"\x1b[106u") ); assert_eq!( rx.try_recv().expect("forwarded release after pane move"), diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs index 8d8988f9f8..fccfaf0473 100644 --- a/src/app/input/mouse.rs +++ b/src/app/input/mouse.rs @@ -3577,6 +3577,7 @@ mod tests { mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Sgr, mouse_alternate_scroll: true, modify_other_keys: false, + modify_other_keys_mode: None, color_scheme_reporting: false, }; @@ -4254,6 +4255,7 @@ mod tests { mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Default, mouse_alternate_scroll: true, modify_other_keys: false, + modify_other_keys_mode: None, color_scheme_reporting: false, }; @@ -4271,6 +4273,7 @@ mod tests { mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Default, mouse_alternate_scroll: true, modify_other_keys: false, + modify_other_keys_mode: None, color_scheme_reporting: false, }; diff --git a/src/app/mod.rs b/src/app/mod.rs index 15f352af8f..4962082d01 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -3622,7 +3622,7 @@ mod tests { assert_eq!( rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:1u") + bytes::Bytes::from_static(b"\x1b[106u") ); assert_eq!( rx.recv().await.unwrap(), @@ -5305,7 +5305,10 @@ last_pane = "prefix+tab" app.route_client_input(b"\x1b[106;1:1u\x1b[106;1:2u\x1b[106;1:3u".to_vec()); assert_eq!(rx.recv().await.unwrap(), bytes::Bytes::from_static(b"j")); - assert_eq!(rx.recv().await.unwrap(), bytes::Bytes::from_static(b"j")); + assert_eq!( + rx.recv().await.unwrap(), + bytes::Bytes::from_static(b"\x1b[106;1:2u") + ); assert_eq!( rx.recv().await.unwrap(), bytes::Bytes::from_static(b"\x1b[106;1:3u") @@ -5392,7 +5395,7 @@ last_pane = "prefix+tab" assert_eq!( rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:1u") + bytes::Bytes::from_static(b"\x1b[106u") ); assert_eq!( rx.recv().await.unwrap(), @@ -5490,7 +5493,7 @@ last_pane = "prefix+tab" assert_eq!( rx.try_recv().expect("physical press"), - bytes::Bytes::from_static(b"\x1b[106;1:1u") + bytes::Bytes::from_static(b"\x1b[106u") ); assert_eq!( rx.try_recv().expect("committed text"), @@ -5530,7 +5533,7 @@ last_pane = "prefix+tab" assert_eq!( rx.try_recv().expect("forwarded press"), - bytes::Bytes::from_static(b"\x1b[106;1:1u") + bytes::Bytes::from_static(b"\x1b[106u") ); assert_eq!( rx.try_recv().expect("synthetic release on focus loss"), @@ -5565,7 +5568,7 @@ last_pane = "prefix+tab" assert_eq!( rx.try_recv().expect("forwarded press"), - bytes::Bytes::from_static(b"\x1b[106;1:1u") + bytes::Bytes::from_static(b"\x1b[106u") ); assert_eq!( rx.try_recv().expect("synthetic release on disconnect"), @@ -5647,7 +5650,7 @@ last_pane = "prefix+tab" ); for expected in [ - b"\x1b[97;1:1u".as_slice(), + b"\x1b[97u".as_slice(), b"\x1b[97;1:2u".as_slice(), b"\x1b[97;1:2u".as_slice(), b"\x1b[97;1:3u".as_slice(), @@ -5710,7 +5713,7 @@ last_pane = "prefix+tab" assert_eq!( rx.try_recv().expect("grouped press"), - bytes::Bytes::from_static(b"\x1b[97;1:1u\x1b[97;1:2u\x1b[97;1:2u") + bytes::Bytes::from_static(b"\x1b[97u\x1b[97;1:2u\x1b[97;1:2u") ); assert_eq!( rx.try_recv() @@ -5748,7 +5751,7 @@ last_pane = "prefix+tab" assert_eq!( pressed_rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:1u") + bytes::Bytes::from_static(b"\x1b[106u") ); assert_eq!( pressed_rx.recv().await.unwrap(), @@ -5823,7 +5826,7 @@ last_pane = "prefix+tab" for rx in [&mut first_rx, &mut second_rx] { assert_eq!( rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106;1:1u") + bytes::Bytes::from_static(b"\x1b[106u") ); assert_eq!( rx.recv().await.unwrap(), @@ -5850,7 +5853,7 @@ last_pane = "prefix+tab" assert_eq!( rx.recv().await.unwrap(), - bytes::Bytes::from_static(b"\x1b[106:74;2:1u") + bytes::Bytes::from_static(b"\x1b[106:74;2u") ); assert_eq!( rx.recv().await.unwrap(), diff --git a/src/client/input.rs b/src/client/input.rs index 456f59b512..2e7ae8ab8c 100644 --- a/src/client/input.rs +++ b/src/client/input.rs @@ -365,7 +365,7 @@ fn windows_crossterm_reader_loop( Err(_) => break, }; - let raw_sequence_pending = framer.has_pending_input(); + let raw_sequence_pending = framer.requires_raw_continuation(); if let Some(bytes) = windows_key_raw_bytes(&event, raw_sequence_pending) { tracing::debug!( bytes = ?bytes, @@ -521,6 +521,9 @@ fn windows_client_input_event_from_raw( let source = if let Some(bytes) = key.vt_bytes() { crate::protocol::ClientKeySource::Vt { bytes: bytes.to_vec(), + shifted_codepoint: key.shifted_codepoint, + base_layout_codepoint: key.base_layout_codepoint, + text_commit: key.is_text_commit(), } } else if let Some(record) = key.windows_record() { crate::protocol::ClientKeySource::WindowsConsole { record } @@ -748,6 +751,25 @@ mod windows_tests { use super::*; use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; + #[test] + fn windows_oversized_csi_final_is_routed_through_discard_state() { + let mut framer = crate::raw_input::RawInputFramer::default(); + let mut oversized = b"\x1b[".to_vec(); + oversized.extend(std::iter::repeat_n(b'1', 4096)); + + assert!(framer.push(&oversized).is_empty()); + assert!(framer.requires_raw_continuation()); + + let final_key = Event::Key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::empty())); + let final_bytes = windows_key_raw_bytes(&final_key, framer.requires_raw_continuation()) + .expect("discard continuation routes through raw framer"); + assert!(framer.push(&final_bytes).is_empty()); + assert!(!framer.requires_raw_continuation()); + + let following_key = Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::empty())); + assert_eq!(windows_key_raw_bytes(&following_key, false), None); + } + #[test] fn windows_control_chars_are_reframed_as_raw_bytes() { let escape = Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::empty())); @@ -776,18 +798,23 @@ mod windows_tests { #[test] fn windows_crossterm_printable_press_keeps_key_semantics_and_text() { let event = Event::Key(KeyEvent::new(KeyCode::Char('你'), KeyModifiers::empty())); + let event = windows_crossterm_input_event(event).expect("printable key converts"); assert_eq!( - windows_crossterm_input_event(event), - Some(crate::protocol::ClientInputEvent::Key { + event, + crate::protocol::ClientInputEvent::Key { code: crate::protocol::ClientKeyCode::Char('你'), modifiers: 0, kind: crate::protocol::ClientKeyKind::Press, repeat_count: 1, generated_text: Some("你".to_string()), source: crate::protocol::ClientKeySource::Synthesized, - }) + } ); + let crate::raw_input::RawInputEvent::Key(key) = event.to_raw_input_event() else { + panic!("expected key event"); + }; + assert!(key.is_text_commit()); } #[test] @@ -825,9 +852,8 @@ mod windows_tests { assert_eq!(windows_key_raw_bytes(&ctrl_shift_bracket, false), None); } - #[cfg(windows)] - #[test] - fn windows_ctrl_d_semantic_event_encodes_to_eot() { + #[tokio::test] + async fn windows_ctrl_d_reaches_the_pane_as_eot() { let event = Event::Key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)); assert_eq!(windows_key_raw_bytes(&event, false), None); @@ -839,10 +865,12 @@ mod windows_tests { }; assert_eq!(key.code, KeyCode::Char('d')); assert_eq!(key.modifiers, KeyModifiers::CONTROL); - assert_eq!( - crate::input::encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy), - b"\x04" - ); + + let (runtime, _rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 80, 24, 0, b"", 1, + ); + assert_eq!(runtime.encode_terminal_key(key), b"\x04"); } #[test] @@ -883,7 +911,12 @@ mod windows_tests { kind: crate::protocol::ClientKeyKind::Press, repeat_count: 1, generated_text: None, - source: crate::protocol::ClientKeySource::Vt { bytes: vec![4] }, + source: crate::protocol::ClientKeySource::Vt { + bytes: vec![4], + shifted_codepoint: None, + base_layout_codepoint: None, + text_commit: false, + }, } ); } @@ -907,12 +940,41 @@ mod windows_tests { repeat_count: 1, generated_text: None, source: crate::protocol::ClientKeySource::Vt { - bytes: b"\x1b[A".to_vec() + bytes: b"\x1b[A".to_vec(), + shifted_codepoint: None, + base_layout_codepoint: None, + text_commit: false, }, } ); } + #[test] + fn windows_raw_kitty_input_preserves_alternate_codepoints() { + let mut framer = crate::raw_input::RawInputFramer::default(); + let mut events = framer.push(b"\x1b[97:65:113;;65:769u"); + assert_eq!(events.len(), 1); + + let event = + windows_client_input_event_from_raw(events.remove(0)).expect("raw key converts"); + let crate::protocol::ClientInputEvent::Key { + generated_text, + source: + crate::protocol::ClientKeySource::Vt { + shifted_codepoint, + base_layout_codepoint, + .. + }, + .. + } = event + else { + panic!("expected VT key event"); + }; + assert_eq!(generated_text.as_deref(), Some("A\u{301}")); + assert_eq!(shifted_codepoint, Some('A' as u32)); + assert_eq!(base_layout_codepoint, Some('q' as u32)); + } + #[test] fn windows_bare_escape_flushes_to_semantic_escape() { let mut framer = crate::raw_input::RawInputFramer::default(); @@ -930,7 +992,12 @@ mod windows_tests { kind: crate::protocol::ClientKeyKind::Press, repeat_count: 1, generated_text: None, - source: crate::protocol::ClientKeySource::Vt { bytes: vec![0x1b] }, + source: crate::protocol::ClientKeySource::Vt { + bytes: vec![0x1b], + shifted_codepoint: None, + base_layout_codepoint: None, + text_commit: false, + }, } ); } diff --git a/src/client/input/windows_vti.rs b/src/client/input/windows_vti.rs index c95cbc43f7..ca21a5dd10 100644 --- a/src/client/input/windows_vti.rs +++ b/src/client/input/windows_vti.rs @@ -1467,7 +1467,12 @@ mod tests { repeat_count: 1, generated_text: None, - source: crate::protocol::ClientKeySource::Vt { bytes: vec![0x1b] }, + source: crate::protocol::ClientKeySource::Vt { + bytes: vec![0x1b], + shifted_codepoint: None, + base_layout_codepoint: None, + text_commit: false, + }, }] ); } @@ -1487,7 +1492,12 @@ mod tests { repeat_count: 1, generated_text: None, - source: crate::protocol::ClientKeySource::Vt { bytes: vec![0x1b] }, + source: crate::protocol::ClientKeySource::Vt { + bytes: vec![0x1b], + shifted_codepoint: None, + base_layout_codepoint: None, + text_commit: false, + }, }] ); } @@ -2201,7 +2211,12 @@ mod tests { repeat_count: 1, generated_text: None, - source: crate::protocol::ClientKeySource::Vt { bytes: vec![0x1b] }, + source: crate::protocol::ClientKeySource::Vt { + bytes: vec![0x1b], + shifted_codepoint: None, + base_layout_codepoint: None, + text_commit: false, + }, }] ); } @@ -2220,7 +2235,12 @@ mod tests { repeat_count: 1, generated_text: None, - source: crate::protocol::ClientKeySource::Vt { bytes: vec![0x1b] }, + source: crate::protocol::ClientKeySource::Vt { + bytes: vec![0x1b], + shifted_codepoint: None, + base_layout_codepoint: None, + text_commit: false, + }, }, crate::protocol::ClientInputEvent::Key { code: crate::protocol::ClientKeyCode::Enter, diff --git a/src/ghostty/bindings.rs b/src/ghostty/bindings.rs index af743eaa5b..7b6eaebe89 100644 --- a/src/ghostty/bindings.rs +++ b/src/ghostty/bindings.rs @@ -67,6 +67,8 @@ pub const GHOSTTY_MODS_ALT: u32 = 4; pub const GHOSTTY_MODS_SUPER: u32 = 8; pub const GHOSTTY_MODS_CAPS_LOCK: u32 = 16; pub const GHOSTTY_MODS_NUM_LOCK: u32 = 32; +pub const GHOSTTY_MODS_HYPER: u32 = 1024; +pub const GHOSTTY_MODS_META: u32 = 2048; pub const GHOSTTY_MODS_SHIFT_SIDE: u32 = 64; pub const GHOSTTY_MODS_CTRL_SIDE: u32 = 128; pub const GHOSTTY_MODS_ALT_SIDE: u32 = 256; @@ -3818,6 +3820,16 @@ pub const GhosttyKey_GHOSTTY_KEY_WAKE_UP: GhosttyKey = 172; pub const GhosttyKey_GHOSTTY_KEY_COPY: GhosttyKey = 173; pub const GhosttyKey_GHOSTTY_KEY_CUT: GhosttyKey = 174; pub const GhosttyKey_GHOSTTY_KEY_PASTE: GhosttyKey = 175; +pub const GhosttyKey_GHOSTTY_KEY_F26: GhosttyKey = 176; +pub const GhosttyKey_GHOSTTY_KEY_F27: GhosttyKey = 177; +pub const GhosttyKey_GHOSTTY_KEY_F28: GhosttyKey = 178; +pub const GhosttyKey_GHOSTTY_KEY_F29: GhosttyKey = 179; +pub const GhosttyKey_GHOSTTY_KEY_F30: GhosttyKey = 180; +pub const GhosttyKey_GHOSTTY_KEY_F31: GhosttyKey = 181; +pub const GhosttyKey_GHOSTTY_KEY_F32: GhosttyKey = 182; +pub const GhosttyKey_GHOSTTY_KEY_F33: GhosttyKey = 183; +pub const GhosttyKey_GHOSTTY_KEY_F34: GhosttyKey = 184; +pub const GhosttyKey_GHOSTTY_KEY_F35: GhosttyKey = 185; pub const GhosttyKey_GHOSTTY_KEY_MAX_VALUE: GhosttyKey = 2147483647; #[doc = " Physical key codes.\n\n The set of key codes that Ghostty is aware of. These represent physical keys\n on the keyboard and are layout-independent. For example, the \"a\" key on a US\n keyboard is the same as the \"ф\" key on a Russian keyboard, but both will\n report the same key_a value.\n\n Layout-dependent strings are provided separately as UTF-8 text and are produced\n by the platform. These values are based on the W3C UI Events KeyboardEvent code\n standard. See: https://www.w3.org/TR/uievents-code\n\n @ingroup key"] pub type GhosttyKey = ::std::os::raw::c_uint; @@ -3891,6 +3903,18 @@ unsafe extern "C" { #[doc = " Set the unshifted Unicode codepoint.\n\n @param event The key event handle, must not be NULL\n @param codepoint The unshifted Unicode codepoint to set\n\n @ingroup key"] pub fn ghostty_key_event_set_unshifted_codepoint(event: GhosttyKeyEvent, codepoint: u32); } +unsafe extern "C" { + pub fn ghostty_key_event_set_shifted_codepoint(event: GhosttyKeyEvent, codepoint: u32); +} +unsafe extern "C" { + pub fn ghostty_key_event_get_shifted_codepoint(event: GhosttyKeyEvent) -> u32; +} +unsafe extern "C" { + pub fn ghostty_key_event_set_base_layout_codepoint(event: GhosttyKeyEvent, codepoint: u32); +} +unsafe extern "C" { + pub fn ghostty_key_event_get_base_layout_codepoint(event: GhosttyKeyEvent) -> u32; +} unsafe extern "C" { #[doc = " Get the unshifted Unicode codepoint.\n\n @param event The key event handle, must not be NULL\n @return The unshifted Unicode codepoint\n\n @ingroup key"] pub fn ghostty_key_event_get_unshifted_codepoint(event: GhosttyKeyEvent) -> u32; @@ -3939,7 +3963,9 @@ pub const GhosttyKeyEncoderOption_GHOSTTY_KEY_ENCODER_OPT_MACOS_OPTION_AS_ALT: #[doc = " Backarrow key mode (value: bool)\n See https://vt100.net/dec/ek-vt3xx-tp-002.pdf page 170\n If `false` (the default), `backspace` emits 0x7f\n If `true`, `backspace` emits 0x08"] pub const GhosttyKeyEncoderOption_GHOSTTY_KEY_ENCODER_OPT_BACKARROW_KEY_MODE: GhosttyKeyEncoderOption = 7; -#[doc = " Backarrow key mode (value: bool)\n See https://vt100.net/dec/ek-vt3xx-tp-002.pdf page 170\n If `false` (the default), `backspace` emits 0x7f\n If `true`, `backspace` emits 0x08"] +#[doc = " Input events originated in another terminal and already carry semantic\n modifiers and generated text (value: bool). This makes encoding\n independent of host OS input conventions."] +pub const GhosttyKeyEncoderOption_GHOSTTY_KEY_ENCODER_OPT_PROXY_EVENTS: GhosttyKeyEncoderOption = 8; +#[doc = " Input events originated in another terminal and already carry semantic\n modifiers and generated text (value: bool). This makes encoding\n independent of host OS input conventions."] pub const GhosttyKeyEncoderOption_GHOSTTY_KEY_ENCODER_OPT_MAX_VALUE: GhosttyKeyEncoderOption = 2147483647; #[doc = " Key encoder option identifiers.\n\n These values are used with ghostty_key_encoder_setopt() to configure\n the behavior of the key encoder.\n\n @ingroup key"] diff --git a/src/ghostty/mod.rs b/src/ghostty/mod.rs index 4dead632c9..5c8ed9840a 100644 --- a/src/ghostty/mod.rs +++ b/src/ghostty/mod.rs @@ -138,6 +138,8 @@ pub const MOD_SHIFT: u16 = ffi::GHOSTTY_MODS_SHIFT as u16; pub const MOD_CTRL: u16 = ffi::GHOSTTY_MODS_CTRL as u16; pub const MOD_ALT: u16 = ffi::GHOSTTY_MODS_ALT as u16; pub const MOD_SUPER: u16 = ffi::GHOSTTY_MODS_SUPER as u16; +pub const MOD_HYPER: u16 = ffi::GHOSTTY_MODS_HYPER as u16; +pub const MOD_META: u16 = ffi::GHOSTTY_MODS_META as u16; pub const KEY_ENTER: u32 = ffi::GhosttyKey_GHOSTTY_KEY_ENTER; pub const KEY_UP: u32 = ffi::GhosttyKey_GHOSTTY_KEY_ARROW_UP; @@ -2557,13 +2559,17 @@ impl Drop for RenderState { pub struct KeyEvent { raw: ffi::GhosttyKeyEvent, + utf8: String, } impl KeyEvent { pub fn new() -> Result { let mut raw = ptr::null_mut(); unsafe { ffi::ghostty_key_event_new(ptr::null(), &mut raw).into_result()? }; - Ok(Self { raw }) + Ok(Self { + raw, + utf8: String::new(), + }) } pub fn set_action(&mut self, action: ffi::GhosttyKeyAction) { @@ -2578,15 +2584,33 @@ impl KeyEvent { unsafe { ffi::ghostty_key_event_set_mods(self.raw, mods) } } + pub fn set_consumed_mods(&mut self, mods: u16) { + unsafe { ffi::ghostty_key_event_set_consumed_mods(self.raw, mods) } + } + pub fn set_utf8(&mut self, text: &str) { - unsafe { - ffi::ghostty_key_event_set_utf8(self.raw, text.as_ptr().cast::(), text.len()) - } + self.utf8.clear(); + self.utf8.push_str(text); + let bytes = self.utf8.as_bytes(); + let data = if bytes.is_empty() { + ptr::null() + } else { + bytes.as_ptr().cast::() + }; + unsafe { ffi::ghostty_key_event_set_utf8(self.raw, data, bytes.len()) } } pub fn set_unshifted_codepoint(&mut self, codepoint: u32) { unsafe { ffi::ghostty_key_event_set_unshifted_codepoint(self.raw, codepoint) } } + + pub fn set_shifted_codepoint(&mut self, codepoint: u32) { + unsafe { ffi::ghostty_key_event_set_shifted_codepoint(self.raw, codepoint) } + } + + pub fn set_base_layout_codepoint(&mut self, codepoint: u32) { + unsafe { ffi::ghostty_key_event_set_base_layout_codepoint(self.raw, codepoint) } + } } impl Drop for KeyEvent { @@ -2608,6 +2632,17 @@ impl KeyEncoder { pub fn set_from_terminal(&mut self, terminal: &Terminal) { unsafe { ffi::ghostty_key_encoder_setopt_from_terminal(self.raw, terminal.raw()) } + + // Pane input originated in another terminal and is already semantic. + // Keep encoding independent of the server's host OS conventions. + let proxy_events = true; + unsafe { + ffi::ghostty_key_encoder_setopt( + self.raw, + ffi::GhosttyKeyEncoderOption_GHOSTTY_KEY_ENCODER_OPT_PROXY_EVENTS, + ptr::from_ref(&proxy_events).cast(), + ) + } } pub fn encode(&mut self, event: &KeyEvent) -> Result, Error> { @@ -3566,6 +3601,16 @@ mod tests { assert_eq!(terminal.take_pwd_changes(), [b"file:///tmp/herdr".to_vec()]); } + #[test] + fn key_event_retains_utf8_text_for_its_lifetime() { + let mut event = KeyEvent::new().unwrap(); + let text = String::from("A"); + event.set_utf8(&text); + drop(text); + + assert_eq!(event.utf8, "A"); + } + #[test] fn key_and_mouse_encoders_follow_terminal_state() { let mut terminal = Terminal::new(80, 24, 0).unwrap(); diff --git a/src/input/encode.rs b/src/input/encode.rs index 8ec8a1c518..facc1bda4d 100644 --- a/src/input/encode.rs +++ b/src/input/encode.rs @@ -1,78 +1,6 @@ -use std::fmt::Write as _; +use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind}; -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEventKind}; - -use super::model::KITTY_FLAG_REPORT_ALL_KEYS; -use super::{KeyboardProtocol, MouseProtocolEncoding, TerminalKey}; - -const KITTY_FLAG_REPORT_EVENT_TYPES: u16 = 0b0000_0010; -const KITTY_FLAG_REPORT_ALTERNATE_KEYS: u16 = 0b0000_0100; -const KITTY_FLAG_REPORT_ASSOCIATED_TEXT: u16 = 0b0001_0000; - -/// Encode a key event for a PTY child using the pane's negotiated keyboard protocol. -#[allow(dead_code)] // exercised in input unit tests; production uses TerminalRuntime helpers -pub fn encode_key(key: KeyEvent, protocol: KeyboardProtocol) -> Vec { - encode_terminal_key(key.into(), protocol) -} - -pub fn encode_terminal_key(key: TerminalKey, protocol: KeyboardProtocol) -> Vec { - if key.kind != crossterm::event::KeyEventKind::Release { - if let Some(text) = &key.generated_text { - return text.as_bytes().to_vec(); - } - } - - // A release event only produces bytes when the pane protocol reports event - // types (Kitty REPORT_EVENT_TYPES). Otherwise the child expects a single - // legacy byte per keystroke, so re-emitting it on release would double keys - // like Enter/Backspace. The Ghostty wrapper can route release events through - // this fallback, so guard the fallback encoder too. - if key.kind == crossterm::event::KeyEventKind::Release && !protocol.reports_event_types() { - return Vec::new(); - } - - let kitty_first = protocol.reports_all_keys() - || (key.kind == crossterm::event::KeyEventKind::Release && protocol.reports_event_types()); - - if kitty_first { - if let KeyboardProtocol::Kitty { flags } = protocol { - if let Some(bytes) = try_encode_csi_u(&key, flags) { - return bytes; - } - } - } - - if let Some(bytes) = encode_text_input(&key) { - return bytes; - } - - if !kitty_first { - if let KeyboardProtocol::Kitty { flags } = protocol { - if let Some(bytes) = try_encode_csi_u(&key, flags) { - return bytes; - } - } - } - if key.kind == crossterm::event::KeyEventKind::Release && protocol.reports_event_types() { - return Vec::new(); - } - encode_legacy(key) -} - -#[allow(dead_code)] // exercised in input unit tests; production uses TerminalRuntime helpers -pub fn encode_cursor_key(code: KeyCode, application_cursor: bool) -> Vec { - match (code, application_cursor) { - (KeyCode::Up, true) => b"\x1bOA".to_vec(), - (KeyCode::Down, true) => b"\x1bOB".to_vec(), - (KeyCode::Right, true) => b"\x1bOC".to_vec(), - (KeyCode::Left, true) => b"\x1bOD".to_vec(), - (KeyCode::Up, false) => b"\x1b[A".to_vec(), - (KeyCode::Down, false) => b"\x1b[B".to_vec(), - (KeyCode::Right, false) => b"\x1b[C".to_vec(), - (KeyCode::Left, false) => b"\x1b[D".to_vec(), - _ => encode_legacy(KeyEvent::new(code, KeyModifiers::empty()).into()), - } -} +use super::MouseProtocolEncoding; #[allow(dead_code)] // exercised in input unit tests; pane runtime uses backend helpers pub fn encode_mouse_scroll( @@ -115,7 +43,6 @@ pub fn encode_mouse_button( encode_mouse_cb(button, release, column, row, modifiers, encoding) } -#[allow(dead_code)] // only reached through mouse encoding helpers above fn encode_mouse_cb( base_button: u16, release: bool, @@ -167,7 +94,6 @@ fn encode_mouse_cb( } } -#[allow(dead_code)] // only reached through mouse encoding helpers above fn push_mouse_codepoint(bytes: &mut Vec, value: u32) -> Option<()> { let ch = char::from_u32(value)?; let mut buf = [0u8; 4]; @@ -175,486 +101,14 @@ fn push_mouse_codepoint(bytes: &mut Vec, value: u32) -> Option<()> { Some(()) } -/// CSI u encoding: \e[{codepoint};{modifiers}u -/// Used when the child has pushed Kitty keyboard enhancement. -/// Returns None if the key doesn't need CSI u (unmodified basic keys). -fn try_encode_csi_u(key: &TerminalKey, flags: u16) -> Option> { - let mods = key.modifiers; - let event_suffix = kitty_event_suffix(key, flags); - let report_all_keys = flags & KITTY_FLAG_REPORT_ALL_KEYS != 0; - - if !report_all_keys - && key.modifiers.is_empty() - && matches!(key.code, KeyCode::Enter | KeyCode::Tab | KeyCode::Backspace) - { - return None; - } - - // Unmodified keys use legacy encoding (more compatible) - if mods.is_empty() && event_suffix.is_none() && !report_all_keys { - return None; - } - - // Special keys (arrows, F-keys, etc.) have well-established legacy - // xterm modified formats (\x1b[1;3A for Alt+Up, etc.) that are universally - // understood. Even Ghostty sends these in legacy format with kitty mode on. - // Only use CSI u for character keys and keys without legacy representations. - match key.code { - KeyCode::Up - | KeyCode::Down - | KeyCode::Left - | KeyCode::Right - | KeyCode::Home - | KeyCode::End - | KeyCode::PageUp - | KeyCode::PageDown - | KeyCode::Insert - | KeyCode::Delete - | KeyCode::F(_) - if event_suffix.is_none() && !report_all_keys => - { - return None; // let legacy handle these - } - _ => {} - } - - let (codepoint, alternate_shifted) = match key.code { - KeyCode::Char(c) => { - let base = canonical_kitty_char(c, mods); - let shifted = alternate_shifted_codepoint(key, flags); - (base as u32, shifted) - } - KeyCode::Enter => (13, None), - KeyCode::Tab => (9, None), - KeyCode::Backspace => (127, None), - KeyCode::Esc => (27, None), - KeyCode::Left => (57417, None), - KeyCode::Right => (57418, None), - KeyCode::Up => (57419, None), - KeyCode::Down => (57420, None), - KeyCode::PageUp => (57421, None), - KeyCode::PageDown => (57422, None), - KeyCode::Home => (57423, None), - KeyCode::End => (57424, None), - KeyCode::Insert => (57425, None), - KeyCode::Delete => (57426, None), - _ => return None, // fall back to legacy for unhandled keys - }; - - let modifier = kitty_modifier(mods); - - let mut sequence = String::with_capacity(32); - sequence.push_str("\x1b["); - write!(&mut sequence, "{codepoint}").ok()?; - if let Some(shifted) = alternate_shifted { - write!(&mut sequence, ":{shifted}").ok()?; - } - write!(&mut sequence, ";{modifier}").ok()?; - if let Some(event) = event_suffix { - write!(&mut sequence, ":{event}").ok()?; - } - if flags & KITTY_FLAG_REPORT_ASSOCIATED_TEXT != 0 { - if let Some(text) = text_codepoint_for_key(key) { - write!(&mut sequence, ";{text}").ok()?; - } - } - sequence.push('u'); - - Some(sequence.into_bytes()) -} - -fn text_codepoint_for_key(key: &TerminalKey) -> Option { - let ch = text_char_for_key(key)?; - (!ch.is_control()).then_some(ch as u32) -} - -/// Legacy terminal encoding (standard escape sequences). -fn encode_legacy(key: TerminalKey) -> Vec { - let mods = key.modifiers; - - // Modified special keys (arrows, home, end, etc.) use xterm format: - // \x1b[1;{modifier}A for arrows/home/end - // \x1b[{n};{modifier}~ for insert/delete/pgup/pgdn - // The ESC-prefix hack doesn't work for these since they're already escape sequences. - if !mods.is_empty() { - if let Some(bytes) = encode_modified_special(key.code, mods) { - return bytes; - } - } - - // Alt modifier on character keys: prefix with ESC - if mods.contains(KeyModifiers::ALT) { - let inner = key.with_modifiers(mods.difference(KeyModifiers::ALT)); - let mut bytes = vec![0x1b]; - bytes.extend(encode_legacy_inner(inner)); - return bytes; - } - encode_legacy_inner(key) -} - -/// xterm-style encoding for modified special keys. -/// Modifier value: 1 + (shift?1:0) + (alt?2:0) + (ctrl?4:0) -fn encode_modified_special(code: KeyCode, mods: KeyModifiers) -> Option> { - let modifier = xterm_modifier(mods); - if modifier <= 1 { - return None; // no modifiers to encode - } - - match code { - // CSI 1;{mod}{letter} format - KeyCode::Up => Some(format!("\x1b[1;{modifier}A").into_bytes()), - KeyCode::Down => Some(format!("\x1b[1;{modifier}B").into_bytes()), - KeyCode::Right => Some(format!("\x1b[1;{modifier}C").into_bytes()), - KeyCode::Left => Some(format!("\x1b[1;{modifier}D").into_bytes()), - KeyCode::Home => Some(format!("\x1b[1;{modifier}H").into_bytes()), - KeyCode::End => Some(format!("\x1b[1;{modifier}F").into_bytes()), - // CSI {n};{mod}~ format - KeyCode::Insert => Some(format!("\x1b[2;{modifier}~").into_bytes()), - KeyCode::Delete => Some(format!("\x1b[3;{modifier}~").into_bytes()), - KeyCode::PageUp => Some(format!("\x1b[5;{modifier}~").into_bytes()), - KeyCode::PageDown => Some(format!("\x1b[6;{modifier}~").into_bytes()), - // F1-F4: CSI 1;{mod}{P-S} - KeyCode::F(1) => Some(format!("\x1b[1;{modifier}P").into_bytes()), - KeyCode::F(2) => Some(format!("\x1b[1;{modifier}Q").into_bytes()), - KeyCode::F(3) => Some(format!("\x1b[1;{modifier}R").into_bytes()), - KeyCode::F(4) => Some(format!("\x1b[1;{modifier}S").into_bytes()), - // F5-F12: CSI {n};{mod}~ - KeyCode::F(n @ 5..=12) => { - let code = match n { - 5 => 15, - 6 => 17, - 7 => 18, - 8 => 19, - 9 => 20, - 10 => 21, - 11 => 23, - 12 => 24, - _ => unreachable!(), - }; - Some(format!("\x1b[{code};{modifier}~").into_bytes()) - } - _ => None, - } -} - -/// xterm modifier encoding: 1 + shift(1) + alt(2) + ctrl(4) -/// Used for legacy modified special keys (arrows, function keys, etc.) -fn xterm_modifier(mods: KeyModifiers) -> u32 { - let mut m = 1u32; - if mods.contains(KeyModifiers::SHIFT) { - m += 1; - } - if mods.contains(KeyModifiers::ALT) { - m += 2; - } - if mods.contains(KeyModifiers::CONTROL) { - m += 4; - } - m -} - -/// Kitty protocol modifier encoding: 1 + shift(1) + alt(2) + ctrl(4) + super(8) + hyper(16) + meta(32) -/// Superset of xterm — adds Super/Hyper/Meta bits. -fn kitty_modifier(mods: KeyModifiers) -> u32 { - let mut m = xterm_modifier(mods); - if mods.contains(KeyModifiers::SUPER) { - m += 8; - } - if mods.contains(KeyModifiers::HYPER) { - m += 16; - } - if mods.contains(KeyModifiers::META) { - m += 32; - } - m -} - -fn encode_text_input(key: &TerminalKey) -> Option> { - let ch = text_char_for_key(key)?; - let mut buf = [0u8; 4]; - Some(ch.encode_utf8(&mut buf).as_bytes().to_vec()) -} - -fn text_char_for_key(key: &TerminalKey) -> Option { - if key.kind == crossterm::event::KeyEventKind::Release { - return None; - } - - let KeyCode::Char(ch) = key.code else { - return None; - }; - - if key.modifiers.is_empty() { - return Some(ch); - } - if key.modifiers == KeyModifiers::SHIFT { - return shifted_text_char(key, ch); - } - None -} - -fn shifted_text_char(key: &TerminalKey, ch: char) -> Option { - if let Some(shifted) = key.shifted_codepoint.and_then(char::from_u32) { - return Some(shifted); - } - - if ch.is_ascii_uppercase() { - return Some(ch); - } - - if ch.is_ascii_lowercase() { - return Some(ch.to_ascii_uppercase()); - } - - if is_shifted_ascii_punctuation(ch) { - return Some(ch); - } - - None -} - -fn is_shifted_ascii_punctuation(ch: char) -> bool { - matches!( - ch, - '!' | '@' - | '#' - | '$' - | '%' - | '^' - | '&' - | '*' - | '(' - | ')' - | '_' - | '+' - | '{' - | '}' - | '|' - | ':' - | '"' - | '<' - | '>' - | '?' - | '~' - ) -} - -fn canonical_kitty_char(ch: char, mods: KeyModifiers) -> char { - if mods.contains(KeyModifiers::SHIFT) && ch.is_ascii_uppercase() { - ch.to_ascii_lowercase() - } else { - ch - } -} - -fn alternate_shifted_codepoint(key: &TerminalKey, flags: u16) -> Option { - if flags & KITTY_FLAG_REPORT_ALTERNATE_KEYS == 0 { - return None; - } - - if let Some(shifted) = key.shifted_codepoint { - return Some(shifted); - } - - match key.code { - KeyCode::Char(ch) - if key.modifiers.contains(KeyModifiers::SHIFT) && ch.is_ascii_uppercase() => - { - Some(ch as u32) - } - _ => None, - } -} - -fn kitty_event_suffix(key: &TerminalKey, flags: u16) -> Option { - if flags & KITTY_FLAG_REPORT_EVENT_TYPES == 0 { - return None; - } - - Some(match key.kind { - crossterm::event::KeyEventKind::Press => 1, - crossterm::event::KeyEventKind::Repeat => 2, - crossterm::event::KeyEventKind::Release => 3, - }) -} - -fn encode_legacy_inner(key: TerminalKey) -> Vec { - match key.code { - KeyCode::Char(ch) => { - if key.modifiers.contains(KeyModifiers::CONTROL) { - let upper = ch.to_ascii_uppercase(); - match upper { - 'A'..='Z' => vec![upper as u8 - 64], - ' ' | '@' | '2' => vec![0], - '[' | '3' => vec![27], - '\\' | '4' => vec![28], - ']' | '5' => vec![29], - '^' | '6' => vec![30], - '_' | '/' | '7' | '-' => vec![31], - _ => vec![ch as u8], - } - } else { - let ch = if key.modifiers == KeyModifiers::SHIFT { - shifted_text_char(&key, ch).unwrap_or(ch) - } else { - ch - }; - let mut buf = [0u8; 4]; - ch.encode_utf8(&mut buf).as_bytes().to_vec() - } - } - KeyCode::Enter => vec![b'\r'], - KeyCode::Backspace => vec![127], - KeyCode::Tab => vec![9], - KeyCode::BackTab => vec![27, 91, 90], - KeyCode::Esc => vec![27], - KeyCode::Left => vec![27, 91, 68], - KeyCode::Right => vec![27, 91, 67], - KeyCode::Up => vec![27, 91, 65], - KeyCode::Down => vec![27, 91, 66], - KeyCode::Home => vec![27, 91, 72], - KeyCode::End => vec![27, 91, 70], - KeyCode::PageUp => vec![27, 91, 53, 126], - KeyCode::PageDown => vec![27, 91, 54, 126], - KeyCode::Delete => vec![27, 91, 51, 126], - KeyCode::Insert => vec![27, 91, 50, 126], - KeyCode::F(n) => encode_f_key(n), - _ => vec![], - } -} - -fn encode_f_key(n: u8) -> Vec { - match n { - 1 => vec![27, 79, 80], - 2 => vec![27, 79, 81], - 3 => vec![27, 79, 82], - 4 => vec![27, 79, 83], - 5 => vec![27, 91, 49, 53, 126], - 6 => vec![27, 91, 49, 55, 126], - 7 => vec![27, 91, 49, 56, 126], - 8 => vec![27, 91, 49, 57, 126], - 9 => vec![27, 91, 50, 48, 126], - 10 => vec![27, 91, 50, 49, 126], - 11 => vec![27, 91, 50, 51, 126], - 12 => vec![27, 91, 50, 52, 126], - _ => vec![], - } -} - #[cfg(test)] mod tests { - use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; - use super::*; - use crate::input::parse_terminal_key_sequence; - - fn assert_terminal_key_eq( - actual: TerminalKey, - code: KeyCode, - modifiers: KeyModifiers, - kind: crossterm::event::KeyEventKind, - shifted_codepoint: Option, - ) { - assert_eq!(actual.code, code); - assert_eq!(actual.modifiers, modifiers); - assert_eq!(actual.kind, kind); - assert_eq!(actual.shifted_codepoint, shifted_codepoint); - } - - #[test] - fn legacy_enter() { - let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()); - assert_eq!(encode_key(key, KeyboardProtocol::Legacy), vec![b'\r']); - } - - #[test] - fn legacy_ctrl_c() { - let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL); - assert_eq!(encode_key(key, KeyboardProtocol::Legacy), vec![3]); - } - - #[test] - fn legacy_ctrl_slash_aliases_ctrl_underscore() { - let key = KeyEvent::new(KeyCode::Char('/'), KeyModifiers::CONTROL); - assert_eq!(encode_key(key, KeyboardProtocol::Legacy), vec![31]); - } - - #[test] - fn legacy_shift_enter_is_just_cr() { - let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT); - assert_eq!(encode_key(key, KeyboardProtocol::Legacy), vec![b'\r']); - } - - #[test] - fn legacy_alt_up() { - let key = KeyEvent::new(KeyCode::Up, KeyModifiers::ALT); - assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"\x1b[1;3A"); - } - - #[test] - fn legacy_shift_right() { - let key = KeyEvent::new(KeyCode::Right, KeyModifiers::SHIFT); - assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"\x1b[1;2C"); - } - - #[test] - fn legacy_ctrl_left() { - let key = KeyEvent::new(KeyCode::Left, KeyModifiers::CONTROL); - assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"\x1b[1;5D"); - } - - #[test] - fn legacy_ctrl_shift_end() { - let key = KeyEvent::new(KeyCode::End, KeyModifiers::CONTROL | KeyModifiers::SHIFT); - assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"\x1b[1;6F"); - } - - #[test] - fn legacy_alt_delete() { - let key = KeyEvent::new(KeyCode::Delete, KeyModifiers::ALT); - assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"\x1b[3;3~"); - } - - #[test] - fn legacy_shift_f5() { - let key = KeyEvent::new(KeyCode::F(5), KeyModifiers::SHIFT); - assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"\x1b[15;2~"); - } - - #[test] - fn legacy_alt_char_still_esc_prefix() { - let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::ALT); - assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"\x1ba"); - } - - #[test] - fn legacy_alt_shift_punctuation_uses_shifted_text() { - let key = parse_terminal_key_sequence("\x1b[44:60;4u").unwrap(); - assert_eq!(encode_terminal_key(key, KeyboardProtocol::Legacy), b"\x1b<"); - } - - #[test] - fn legacy_alt_backspace_sends_escape_delete() { - let key = KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT); - assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"\x1b\x7f"); - } - - #[test] - fn application_cursor_keys_use_ss3_sequences() { - assert_eq!(encode_cursor_key(KeyCode::Up, true), b"\x1bOA"); - assert_eq!(encode_cursor_key(KeyCode::Down, true), b"\x1bOB"); - } - - #[test] - fn normal_cursor_keys_use_csi_sequences() { - assert_eq!(encode_cursor_key(KeyCode::Up, false), b"\x1b[A"); - assert_eq!(encode_cursor_key(KeyCode::Down, false), b"\x1b[B"); - } #[test] fn sgr_mouse_scroll_encodes_wheel_button_and_coordinates() { let encoded = encode_mouse_scroll( - crossterm::event::MouseEventKind::ScrollDown, + MouseEventKind::ScrollDown, 4, 6, KeyModifiers::SHIFT, @@ -668,7 +122,7 @@ mod tests { #[test] fn sgr_mouse_release_keeps_button_code() { let encoded = encode_mouse_button( - crossterm::event::MouseEventKind::Up(crossterm::event::MouseButton::Left), + MouseEventKind::Up(MouseButton::Left), 11, 9, KeyModifiers::empty(), @@ -678,508 +132,4 @@ mod tests { assert_eq!(encoded, b"\x1b[<0;12;10m"); } - - #[test] - fn kitty_shift_enter() { - let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT); - assert_eq!( - encode_key(key, KeyboardProtocol::Kitty { flags: 1 }), - b"\x1b[13;2u" - ); - } - - #[test] - fn kitty_ctrl_shift_a() { - let key = KeyEvent::new( - KeyCode::Char('a'), - KeyModifiers::CONTROL | KeyModifiers::SHIFT, - ); - assert_eq!( - encode_key(key, KeyboardProtocol::Kitty { flags: 1 }), - b"\x1b[97;6u" - ); - } - - #[test] - fn kitty_shift_uppercase_letter_sends_text() { - let key = KeyEvent::new(KeyCode::Char('L'), KeyModifiers::SHIFT); - assert_eq!(encode_key(key, KeyboardProtocol::Kitty { flags: 1 }), b"L"); - } - - #[test] - fn kitty_shift_uppercase_letter_ignores_alternate_key_reporting_for_text() { - let key = KeyEvent::new(KeyCode::Char('L'), KeyModifiers::SHIFT); - assert_eq!(encode_key(key, KeyboardProtocol::Kitty { flags: 7 }), b"L"); - } - - #[test] - fn kitty_shift_lowercase_letter_sends_uppercase_text() { - let key = KeyEvent::new(KeyCode::Char('l'), KeyModifiers::SHIFT); - assert_eq!(encode_key(key, KeyboardProtocol::Kitty { flags: 1 }), b"L"); - } - - #[test] - fn kitty_alt_shift_uppercase_letter_uses_base_codepoint() { - let key = KeyEvent::new(KeyCode::Char('L'), KeyModifiers::ALT | KeyModifiers::SHIFT); - assert_eq!( - encode_key(key, KeyboardProtocol::Kitty { flags: 1 }), - b"\x1b[108;4u" - ); - } - - #[test] - fn kitty_ctrl_shift_uppercase_letter_uses_base_codepoint() { - let key = KeyEvent::new( - KeyCode::Char('L'), - KeyModifiers::CONTROL | KeyModifiers::SHIFT, - ); - assert_eq!( - encode_key(key, KeyboardProtocol::Kitty { flags: 1 }), - b"\x1b[108;6u" - ); - } - - #[test] - fn legacy_shift_uppercase_letter_stays_uppercase() { - let key = KeyEvent::new(KeyCode::Char('L'), KeyModifiers::SHIFT); - assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"L"); - } - - #[test] - fn kitty_alt_enter() { - let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT); - assert_eq!( - encode_key(key, KeyboardProtocol::Kitty { flags: 1 }), - b"\x1b[13;3u" - ); - } - - #[test] - fn kitty_alt_backspace_uses_csi_u() { - let key = KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT); - assert_eq!( - encode_key(key, KeyboardProtocol::Kitty { flags: 1 }), - b"\x1b[127;3u" - ); - } - - #[test] - fn kitty_plain_ctrl_c_uses_csi_u() { - let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL); - assert_eq!( - encode_key(key, KeyboardProtocol::Kitty { flags: 1 }), - b"\x1b[99;5u" - ); - } - - #[test] - fn kitty_plain_ctrl_c_includes_press_event_when_requested() { - let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL); - assert_eq!( - encode_key(key, KeyboardProtocol::Kitty { flags: 3 }), - b"\x1b[99;5:1u" - ); - } - - #[test] - fn kitty_unmodified_uses_legacy() { - let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty()); - assert_eq!(encode_key(key, KeyboardProtocol::Kitty { flags: 1 }), b"a"); - } - - #[test] - fn kitty_report_event_types_keeps_basic_compatibility_keys_legacy() { - let cases = [ - (KeyCode::Enter, b"\r".as_slice()), - (KeyCode::Tab, b"\t".as_slice()), - (KeyCode::Backspace, b"\x7f".as_slice()), - ]; - - for (code, expected) in cases { - let press = KeyEvent::new_with_kind( - code, - KeyModifiers::empty(), - crossterm::event::KeyEventKind::Press, - ); - assert_eq!( - encode_key(press, KeyboardProtocol::Kitty { flags: 3 }), - expected, - "{code:?} press should stay legacy-compatible without REPORT_ALL_KEYS" - ); - - let repeat = KeyEvent::new_with_kind( - code, - KeyModifiers::empty(), - crossterm::event::KeyEventKind::Repeat, - ); - assert_eq!( - encode_key(repeat, KeyboardProtocol::Kitty { flags: 3 }), - expected, - "{code:?} repeat should stay legacy-compatible without REPORT_ALL_KEYS" - ); - - let release = KeyEvent::new_with_kind( - code, - KeyModifiers::empty(), - crossterm::event::KeyEventKind::Release, - ); - assert_eq!( - encode_key(release, KeyboardProtocol::Kitty { flags: 3 }), - b"", - "{code:?} release should not fall back to legacy bytes" - ); - } - } - - #[test] - fn kitty_report_all_keys_encodes_basic_compatibility_keys_with_events() { - let enter_press = KeyEvent::new_with_kind( - KeyCode::Enter, - KeyModifiers::empty(), - crossterm::event::KeyEventKind::Press, - ); - assert_eq!( - encode_key(enter_press, KeyboardProtocol::Kitty { flags: 9 }), - b"\x1b[13;1u" - ); - - let backspace_press = KeyEvent::new_with_kind( - KeyCode::Backspace, - KeyModifiers::empty(), - crossterm::event::KeyEventKind::Press, - ); - assert_eq!( - encode_key(backspace_press, KeyboardProtocol::Kitty { flags: 11 }), - b"\x1b[127;1:1u" - ); - - let backspace_release = KeyEvent::new_with_kind( - KeyCode::Backspace, - KeyModifiers::empty(), - crossterm::event::KeyEventKind::Release, - ); - assert_eq!( - encode_key(backspace_release, KeyboardProtocol::Kitty { flags: 11 }), - b"\x1b[127;1:3u" - ); - } - - #[test] - fn kitty_report_all_keys_encodes_printable_event_kinds() { - for (kind, expected) in [ - ( - crossterm::event::KeyEventKind::Press, - b"\x1b[106;1:1u".as_slice(), - ), - ( - crossterm::event::KeyEventKind::Repeat, - b"\x1b[106;1:2u".as_slice(), - ), - ( - crossterm::event::KeyEventKind::Release, - b"\x1b[106;1:3u".as_slice(), - ), - ] { - let key = KeyEvent::new_with_kind(KeyCode::Char('j'), KeyModifiers::empty(), kind); - assert_eq!( - encode_key(key, KeyboardProtocol::Kitty { flags: 15 }), - expected - ); - } - } - - #[test] - fn kitty_report_associated_text_embeds_shifted_printables() { - let cases = [ - ( - TerminalKey::new(KeyCode::Char('A'), KeyModifiers::SHIFT), - b"\x1b[97;2;65u".as_slice(), - ), - ( - TerminalKey::new(KeyCode::Char('1'), KeyModifiers::SHIFT) - .with_shifted_codepoint('!' as u32), - b"\x1b[49;2;33u".as_slice(), - ), - ( - TerminalKey::new(KeyCode::Char(':'), KeyModifiers::SHIFT), - b"\x1b[58;2;58u".as_slice(), - ), - ]; - - for (key, expected) in cases { - assert_eq!( - encode_terminal_key(key, KeyboardProtocol::Kitty { flags: 25 }), - expected - ); - } - } - - #[test] - fn kitty_associated_text_composes_with_alternates_and_events() { - for (kind, expected) in [ - ( - crossterm::event::KeyEventKind::Press, - b"\x1b[97:65;2:1;65u".as_slice(), - ), - ( - crossterm::event::KeyEventKind::Repeat, - b"\x1b[97:65;2:2;65u".as_slice(), - ), - ( - crossterm::event::KeyEventKind::Release, - b"\x1b[97:65;2:3u".as_slice(), - ), - ] { - let key = TerminalKey::new(KeyCode::Char('A'), KeyModifiers::SHIFT).with_kind(kind); - assert_eq!( - encode_terminal_key(key, KeyboardProtocol::Kitty { flags: 31 }), - expected - ); - } - } - - #[test] - fn kitty_printable_release_is_encoded_without_report_all() { - let release = KeyEvent::new_with_kind( - KeyCode::Char('j'), - KeyModifiers::empty(), - crossterm::event::KeyEventKind::Release, - ); - assert_eq!( - encode_key(release, KeyboardProtocol::Kitty { flags: 3 }), - b"\x1b[106;1:3u" - ); - - let mut malformed_release = TerminalKey::from(release); - malformed_release.generated_text = Some("j".to_owned()); - assert_eq!( - encode_terminal_key(malformed_release, KeyboardProtocol::Kitty { flags: 3 }), - b"\x1b[106;1:3u" - ); - } - - #[test] - fn kitty_shift_tab() { - let key = KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT); - assert_eq!( - encode_key(key, KeyboardProtocol::Kitty { flags: 1 }), - b"\x1b[9;2u" - ); - } - - #[test] - fn kitty_ctrl_shift_enter() { - let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL | KeyModifiers::SHIFT); - assert_eq!( - encode_key(key, KeyboardProtocol::Kitty { flags: 1 }), - b"\x1b[13;6u" - ); - } - - #[test] - fn kitty_repeat_event_type_is_encoded_when_requested() { - let key = KeyEvent::new_with_kind( - KeyCode::Enter, - KeyModifiers::SHIFT, - crossterm::event::KeyEventKind::Repeat, - ); - assert_eq!( - encode_key(key, KeyboardProtocol::Kitty { flags: 3 }), - b"\x1b[13;2:2u" - ); - } - - #[test] - fn kitty_shift_letter_release_uses_csi_u() { - let key = KeyEvent::new_with_kind( - KeyCode::Char('L'), - KeyModifiers::SHIFT, - crossterm::event::KeyEventKind::Release, - ); - assert_eq!( - encode_key(key, KeyboardProtocol::Kitty { flags: 7 }), - b"\x1b[108:76;2:3u" - ); - } - - #[test] - fn kitty_shifted_punctuation_literals_send_text() { - for ch in "!@#$%^&*()_+{}|:\"<>?~".chars() { - let key = TerminalKey::new(KeyCode::Char(ch), KeyModifiers::SHIFT); - let encoded = encode_terminal_key(key, KeyboardProtocol::Kitty { flags: 7 }); - assert_eq!(encoded, ch.to_string().into_bytes(), "ch={ch}"); - } - } - - #[test] - fn kitty_shifted_punctuation_release_does_not_emit_text() { - let key = TerminalKey::new(KeyCode::Char('?'), KeyModifiers::SHIFT) - .with_kind(crossterm::event::KeyEventKind::Release); - assert_eq!( - encode_terminal_key(key, KeyboardProtocol::Kitty { flags: 7 }), - b"\x1b[63;2:3u" - ); - } - - #[test] - fn kitty_shifted_punctuation_does_not_infer_layout() { - let key = TerminalKey::new(KeyCode::Char('1'), KeyModifiers::SHIFT); - assert_eq!( - encode_terminal_key(key, KeyboardProtocol::Kitty { flags: 7 }), - b"\x1b[49;2:1u" - ); - } - - #[test] - fn kitty_modified_shifted_punctuation_stays_modified_key() { - for (modifiers, expected) in [ - ( - KeyModifiers::CONTROL | KeyModifiers::SHIFT, - b"\x1b[33;6:1u".as_slice(), - ), - ( - KeyModifiers::ALT | KeyModifiers::SHIFT, - b"\x1b[33;4:1u".as_slice(), - ), - ( - KeyModifiers::SUPER | KeyModifiers::SHIFT, - b"\x1b[33;10:1u".as_slice(), - ), - ] { - let key = TerminalKey::new(KeyCode::Char('!'), modifiers); - let encoded = encode_terminal_key(key, KeyboardProtocol::Kitty { flags: 7 }); - assert_eq!(encoded, expected, "modifiers={modifiers:?}"); - } - } - - #[test] - fn release_bytes_gated_on_report_event_types() { - for code in [KeyCode::Enter, KeyCode::Backspace] { - let release = KeyEvent::new_with_kind( - code, - KeyModifiers::empty(), - crossterm::event::KeyEventKind::Release, - ); - - // Legacy and Kitty disambiguate-only (no REPORT_EVENT_TYPES) must not - // emit a byte on release, otherwise Enter/Backspace double (issue #769). - assert_eq!(encode_key(release, KeyboardProtocol::Legacy), b""); - assert_eq!( - encode_key(release, KeyboardProtocol::Kitty { flags: 1 }), - b"" - ); - } - - let modified_release = KeyEvent::new_with_kind( - KeyCode::Enter, - KeyModifiers::CONTROL, - crossterm::event::KeyEventKind::Release, - ); - assert_eq!( - encode_key(modified_release, KeyboardProtocol::Kitty { flags: 3 }), - b"\x1b[13;5:3u" - ); - } - - #[test] - fn kitty_shifted_symbol_sends_text() { - let key = TerminalKey::new(KeyCode::Char('1'), KeyModifiers::SHIFT) - .with_shifted_codepoint('!' as u32); - assert_eq!( - encode_terminal_key(key, KeyboardProtocol::Kitty { flags: 7 }), - b"!" - ); - } - - #[test] - fn legacy_modified_special_roundtrip_matrix() { - let cases = [ - KeyEvent::new(KeyCode::Up, KeyModifiers::ALT), - KeyEvent::new(KeyCode::Down, KeyModifiers::ALT), - KeyEvent::new(KeyCode::Right, KeyModifiers::SHIFT), - KeyEvent::new(KeyCode::Left, KeyModifiers::CONTROL), - KeyEvent::new(KeyCode::Home, KeyModifiers::CONTROL), - KeyEvent::new(KeyCode::End, KeyModifiers::CONTROL | KeyModifiers::SHIFT), - KeyEvent::new(KeyCode::PageUp, KeyModifiers::ALT), - KeyEvent::new(KeyCode::PageDown, KeyModifiers::CONTROL), - KeyEvent::new(KeyCode::Insert, KeyModifiers::SHIFT), - KeyEvent::new(KeyCode::Delete, KeyModifiers::ALT), - ]; - - for key in cases { - let encoded = encode_key(key, KeyboardProtocol::Legacy); - let parsed = - parse_terminal_key_sequence(std::str::from_utf8(&encoded).unwrap()).unwrap(); - assert_terminal_key_eq(parsed, key.code, key.modifiers, key.kind, None); - } - } - - #[test] - fn kitty_shifted_symbol_prefers_text_over_roundtrip_key_identity() { - let key = TerminalKey::new(KeyCode::Char('1'), KeyModifiers::SHIFT) - .with_shifted_codepoint('!' as u32); - let encoded = encode_terminal_key(key, KeyboardProtocol::Kitty { flags: 7 }); - assert_eq!(encoded, b"!"); - } - - #[test] - fn legacy_basic_special_roundtrip_matrix() { - let cases = [ - KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - KeyEvent::new(KeyCode::Tab, KeyModifiers::empty()), - KeyEvent::new(KeyCode::Backspace, KeyModifiers::empty()), - KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), - KeyEvent::new(KeyCode::Up, KeyModifiers::empty()), - KeyEvent::new(KeyCode::Down, KeyModifiers::empty()), - KeyEvent::new(KeyCode::Left, KeyModifiers::empty()), - KeyEvent::new(KeyCode::Right, KeyModifiers::empty()), - KeyEvent::new(KeyCode::Home, KeyModifiers::empty()), - KeyEvent::new(KeyCode::End, KeyModifiers::empty()), - KeyEvent::new(KeyCode::PageUp, KeyModifiers::empty()), - KeyEvent::new(KeyCode::PageDown, KeyModifiers::empty()), - KeyEvent::new(KeyCode::Insert, KeyModifiers::empty()), - KeyEvent::new(KeyCode::Delete, KeyModifiers::empty()), - ]; - - for key in cases { - let encoded = encode_key(key, KeyboardProtocol::Legacy); - let parsed = - parse_terminal_key_sequence(std::str::from_utf8(&encoded).unwrap()).unwrap(); - assert_terminal_key_eq(parsed, key.code, key.modifiers, key.kind, None); - } - } - - #[test] - fn kitty_shifted_symbol_pair_matrix_is_encoded_as_text() { - let cases = [('1', '!'), ('/', '?'), ('[', '{')]; - - for (base, shifted) in cases { - let key = TerminalKey::new(KeyCode::Char(base), KeyModifiers::SHIFT) - .with_shifted_codepoint(shifted as u32); - let encoded = encode_terminal_key(key, KeyboardProtocol::Kitty { flags: 7 }); - assert_eq!(encoded, shifted.to_string().into_bytes(), "base={base}"); - } - } - - #[test] - fn chinese_char_encodes_as_utf8() { - let key = TerminalKey::new(KeyCode::Char('中'), KeyModifiers::empty()); - let encoded = encode_terminal_key(key, KeyboardProtocol::Legacy); - assert_eq!(encoded, "中".as_bytes()); - } - - #[test] - fn chinese_char_with_kitty_protocol_encodes_as_utf8() { - let key = TerminalKey::new(KeyCode::Char('文'), KeyModifiers::empty()); - let encoded = encode_terminal_key(key, KeyboardProtocol::Kitty { flags: 7 }); - assert_eq!(encoded, "文".as_bytes()); - } - - #[test] - fn chinese_char_with_modifiers_falls_back_to_kitty_encoding() { - let key = TerminalKey::new(KeyCode::Char('测'), KeyModifiers::ALT); - let encoded = encode_terminal_key(key, KeyboardProtocol::Kitty { flags: 7 }); - assert!(!encoded.is_empty()); - assert_ne!(encoded, "测".as_bytes()); - } } diff --git a/src/input/mod.rs b/src/input/mod.rs index 2189330313..59aba3f9c5 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -2,15 +2,17 @@ mod encode; mod model; pub(crate) mod mouse; mod parse; +#[cfg(test)] +pub(crate) mod test_support; +// Preserve mouse encoder re-exports for facade consumers even though Herdr's +// runtime routes mouse encoding through terminal backends. #[allow(unused_imports)] -pub use encode::{ - encode_cursor_key, encode_key, encode_mouse_button, encode_mouse_scroll, encode_terminal_key, -}; +pub use encode::{encode_mouse_button, encode_mouse_scroll}; #[cfg(not(windows))] pub use model::ime_compatible_keyboard_enhancement_flags; pub use model::{ - host_modify_other_keys_mode, KeyIdentity, KeyboardProtocol, MouseProtocolEncoding, - MouseProtocolMode, TerminalKey, TextCommit, WindowsKeyRecord, + host_modify_other_keys_mode, KeyIdentity, KeyboardProtocol, ModifyOtherKeysMode, + MouseProtocolEncoding, MouseProtocolMode, TerminalKey, TextCommit, WindowsKeyRecord, }; pub use parse::parse_terminal_key_sequence; diff --git a/src/input/model.rs b/src/input/model.rs index 21252d2d47..41b91cfe18 100644 --- a/src/input/model.rs +++ b/src/input/model.rs @@ -72,7 +72,9 @@ pub struct TerminalKey { pub kind: crossterm::event::KeyEventKind, pub repeat_count: u16, pub shifted_codepoint: Option, + pub base_layout_codepoint: Option, pub generated_text: Option, + text_commit: bool, source: KeySource, } @@ -84,7 +86,9 @@ impl TerminalKey { kind: crossterm::event::KeyEventKind::Press, repeat_count: 1, shifted_codepoint: None, + base_layout_codepoint: None, generated_text: None, + text_commit: false, source: KeySource::Synthesized, } } @@ -93,6 +97,7 @@ impl TerminalKey { if kind == crossterm::event::KeyEventKind::Release { self.repeat_count = 1; self.generated_text = None; + self.text_commit = false; } self.kind = kind; self @@ -107,14 +112,23 @@ impl TerminalKey { self } - pub(crate) fn with_modifiers(mut self, modifiers: KeyModifiers) -> Self { - self.modifiers = modifiers; + pub fn with_shifted_codepoint(mut self, shifted_codepoint: u32) -> Self { + self.shifted_codepoint = Some(shifted_codepoint); self } - #[allow(dead_code)] // Reserved for the upcoming raw input parser to preserve shifted/base key pairs. - pub fn with_shifted_codepoint(mut self, shifted_codepoint: u32) -> Self { - self.shifted_codepoint = Some(shifted_codepoint); + pub fn with_base_layout_codepoint(mut self, base_layout_codepoint: u32) -> Self { + self.base_layout_codepoint = Some(base_layout_codepoint); + self + } + + pub(crate) fn with_alternate_codepoints( + mut self, + shifted_codepoint: Option, + base_layout_codepoint: Option, + ) -> Self { + self.shifted_codepoint = shifted_codepoint; + self.base_layout_codepoint = base_layout_codepoint; self } @@ -161,6 +175,15 @@ impl TerminalKey { } } + pub(crate) fn is_windows_ctrl_minus_alias(&self) -> bool { + const CTRL_PRESSED: u32 = 0x0004 | 0x0008; + matches!( + self.source, + KeySource::WindowsConsole { record, .. } + if record.unicode == 0x1f && record.control_key_state & CTRL_PRESSED != 0 + ) + } + pub(crate) fn identity(&self) -> KeyIdentity { match self.source { KeySource::WindowsConsole { @@ -185,6 +208,10 @@ impl TerminalKey { ) } + pub(crate) fn is_text_commit(&self) -> bool { + self.text_commit + } + pub fn with_text_commit(mut self) -> Self { let has_text_only_modifiers = match self.code { KeyCode::Char(ch) if ch.is_uppercase() => { @@ -198,6 +225,7 @@ impl TerminalKey { KeyCode::Char(ch) => Some(ch.to_string()), _ => None, }; + self.text_commit = self.generated_text.is_some(); } self } @@ -222,7 +250,7 @@ pub fn ime_compatible_keyboard_enhancement_flags() -> KeyboardEnhancementFlags { | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum ModifyOtherKeysMode { Mode1, Mode2, @@ -389,8 +417,10 @@ mod tests { .with_generated_text(Some("ignored".to_owned())); assert_eq!(release.generated_text, None); + assert!(!release.is_text_commit()); assert_eq!(release.repeat_count, 1); assert_eq!(regrouped_release.generated_text, None); + assert!(!regrouped_release.is_text_commit()); assert_eq!(regrouped_release.repeat_count, 1); } @@ -399,6 +429,7 @@ mod tests { let key = TerminalKey::new(KeyCode::Char('É'), KeyModifiers::SHIFT).with_text_commit(); assert_eq!(key.generated_text.as_deref(), Some("É")); + assert!(key.is_text_commit()); } #[test] diff --git a/src/input/parse.rs b/src/input/parse.rs index b2318339f8..58c7cac0ff 100644 --- a/src/input/parse.rs +++ b/src/input/parse.rs @@ -2,6 +2,8 @@ use crossterm::event::{KeyCode, KeyModifiers, MediaKeyCode, ModifierKeyCode}; use super::TerminalKey; +const MAX_KITTY_ASSOCIATED_TEXT_CODEPOINTS: usize = 64; + #[allow(dead_code)] // Next step: raw stdin parser will feed TerminalKey directly through this path. pub fn parse_terminal_key_sequence(data: &str) -> Option { parse_kitty_key_sequence(data) @@ -16,35 +18,38 @@ fn parse_kitty_key_sequence(data: &str) -> Option { let mut fields = body.split(';'); let key_part = fields.next()?; let modifier_part = fields.next().unwrap_or("1"); - let associated_text = fields.next(); + let associated_text = fields.next().filter(|field| !field.is_empty()); if fields.next().is_some() { return None; } + let modifier_part = if modifier_part.is_empty() { + "1" + } else { + modifier_part + }; let (modifier_text, event_type) = split_modifier_and_event(modifier_part); - let modifier = modifier_text.parse::().ok()?.checked_sub(1)?; + let modifier = u8::try_from(modifier_text.parse::().ok()?.checked_sub(1)?).ok()?; let mut key_fields = key_part.split(':'); let codepoint = key_fields.next()?.parse::().ok()?; - let shifted_codepoint = key_fields - .next() - .filter(|field| !field.is_empty()) - .and_then(|field| field.parse::().ok()); - - if let Some(text) = associated_text { - if text.parse::().ok()? != codepoint { - return None; - } + let shifted_codepoint = parse_optional_kitty_codepoint(key_fields.next())?; + let base_layout_codepoint = parse_optional_kitty_codepoint(key_fields.next())?; + if key_fields.next().is_some() { + return None; } + let generated_text = match associated_text { + Some(text) => Some(parse_kitty_associated_text(text)?), + None => None, + }; let code = kitty_codepoint_to_keycode(codepoint)?; let kind = parse_kitty_event_type(event_type)?; let mut modifiers = key_modifiers_from_u8(modifier); // Kitty permits the shifted alternate only while Shift is active. Normalize // contradictory reports here so they cannot dispatch an unshifted command. if matches!(code, KeyCode::Char(_)) - && shifted_codepoint - .is_some_and(|shifted| shifted != codepoint && char::from_u32(shifted).is_some()) + && shifted_codepoint.is_some_and(|shifted| shifted != codepoint) { modifiers |= KeyModifiers::SHIFT; } @@ -53,7 +58,33 @@ fn parse_kitty_key_sequence(data: &str) -> Option { if let Some(shifted_codepoint) = shifted_codepoint { key = key.with_shifted_codepoint(shifted_codepoint); } - Some(key) + if let Some(base_layout_codepoint) = base_layout_codepoint { + key = key.with_base_layout_codepoint(base_layout_codepoint); + } + Some(key.with_generated_text(generated_text)) +} + +fn parse_optional_kitty_codepoint(field: Option<&str>) -> Option> { + let Some(field) = field.filter(|field| !field.is_empty()) else { + return Some(None); + }; + let codepoint = field.parse::().ok()?; + char::from_u32(codepoint).map(|_| Some(codepoint)) +} + +fn parse_kitty_associated_text(text: &str) -> Option { + let mut generated = String::new(); + for (index, field) in text.split(':').enumerate() { + if index >= MAX_KITTY_ASSOCIATED_TEXT_CODEPOINTS { + return None; + } + let ch = field.parse::().ok().and_then(char::from_u32)?; + if ch.is_control() { + return None; + } + generated.push(ch); + } + Some(generated) } #[allow(dead_code)] // Reserved for the upcoming raw stdin parser. @@ -362,7 +393,6 @@ mod tests { use crossterm::event::{KeyCode, KeyModifiers, ModifierKeyCode}; use super::*; - use crate::input::{encode_terminal_key, KeyboardProtocol}; fn assert_terminal_key_eq( actual: TerminalKey, @@ -537,13 +567,12 @@ mod tests { fn parse_legacy_alt_shift_letter_preserves_shift() { let key = parse_terminal_key_sequence("\x1bA").expect("alt-shift letter should parse"); assert_terminal_key_eq( - key.clone(), + key, KeyCode::Char('A'), KeyModifiers::ALT | KeyModifiers::SHIFT, crossterm::event::KeyEventKind::Press, None, ); - assert_eq!(encode_terminal_key(key, KeyboardProtocol::Legacy), b"\x1bA"); } #[test] @@ -551,16 +580,12 @@ mod tests { let key = parse_terminal_key_sequence("\x1b\x06") .expect("ctrl-alt-f legacy sequence should parse"); assert_terminal_key_eq( - key.clone(), + key, KeyCode::Char('f'), KeyModifiers::CONTROL | KeyModifiers::ALT, crossterm::event::KeyEventKind::Press, None, ); - assert_eq!( - encode_terminal_key(key, KeyboardProtocol::Legacy), - b"\x1b\x06" - ); } #[test] @@ -603,6 +628,21 @@ mod tests { assert_eq!(parse_terminal_key_sequence("\x1b[14;3~"), None); } + #[test] + fn parse_kitty_sequence_accepts_every_modifier_bit() { + let key = parse_terminal_key_sequence("\x1b[97;256u").unwrap(); + + assert_eq!( + key.modifiers, + KeyModifiers::SHIFT + | KeyModifiers::ALT + | KeyModifiers::CONTROL + | KeyModifiers::SUPER + | KeyModifiers::HYPER + | KeyModifiers::META + ); + } + #[test] fn parse_kitty_sequence_preserves_shifted_symbol_pair() { let key = parse_terminal_key_sequence("\x1b[49:33;2:1u").unwrap(); @@ -612,6 +652,27 @@ mod tests { assert_eq!(key.shifted_codepoint, Some('!' as u32)); } + #[test] + fn omitted_kitty_press_suffix_is_semantically_equivalent() { + for (implicit_press, explicit_press) in [ + ("\x1b[108:76;2u", "\x1b[108:76;2:1u"), + ("\x1b[108:76;2;76u", "\x1b[108:76;2:1;76u"), + ("\x1b[97;9u", "\x1b[97;9:1u"), + ] { + let implicit = parse_terminal_key_sequence(implicit_press).unwrap(); + let explicit = parse_terminal_key_sequence(explicit_press).unwrap(); + assert_eq!(implicit.code, explicit.code); + assert_eq!(implicit.modifiers, explicit.modifiers); + assert_eq!(implicit.kind, explicit.kind); + assert_eq!(implicit.shifted_codepoint, explicit.shifted_codepoint); + assert_eq!( + implicit.base_layout_codepoint, + explicit.base_layout_codepoint + ); + assert_eq!(implicit.generated_text, explicit.generated_text); + } + } + #[test] fn parse_kitty_sequence_preserves_shifted_letter_pair_and_release() { let key = parse_terminal_key_sequence("\x1b[108:76;2:3u").unwrap(); @@ -662,8 +723,8 @@ mod tests { } #[test] - fn parse_kitty_sequence_with_associated_emoji_text() { - let key = parse_terminal_key_sequence("\x1b[128512;1;128512u").unwrap(); + fn parse_kitty_sequence_preserves_associated_text() { + let key = parse_terminal_key_sequence("\x1b[128512;1;128512:65039u").unwrap(); assert_terminal_key_eq( key.clone(), KeyCode::Char('😀'), @@ -671,15 +732,44 @@ mod tests { crossterm::event::KeyEventKind::Press, None, ); + assert_eq!(key.generated_text.as_deref(), Some("😀\u{fe0f}")); } #[test] - fn reject_unmodeled_kitty_associated_text() { - assert_eq!(parse_terminal_key_sequence("\x1b[128512;1;128513u"), None); - assert_eq!( - parse_terminal_key_sequence("\x1b[128512;1;128512:65039u"), - None - ); + fn parse_kitty_sequence_preserves_full_alternates_and_omitted_modifiers() { + let key = parse_terminal_key_sequence("\x1b[97:65:113;;65:769u").unwrap(); + + assert_eq!(key.code, KeyCode::Char('a')); + assert_eq!(key.modifiers, KeyModifiers::SHIFT); + assert_eq!(key.shifted_codepoint, Some('A' as u32)); + assert_eq!(key.base_layout_codepoint, Some('q' as u32)); + assert_eq!(key.generated_text.as_deref(), Some("A\u{301}")); + } + + #[test] + fn parse_kitty_sequence_discards_associated_text_on_release() { + let key = parse_terminal_key_sequence("\x1b[97:65;2:3;65u").unwrap(); + + assert_eq!(key.kind, crossterm::event::KeyEventKind::Release); + assert_eq!(key.generated_text, None); + } + + #[test] + fn reject_malformed_kitty_alternates_and_associated_text() { + for sequence in [ + "\x1b[97:65:113:120;1u", + "\x1b[97;1;1114112u", + "\x1b[97;1;65::66u", + "\x1b[97;1;3u", + "\x1b[97;1;27u", + "\x1b[97;1;133u", + ] { + assert_eq!(parse_terminal_key_sequence(sequence), None, "{sequence:?}"); + } + + let associated = vec!["97"; MAX_KITTY_ASSOCIATED_TEXT_CODEPOINTS + 1].join(":"); + let oversized = format!("\x1b[97;1;{associated}u"); + assert_eq!(parse_terminal_key_sequence(&oversized), None); } #[test] @@ -811,12 +901,6 @@ mod tests { assert_eq!(key.modifiers, KeyModifiers::CONTROL); } - #[test] - fn legacy_lf_roundtrips_as_lf() { - let key = parse_terminal_key_sequence("\n").unwrap(); - assert_eq!(encode_terminal_key(key, KeyboardProtocol::Legacy), b"\n"); - } - #[test] fn legacy_ctrl_byte_matrix_is_covered() { for (byte, expected) in [ @@ -958,7 +1042,13 @@ mod tests { #[test] fn keyboard_protocol_corpus_fixture_parses() { let corpus = include_str!("../../tests/fixtures/keyboard_protocol_corpus.tsv"); - assert_fixture_corpus_parses(corpus); + for case in crate::input::test_support::keyboard_corpus_cases(corpus) { + let text = std::str::from_utf8(&case.input).expect("fixture input must be UTF-8"); + let parsed = parse_terminal_key_sequence(text) + .unwrap_or_else(|| panic!("fixture failed to parse: {}", case.family)); + + case.assert_key_semantics(&parsed); + } } #[test] diff --git a/src/input/test_support.rs b/src/input/test_support.rs new file mode 100644 index 0000000000..bfc5f7d80e --- /dev/null +++ b/src/input/test_support.rs @@ -0,0 +1,154 @@ +use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers}; + +#[derive(Debug)] +pub(crate) struct KeyboardCorpusCase<'a> { + pub family: &'a str, + pub input: Vec, + pub code: KeyCode, + pub modifiers: KeyModifiers, + pub kind: KeyEventKind, + pub shifted_codepoint: Option, + pub base_layout_codepoint: Option, + pub generated_text: Option, + pub pane_profile: &'a str, + pub expected_pane_hex: &'a str, +} + +impl KeyboardCorpusCase<'_> { + pub fn assert_key_semantics(&self, key: &crate::input::TerminalKey) { + assert_eq!(key.code, self.code, "{} code", self.family); + assert_eq!(key.modifiers, self.modifiers, "{} modifiers", self.family); + assert_eq!(key.kind, self.kind, "{} kind", self.family); + assert_eq!(key.repeat_count, 1, "{} repeat count", self.family); + assert_eq!( + key.shifted_codepoint, self.shifted_codepoint, + "{} shifted codepoint", + self.family + ); + assert_eq!( + key.base_layout_codepoint, self.base_layout_codepoint, + "{} base layout codepoint", + self.family + ); + } +} + +pub(crate) fn keyboard_corpus_cases(corpus: &str) -> Vec> { + corpus + .lines() + .filter_map(|line| { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + return None; + } + + let columns: Vec<_> = line.split('\t').collect(); + assert!( + matches!(columns.len(), 9 | 10), + "keyboard corpus row must have 9 or 10 columns: {line}" + ); + + Some(KeyboardCorpusCase { + family: columns[0], + input: decode_hex(columns[1]), + code: parse_key_code(columns[2]), + modifiers: parse_modifiers(columns[3]), + kind: parse_kind(columns[4]), + shifted_codepoint: (!columns[5].is_empty()) + .then(|| columns[5].parse::().expect("shifted codepoint")), + base_layout_codepoint: columns + .get(9) + .filter(|field| !field.is_empty()) + .map(|field| field.parse::().expect("base layout codepoint")), + generated_text: (!columns[6].is_empty() && columns[6] != "-").then(|| { + String::from_utf8(decode_hex(columns[6])).expect("generated text must be UTF-8") + }), + pane_profile: columns[7], + expected_pane_hex: columns[8], + }) + }) + .collect() +} + +pub(crate) fn decode_hex(hex: &str) -> Vec { + if hex == "empty" { + return Vec::new(); + } + assert_eq!(hex.len() % 2, 0, "hex string must have even length: {hex}"); + (0..hex.len()) + .step_by(2) + .map(|idx| u8::from_str_radix(&hex[idx..idx + 2], 16).expect("hex byte")) + .collect() +} + +fn parse_key_code(value: &str) -> KeyCode { + match value { + "enter" => KeyCode::Enter, + "tab" => KeyCode::Tab, + "backspace" => KeyCode::Backspace, + "esc" => KeyCode::Esc, + "up" => KeyCode::Up, + "down" => KeyCode::Down, + "left" => KeyCode::Left, + "right" => KeyCode::Right, + "home" => KeyCode::Home, + "end" => KeyCode::End, + "pageup" => KeyCode::PageUp, + "pagedown" => KeyCode::PageDown, + "insert" => KeyCode::Insert, + "delete" => KeyCode::Delete, + value if value.starts_with("char:") => { + let mut chars = value.trim_start_matches("char:").chars(); + let ch = chars.next().expect("fixture character"); + assert!( + chars.next().is_none(), + "fixture must contain one character: {value}" + ); + KeyCode::Char(ch) + } + value if value.starts_with("f:") => KeyCode::F( + value + .trim_start_matches("f:") + .parse::() + .expect("fixture function key"), + ), + other => panic!("unsupported fixture key code: {other}"), + } +} + +fn parse_modifiers(value: &str) -> KeyModifiers { + if value == "-" || value.is_empty() { + return KeyModifiers::empty(); + } + + let mut modifiers = KeyModifiers::empty(); + for part in value.split('+') { + modifiers |= match part { + "shift" => KeyModifiers::SHIFT, + "alt" => KeyModifiers::ALT, + "control" => KeyModifiers::CONTROL, + "super" => KeyModifiers::SUPER, + "hyper" => KeyModifiers::HYPER, + "meta" => KeyModifiers::META, + other => panic!("unsupported fixture modifier: {other}"), + }; + } + modifiers +} + +fn parse_kind(value: &str) -> KeyEventKind { + match value { + "press" => KeyEventKind::Press, + "repeat" => KeyEventKind::Repeat, + "release" => KeyEventKind::Release, + other => panic!("unsupported fixture kind: {other}"), + } +} + +#[test] +fn blank_generated_text_fixture_field_is_absent() { + let cases = keyboard_corpus_cases("legacy\t61\tchar:a\t-\tpress\t\t\tlegacy\t61"); + + assert_eq!(cases.len(), 1); + assert_eq!(cases[0].generated_text, None); +} diff --git a/src/pane.rs b/src/pane.rs index 130c75e58c..460fdeb2ee 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -2729,8 +2729,7 @@ impl PaneRuntime { } pub fn encode_terminal_key(&self, key: crate::input::TerminalKey) -> Vec { - self.terminal - .encode_terminal_key(key, self.keyboard_protocol()) + self.terminal.encode_terminal_key(key) } pub async fn send_bytes(&self, bytes: Bytes) -> Result<(), mpsc::error::SendError> { @@ -3486,6 +3485,7 @@ mod tests { mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Sgr, mouse_alternate_scroll: true, modify_other_keys: true, + modify_other_keys_mode: Some(crate::input::ModifyOtherKeysMode::Mode2), color_scheme_reporting: true, }) ); diff --git a/src/pane/input.rs b/src/pane/input.rs index 2ff299c958..4c28f1f3c9 100644 --- a/src/pane/input.rs +++ b/src/pane/input.rs @@ -1,7 +1,67 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum GhosttyKeyEventAdapterError { + UnsupportedKey, + EventAllocation, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct PaneKeyEncodingPolicy { + pub(super) kitty_keyboard: bool, + pub(super) modify_other_keys_mode: Option, +} + +pub(super) fn normalize_terminal_key_for_pane( + mut key: crate::input::TerminalKey, + policy: PaneKeyEncodingPolicy, +) -> crate::input::TerminalKey { + use crossterm::event::{KeyCode, KeyModifiers}; + + if policy.kitty_keyboard { + return key; + } + + if key.code == KeyCode::Tab && key.modifiers == KeyModifiers::CONTROL { + key.modifiers.remove(KeyModifiers::CONTROL); + } + + if policy.modify_other_keys_mode != Some(crate::input::ModifyOtherKeysMode::Mode2) + && key.modifiers.contains(KeyModifiers::CONTROL) + && ghostty_shift_is_consumed(&key) + { + if key.shifted_codepoint.is_none() { + if let KeyCode::Char(shifted) = key.code { + let unshifted = ghostty_unshifted_ascii_pair(shifted).or_else(|| { + shifted + .is_ascii_uppercase() + .then(|| shifted.to_ascii_lowercase()) + }); + if let Some(unshifted) = unshifted { + key.code = KeyCode::Char(unshifted); + key.shifted_codepoint = Some(shifted as u32); + } + } + } + key.modifiers.remove(KeyModifiers::SHIFT); + } + + if key.code == KeyCode::Char('-') + && key.modifiers.contains(KeyModifiers::CONTROL) + && key.is_windows_ctrl_minus_alias() + { + key.code = KeyCode::Char('_'); + } + + key +} + pub(super) fn ghostty_key_event_from_terminal_key( key: &crate::input::TerminalKey, -) -> Option { - let mut event = crate::ghostty::KeyEvent::new().ok()?; +) -> Result { + let ghostty_key = + ghostty_key_from_crossterm_key_code(key.code, key.shifted_codepoint, key.modifiers) + .ok_or(GhosttyKeyEventAdapterError::UnsupportedKey)?; + let mut event = crate::ghostty::KeyEvent::new() + .map_err(|_| GhosttyKeyEventAdapterError::EventAllocation)?; event.set_action(match key.kind { crossterm::event::KeyEventKind::Press => { crate::ghostty::ffi::GhosttyKeyAction_GHOSTTY_KEY_ACTION_PRESS @@ -19,10 +79,8 @@ pub(super) fn ghostty_key_event_from_terminal_key( mods |= crate::ghostty::MOD_SHIFT; } event.set_mods(mods); - event.set_key(ghostty_key_from_crossterm_key_code( - key.code, - key.shifted_codepoint, - )?); + event.set_consumed_mods(ghostty_consumed_mods(key)); + event.set_key(ghostty_key); if let Some(text) = ghostty_key_text(key) { event.set_utf8(&text); @@ -33,12 +91,14 @@ pub(super) fn ghostty_key_event_from_terminal_key( if let Some(codepoint) = ghostty_unshifted_codepoint(key) { event.set_unshifted_codepoint(codepoint); } + if let Some(codepoint) = key.shifted_codepoint { + event.set_shifted_codepoint(codepoint); + } + if let Some(codepoint) = key.base_layout_codepoint { + event.set_base_layout_codepoint(codepoint); + } - Some(event) -} - -pub(super) fn ghostty_prefers_herdr_text_encoding(key: &crate::input::TerminalKey) -> bool { - matches!(key.code, crossterm::event::KeyCode::Char(_)) + Ok(event) } pub(super) fn ghostty_mods_from_key_modifiers(modifiers: crossterm::event::KeyModifiers) -> u16 { @@ -55,9 +115,40 @@ pub(super) fn ghostty_mods_from_key_modifiers(modifiers: crossterm::event::KeyMo if modifiers.contains(crossterm::event::KeyModifiers::SUPER) { ghostty_mods |= crate::ghostty::MOD_SUPER; } + if modifiers.contains(crossterm::event::KeyModifiers::HYPER) { + ghostty_mods |= crate::ghostty::MOD_HYPER; + } + if modifiers.contains(crossterm::event::KeyModifiers::META) { + ghostty_mods |= crate::ghostty::MOD_META; + } ghostty_mods } +fn ghostty_consumed_mods(key: &crate::input::TerminalKey) -> u16 { + if ghostty_shift_is_consumed(key) { + crate::ghostty::MOD_SHIFT + } else { + 0 + } +} + +fn ghostty_shift_is_consumed(key: &crate::input::TerminalKey) -> bool { + let only_shift = key.modifiers == crossterm::event::KeyModifiers::SHIFT; + let has_generated_text = key + .generated_text + .as_ref() + .is_some_and(|text| !text.is_empty()); + key.modifiers + .contains(crossterm::event::KeyModifiers::SHIFT) + && matches!(key.code, crossterm::event::KeyCode::Char(c) if + has_generated_text + || key.shifted_codepoint.and_then(char::from_u32).is_some() + || c.is_ascii_uppercase() + || (only_shift + && (c.is_ascii_alphabetic() + || ghostty_unshifted_ascii_pair(c).is_some()))) +} + pub(super) fn ghostty_mouse_encoder_for_terminal( terminal: &crate::ghostty::Terminal, position: crate::input::mouse::Position, @@ -194,10 +285,18 @@ pub(super) fn ghostty_mouse_event_from_wheel_kind( } fn ghostty_key_text(key: &crate::input::TerminalKey) -> Option { + if let Some(text) = &key.generated_text { + return Some(text.clone()); + } match key.code { crossterm::event::KeyCode::Char(c) => Some( key.shifted_codepoint .and_then(char::from_u32) + .or_else(|| { + (key.modifiers == crossterm::event::KeyModifiers::SHIFT) + .then(|| c.is_ascii_lowercase().then(|| c.to_ascii_uppercase())) + .flatten() + }) .unwrap_or(c) .to_string(), ), @@ -207,6 +306,14 @@ fn ghostty_key_text(key: &crate::input::TerminalKey) -> Option { fn ghostty_unshifted_codepoint(key: &crate::input::TerminalKey) -> Option { match key.code { + crossterm::event::KeyCode::Char(c) + if key.shifted_codepoint.is_none() + && key + .modifiers + .contains(crossterm::event::KeyModifiers::SHIFT) => + { + Some(ghostty_unshifted_ascii_pair(c).unwrap_or_else(|| c.to_ascii_lowercase()) as u32) + } crossterm::event::KeyCode::Char(c) => Some(c as u32), _ => None, } @@ -215,6 +322,7 @@ fn ghostty_unshifted_codepoint(key: &crate::input::TerminalKey) -> Option { fn ghostty_key_from_crossterm_key_code( code: crossterm::event::KeyCode, shifted_codepoint: Option, + modifiers: crossterm::event::KeyModifiers, ) -> Option { use crate::ghostty::ffi; use crossterm::event::KeyCode; @@ -247,18 +355,47 @@ fn ghostty_key_from_crossterm_key_code( 10 => ffi::GhosttyKey_GHOSTTY_KEY_F10, 11 => ffi::GhosttyKey_GHOSTTY_KEY_F11, 12 => ffi::GhosttyKey_GHOSTTY_KEY_F12, + 13 => ffi::GhosttyKey_GHOSTTY_KEY_F13, + 14 => ffi::GhosttyKey_GHOSTTY_KEY_F14, + 15 => ffi::GhosttyKey_GHOSTTY_KEY_F15, + 16 => ffi::GhosttyKey_GHOSTTY_KEY_F16, + 17 => ffi::GhosttyKey_GHOSTTY_KEY_F17, + 18 => ffi::GhosttyKey_GHOSTTY_KEY_F18, + 19 => ffi::GhosttyKey_GHOSTTY_KEY_F19, + 20 => ffi::GhosttyKey_GHOSTTY_KEY_F20, + 21 => ffi::GhosttyKey_GHOSTTY_KEY_F21, + 22 => ffi::GhosttyKey_GHOSTTY_KEY_F22, + 23 => ffi::GhosttyKey_GHOSTTY_KEY_F23, + 24 => ffi::GhosttyKey_GHOSTTY_KEY_F24, + 25 => ffi::GhosttyKey_GHOSTTY_KEY_F25, + 26 => ffi::GhosttyKey_GHOSTTY_KEY_F26, + 27 => ffi::GhosttyKey_GHOSTTY_KEY_F27, + 28 => ffi::GhosttyKey_GHOSTTY_KEY_F28, + 29 => ffi::GhosttyKey_GHOSTTY_KEY_F29, + 30 => ffi::GhosttyKey_GHOSTTY_KEY_F30, + 31 => ffi::GhosttyKey_GHOSTTY_KEY_F31, + 32 => ffi::GhosttyKey_GHOSTTY_KEY_F32, + 33 => ffi::GhosttyKey_GHOSTTY_KEY_F33, + 34 => ffi::GhosttyKey_GHOSTTY_KEY_F34, + 35 => ffi::GhosttyKey_GHOSTTY_KEY_F35, _ => return None, }), - KeyCode::Char(c) => ghostty_key_from_char(c, shifted_codepoint), + KeyCode::Char(c) => ghostty_key_from_char(c, shifted_codepoint, modifiers), _ => None, } } -fn ghostty_key_from_char(c: char, shifted_codepoint: Option) -> Option { +fn ghostty_key_from_char( + c: char, + shifted_codepoint: Option, + modifiers: crossterm::event::KeyModifiers, +) -> Option { use crate::ghostty::ffi; - let base = if let Some(shifted) = shifted_codepoint.and_then(char::from_u32) { - ghostty_unshifted_ascii_pair(shifted).unwrap_or(c) + let base = if shifted_codepoint.is_none() + && modifiers.contains(crossterm::event::KeyModifiers::SHIFT) + { + ghostty_unshifted_ascii_pair(c).unwrap_or(c) } else { c }; @@ -312,7 +449,7 @@ fn ghostty_key_from_char(c: char, shifted_codepoint: Option) -> Option ';' => Some(ffi::GhosttyKey_GHOSTTY_KEY_SEMICOLON), '/' => Some(ffi::GhosttyKey_GHOSTTY_KEY_SLASH), ' ' => Some(ffi::GhosttyKey_GHOSTTY_KEY_SPACE), - _ => None, + _ => Some(ffi::GhosttyKey_GHOSTTY_KEY_UNIDENTIFIED), } } @@ -342,3 +479,117 @@ fn ghostty_unshifted_ascii_pair(c: char) -> Option { _ => return None, }) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::input::{ModifyOtherKeysMode, TerminalKey, WindowsKeyRecord}; + use crossterm::event::{KeyCode, KeyModifiers}; + + const LEGACY: PaneKeyEncodingPolicy = PaneKeyEncodingPolicy { + kitty_keyboard: false, + modify_other_keys_mode: None, + }; + + #[test] + fn legacy_ctrl_tab_policy_only_downgrades_exact_ctrl_tab() { + let ctrl_tab = TerminalKey::new(KeyCode::Tab, KeyModifiers::CONTROL); + let normalized = normalize_terminal_key_for_pane(ctrl_tab.clone(), LEGACY); + assert_eq!(normalized.modifiers, KeyModifiers::empty()); + + for (key, policy) in [ + ( + TerminalKey::new(KeyCode::Tab, KeyModifiers::CONTROL | KeyModifiers::SHIFT), + LEGACY, + ), + ( + ctrl_tab, + PaneKeyEncodingPolicy { + kitty_keyboard: true, + modify_other_keys_mode: None, + }, + ), + ] { + let expected = key.clone(); + assert_eq!(normalize_terminal_key_for_pane(key, policy), expected); + } + } + + #[test] + fn consumed_shift_is_downgraded_only_for_legacy_control_encoding() { + let key = TerminalKey::new( + KeyCode::Char('C'), + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + ); + + for mode in [None, Some(ModifyOtherKeysMode::Mode1)] { + let normalized = normalize_terminal_key_for_pane( + key.clone(), + PaneKeyEncodingPolicy { + kitty_keyboard: false, + modify_other_keys_mode: mode, + }, + ); + assert_eq!(normalized.code, KeyCode::Char('c')); + assert_eq!(normalized.shifted_codepoint, Some('C' as u32)); + assert_eq!(normalized.modifiers, KeyModifiers::CONTROL); + } + + for policy in [ + PaneKeyEncodingPolicy { + kitty_keyboard: false, + modify_other_keys_mode: Some(ModifyOtherKeysMode::Mode2), + }, + PaneKeyEncodingPolicy { + kitty_keyboard: true, + modify_other_keys_mode: None, + }, + ] { + assert_eq!(normalize_terminal_key_for_pane(key.clone(), policy), key); + } + } + + #[test] + fn windows_ctrl_minus_alias_requires_matching_console_provenance() { + const LEFT_CTRL_PRESSED: u32 = 0x0008; + let record = WindowsKeyRecord { + key_down: true, + repeat_count: 1, + virtual_key_code: 0xbd, + virtual_scan_code: 0x0c, + unicode: 0x1f, + control_key_state: LEFT_CTRL_PRESSED, + }; + let windows_key = + TerminalKey::new(KeyCode::Char('-'), KeyModifiers::CONTROL).with_windows_record(record); + assert_eq!( + normalize_terminal_key_for_pane(windows_key.clone(), LEGACY).code, + KeyCode::Char('_') + ); + + let synthetic = TerminalKey::new(KeyCode::Char('-'), KeyModifiers::CONTROL); + assert_eq!( + normalize_terminal_key_for_pane(synthetic.clone(), LEGACY), + synthetic + ); + + let kitty = PaneKeyEncodingPolicy { + kitty_keyboard: true, + modify_other_keys_mode: None, + }; + assert_eq!( + normalize_terminal_key_for_pane(windows_key.clone(), kitty), + windows_key + ); + + let non_alias = TerminalKey::new(KeyCode::Char('-'), KeyModifiers::CONTROL) + .with_windows_record(WindowsKeyRecord { + unicode: b'-' as u16, + ..record + }); + assert_eq!( + normalize_terminal_key_for_pane(non_alias.clone(), LEGACY), + non_alias + ); + } +} diff --git a/src/pane/kitty_keyboard.rs b/src/pane/kitty_keyboard.rs index 30ab62ba76..8bca2f66df 100644 --- a/src/pane/kitty_keyboard.rs +++ b/src/pane/kitty_keyboard.rs @@ -3,8 +3,7 @@ pub(crate) struct KittyKeyboardTracker { pending: Vec, stack: Vec, flags: u16, - #[cfg(windows)] - modify_other_keys: bool, + modify_other_keys_mode: Option, } impl KittyKeyboardTracker { @@ -23,7 +22,7 @@ impl KittyKeyboardTracker { &combined }; let mut index = 0; - while index < bytes.len() { + 'scan: while index < bytes.len() { if bytes[index] != 0x1b { index += 1; continue; @@ -32,9 +31,8 @@ impl KittyKeyboardTracker { self.store_pending(&bytes[index..]); break; } - #[cfg(windows)] if bytes[index + 1] == b'c' { - self.modify_other_keys = false; + self.modify_other_keys_mode = None; } if bytes[index + 1] != b'[' { index += 1; @@ -43,6 +41,10 @@ impl KittyKeyboardTracker { let mut end = index + 2; while end < bytes.len() && !(0x40..=0x7e).contains(&bytes[end]) { + if bytes[end] == 0x1b { + index = end; + continue 'scan; + } end += 1; } if end >= bytes.len() { @@ -52,16 +54,14 @@ impl KittyKeyboardTracker { match bytes[end] { b'u' => self.observe_csi_u(&bytes[index + 2..end]), - #[cfg(windows)] b'm' => self.observe_modify_other_keys(&bytes[index + 2..end]), - #[cfg(windows)] b'n' if bytes[index + 2..end] .strip_prefix(b">") .is_some_and(|params| { !params.contains(&b';') && parse_kitty_keyboard_flags(params) == 4 }) => { - self.modify_other_keys = false; + self.modify_other_keys_mode = None; } _ => {} } @@ -69,18 +69,21 @@ impl KittyKeyboardTracker { } } + pub(crate) fn modify_other_keys_mode(&self) -> Option { + self.modify_other_keys_mode + } + #[cfg(windows)] pub(crate) fn modify_other_keys_enabled(&self) -> bool { - self.modify_other_keys + self.modify_other_keys_mode.is_some() } - #[cfg(windows)] fn observe_modify_other_keys(&mut self, params: &[u8]) { let Some(params) = params.strip_prefix(b">") else { return; }; if params.is_empty() { - self.modify_other_keys = false; + self.modify_other_keys_mode = None; return; } @@ -90,10 +93,18 @@ impl KittyKeyboardTracker { if parts.next().is_some() { return; } - if parse_kitty_keyboard_flags(resource) == 4 { - self.modify_other_keys = - value.is_some_and(|value| parse_kitty_keyboard_flags(value) != 0); + if parse_kitty_keyboard_flags(resource) != 4 { + return; } + self.modify_other_keys_mode = match value { + None | Some([]) => None, + Some(value) => match parse_decimal(value) { + Some(0) => None, + Some(1) => Some(crate::input::ModifyOtherKeysMode::Mode1), + Some(2) => Some(crate::input::ModifyOtherKeysMode::Mode2), + Some(_) | None => return, + }, + }; } fn store_pending(&mut self, bytes: &[u8]) { @@ -146,16 +157,64 @@ impl KittyKeyboardTracker { fn parse_kitty_keyboard_flags(bytes: &[u8]) -> u16 { let first_param = bytes.split(|byte| *byte == b';').next().unwrap_or_default(); - std::str::from_utf8(first_param) - .ok() - .and_then(|value| value.parse::().ok()) - .unwrap_or(0) + parse_decimal(first_param).unwrap_or(0) +} + +fn parse_decimal(bytes: &[u8]) -> Option { + if bytes.is_empty() || !bytes.iter().all(u8::is_ascii_digit) { + return None; + } + std::str::from_utf8(bytes).ok()?.parse().ok() } #[cfg(test)] mod tests { use super::*; + #[test] + fn tracks_exact_modify_other_keys_mode() { + let mut tracker = KittyKeyboardTracker::default(); + + tracker.observe(b"\x1b[>4;1m"); + assert_eq!( + tracker.modify_other_keys_mode(), + Some(crate::input::ModifyOtherKeysMode::Mode1) + ); + tracker.observe(b"\x1b[>4;2m"); + assert_eq!( + tracker.modify_other_keys_mode(), + Some(crate::input::ModifyOtherKeysMode::Mode2) + ); + for malformed in [ + b"\x1b[>4;999999m".as_slice(), + b"\x1b[>4;+1m", + b"\x1b[>+4;1m", + ] { + tracker.observe(malformed); + assert_eq!( + tracker.modify_other_keys_mode(), + Some(crate::input::ModifyOtherKeysMode::Mode2) + ); + } + tracker.observe(b"\x1b[>4;0m"); + assert_eq!(tracker.modify_other_keys_mode(), None); + } + + #[test] + fn interrupted_split_csi_does_not_hide_following_reset_or_mode() { + let mut tracker = KittyKeyboardTracker::default(); + tracker.observe(b"\x1b[>4;2m\x1b[>4;"); + + tracker.observe(b"\x1bc"); + assert_eq!(tracker.modify_other_keys_mode(), None); + + tracker.observe(b"\x1b[>4;\x1b[>4;1m"); + assert_eq!( + tracker.modify_other_keys_mode(), + Some(crate::input::ModifyOtherKeysMode::Mode1) + ); + } + #[test] fn buffers_split_csi_sequences() { let mut tracker = KittyKeyboardTracker::default(); @@ -166,13 +225,16 @@ mod tests { assert_eq!(tracker.flags, 1); assert_eq!(tracker.stack, vec![0]); - #[cfg(windows)] - { - assert!(tracker.modify_other_keys_enabled()); - tracker.observe(b"\x1b[>1m"); - assert!(tracker.modify_other_keys_enabled()); - tracker.observe(b"\x1b[>04n"); - assert!(!tracker.modify_other_keys_enabled()); - } + assert_eq!( + tracker.modify_other_keys_mode(), + Some(crate::input::ModifyOtherKeysMode::Mode1) + ); + tracker.observe(b"\x1b[>1m"); + assert_eq!( + tracker.modify_other_keys_mode(), + Some(crate::input::ModifyOtherKeysMode::Mode1) + ); + tracker.observe(b"\x1b[>04n"); + assert_eq!(tracker.modify_other_keys_mode(), None); } } diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index 9575ad6f84..f6b390f9b1 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -24,7 +24,7 @@ use super::{ ghostty_key_event_from_terminal_key, ghostty_mouse_encoder_for_terminal, ghostty_mouse_event_from_button_kind, ghostty_mouse_event_from_motion_kind, ghostty_mouse_event_from_wheel_kind, ghostty_mouse_position_for_terminal, - ghostty_prefers_herdr_text_encoding, + normalize_terminal_key_for_pane, GhosttyKeyEventAdapterError, PaneKeyEncodingPolicy, }, kitty_keyboard::KittyKeyboardTracker, osc::{ @@ -122,8 +122,12 @@ pub struct InputState { pub mouse_protocol_mode: crate::input::MouseProtocolMode, pub mouse_protocol_encoding: crate::input::MouseProtocolEncoding, pub mouse_alternate_scroll: bool, + /// Mode 2 summary retained for older handoff payloads and callers that need report-all behavior. #[serde(default)] pub modify_other_keys: bool, + /// Exact mode used for encoding and handoff; absent legacy payloads fall back to mode 2. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub modify_other_keys_mode: Option, #[serde(default)] pub color_scheme_reporting: bool, } @@ -158,6 +162,21 @@ pub(crate) struct TerminalReadSnapshot { pub truncated: bool, } +#[derive(Debug, PartialEq, Eq)] +enum KeyEncodingOutcome { + Encoded(Vec), + Suppressed, + Unavailable(KeyEncodingUnavailable), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum KeyEncodingUnavailable { + Adapter(GhosttyKeyEventAdapterError), + TerminalLockPoisoned, + EncoderLockPoisoned, + EncoderError, +} + pub(crate) struct GhosttyPaneTerminal { pub core: Mutex, key_encoder: Mutex, @@ -534,12 +553,8 @@ impl PaneTerminal { .filter(|ansi| !ansi.is_empty()) } - pub fn encode_terminal_key( - &self, - key: crate::input::TerminalKey, - protocol: crate::input::KeyboardProtocol, - ) -> Vec { - self.ghostty.encode_terminal_key(key, protocol) + pub fn encode_terminal_key(&self, key: crate::input::TerminalKey) -> Vec { + self.ghostty.encode_terminal_key(key) } pub(crate) fn encode_mouse_button( @@ -1019,13 +1034,17 @@ impl GhosttyPaneTerminal { let mut key_encoder = crate::ghostty::KeyEncoder::new().map_err(|e| std::io::Error::other(e.to_string()))?; key_encoder.set_from_terminal(&terminal); + let mut kitty_keyboard = KittyKeyboardTracker::default(); + if let Ok(ansi) = terminal.keyboard_state_ansi() { + kitty_keyboard.observe(ansi.as_bytes()); + } Ok(Self { core: Mutex::new(GhosttyPaneCore { terminal, #[cfg(windows)] recent_fallback: windows_recent_fallback::Cache::default(), render_state, - kitty_keyboard: KittyKeyboardTracker::default(), + kitty_keyboard, initial_default_foreground, initial_default_background, host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), @@ -1422,7 +1441,6 @@ impl GhosttyPaneTerminal { let Ok(mut core) = self.core.lock() else { return; }; - #[cfg(windows)] core.kitty_keyboard.observe(ansi.as_bytes()); core.terminal.write(ansi.as_bytes()); #[cfg(windows)] @@ -1507,8 +1525,14 @@ impl GhosttyPaneTerminal { } } - if input_state.modify_other_keys { - core.terminal.write(b"\x1b[>4;2m"); + let modify_other_keys_mode = input_state.modify_other_keys_mode.or_else(|| { + input_state + .modify_other_keys + .then_some(crate::input::ModifyOtherKeysMode::Mode2) + }); + if let Some(mode) = modify_other_keys_mode { + core.kitty_keyboard.observe(mode.set_sequence()); + core.terminal.write(mode.set_sequence()); } if let Ok(mut key_encoder) = self.key_encoder.lock() { @@ -1739,14 +1763,9 @@ impl GhosttyPaneTerminal { mouse_protocol_mode, mouse_protocol_encoding, mouse_alternate_scroll, - #[cfg(windows)] - modify_other_keys: core.kitty_keyboard.modify_other_keys_enabled(), - #[cfg(not(windows))] - modify_other_keys: core - .terminal - .keyboard_state_ansi() - .ok() - .is_some_and(|ansi| !ansi.is_empty()), + modify_other_keys: core.kitty_keyboard.modify_other_keys_mode() + == Some(crate::input::ModifyOtherKeysMode::Mode2), + modify_other_keys_mode: core.kitty_keyboard.modify_other_keys_mode(), color_scheme_reporting: core .terminal .mode_get(crate::ghostty::MODE_COLOR_SCHEME_REPORT) @@ -1795,11 +1814,7 @@ impl GhosttyPaneTerminal { .unwrap_or(false) } - pub fn encode_terminal_key( - &self, - key: crate::input::TerminalKey, - protocol: crate::input::KeyboardProtocol, - ) -> Vec { + pub fn encode_terminal_key(&self, key: crate::input::TerminalKey) -> Vec { #[cfg(windows)] if self.core.lock().is_ok_and(|core| { core.terminal @@ -1814,10 +1829,10 @@ impl GhosttyPaneTerminal { let repeat_count = key.repeat_count; let first = key.with_repeat_count(1); - let mut bytes = self.encode_terminal_key_once(first.clone(), protocol); + let mut bytes = self.encode_terminal_key_once(first.clone()); if repeat_count > 1 && first.kind != crossterm::event::KeyEventKind::Release { let repeated = first.with_kind(crossterm::event::KeyEventKind::Repeat); - let repeated_bytes = self.encode_terminal_key_once(repeated, protocol); + let repeated_bytes = self.encode_terminal_key_once(repeated); for _ in 1..repeat_count { bytes.extend_from_slice(&repeated_bytes); } @@ -1825,37 +1840,55 @@ impl GhosttyPaneTerminal { bytes } - fn encode_terminal_key_once( - &self, - key: crate::input::TerminalKey, - protocol: crate::input::KeyboardProtocol, - ) -> Vec { - if matches!(protocol, crate::input::KeyboardProtocol::Legacy) - && key.code == crossterm::event::KeyCode::Tab - && key.modifiers == crossterm::event::KeyModifiers::CONTROL - { - return crate::input::encode_terminal_key(key, protocol); + fn encode_terminal_key_once(&self, key: crate::input::TerminalKey) -> Vec { + match self.encode_terminal_key_once_outcome(key) { + KeyEncodingOutcome::Encoded(bytes) => bytes, + KeyEncodingOutcome::Suppressed => Vec::new(), + KeyEncodingOutcome::Unavailable(reason) => { + log_key_encoding_unavailable(reason); + Vec::new() + } } + } - if ghostty_prefers_herdr_text_encoding(&key) { - return crate::input::encode_terminal_key(key, protocol); + fn encode_terminal_key_once_outcome( + &self, + key: crate::input::TerminalKey, + ) -> KeyEncodingOutcome { + if key.is_text_commit() { + return key + .generated_text + .map(|text| KeyEncodingOutcome::Encoded(text.into_bytes())) + .unwrap_or(KeyEncodingOutcome::Suppressed); } - let Some(event) = ghostty_key_event_from_terminal_key(&key) else { - return crate::input::encode_terminal_key(key, protocol); + let Ok(core) = self.core.lock() else { + return KeyEncodingOutcome::Unavailable(KeyEncodingUnavailable::TerminalLockPoisoned); + }; + let key = normalize_terminal_key_for_pane( + key, + PaneKeyEncodingPolicy { + kitty_keyboard: core + .terminal + .kitty_keyboard_flags() + .is_ok_and(|flags| flags != 0), + modify_other_keys_mode: core.kitty_keyboard.modify_other_keys_mode(), + }, + ); + let event = match ghostty_key_event_from_terminal_key(&key) { + Ok(event) => event, + Err(error) => { + return KeyEncodingOutcome::Unavailable(KeyEncodingUnavailable::Adapter(error)); + } }; let Ok(mut encoder) = self.key_encoder.lock() else { - return crate::input::encode_terminal_key(key, protocol); + return KeyEncodingOutcome::Unavailable(KeyEncodingUnavailable::EncoderLockPoisoned); }; match encoder.encode(&event) { - Ok(bytes) - if !bytes.is_empty() - && encoded_key_preserves_event_kind(&bytes, &key, protocol) => - { - bytes - } - Ok(_) | Err(_) => crate::input::encode_terminal_key(key, protocol), + Ok(bytes) if bytes.is_empty() => KeyEncodingOutcome::Suppressed, + Ok(bytes) => KeyEncodingOutcome::Encoded(bytes), + Err(_) => KeyEncodingOutcome::Unavailable(KeyEncodingUnavailable::EncoderError), } } @@ -2164,21 +2197,34 @@ impl GhosttyPaneTerminal { } } -fn encoded_key_preserves_event_kind( - bytes: &[u8], - key: &crate::input::TerminalKey, - protocol: crate::input::KeyboardProtocol, -) -> bool { - if !protocol.reports_event_types() || key.kind == crossterm::event::KeyEventKind::Press { - return true; - } +fn log_key_encoding_unavailable(reason: KeyEncodingUnavailable) { + use std::sync::atomic::{AtomicU64, Ordering}; - std::str::from_utf8(bytes) - .ok() - .and_then(crate::input::parse_terminal_key_sequence) - .is_some_and(|parsed| { - parsed.code == key.code && parsed.modifiers == key.modifiers && parsed.kind == key.kind - }) + const LOG_INTERVAL: u64 = 1024; + static EVENT_ALLOCATION_FAILURES: AtomicU64 = AtomicU64::new(0); + static TERMINAL_LOCK_FAILURES: AtomicU64 = AtomicU64::new(0); + static ENCODER_LOCK_FAILURES: AtomicU64 = AtomicU64::new(0); + static ENCODER_ERROR_FAILURES: AtomicU64 = AtomicU64::new(0); + + let failures = match reason { + KeyEncodingUnavailable::Adapter(GhosttyKeyEventAdapterError::UnsupportedKey) => { + debug!(?reason, "Ghostty key encoding unavailable; suppressing key"); + return; + } + KeyEncodingUnavailable::Adapter(GhosttyKeyEventAdapterError::EventAllocation) => { + &EVENT_ALLOCATION_FAILURES + } + KeyEncodingUnavailable::TerminalLockPoisoned => &TERMINAL_LOCK_FAILURES, + KeyEncodingUnavailable::EncoderLockPoisoned => &ENCODER_LOCK_FAILURES, + KeyEncodingUnavailable::EncoderError => &ENCODER_ERROR_FAILURES, + }; + let count = failures.fetch_add(1, Ordering::Relaxed) + 1; + if count == 1 || count.is_multiple_of(LOG_INTERVAL) { + error!( + ?reason, + count, "Ghostty key encoding failed; suppressing key" + ); + } } fn cursor_position_settle_pending(core: &GhosttyPaneCore) -> bool { @@ -3267,6 +3313,191 @@ mod tests { use ratatui::{layout::Rect, style::Color}; use tokio::sync::mpsc; + fn raw_events_from_host_chunks( + chunks: &[&[u8]], + case_name: &str, + assert_no_early_event: bool, + ) -> Vec { + let mut host_framer = crate::raw_input::RawInputByteFramer::for_host_input(); + let mut server_framer = crate::raw_input::RawInputFramer::default(); + let mut events = Vec::new(); + + for (index, chunk) in chunks.iter().enumerate() { + let mut chunk_events = Vec::new(); + for framed in host_framer.push(chunk) { + chunk_events.extend(server_framer.push(&framed)); + } + if assert_no_early_event && index + 1 < chunks.len() { + assert!( + chunk_events.is_empty(), + "{case_name} emitted before its final fragment at chunk {index}" + ); + } + events.extend(chunk_events); + } + for framed in host_framer.flush_timeout() { + events.extend(server_framer.push(&framed)); + } + events.extend(server_framer.flush_timeout()); + assert!( + !host_framer.has_pending_input(), + "{case_name} left pending host input" + ); + assert!( + !server_framer.has_pending_input(), + "{case_name} left pending server input" + ); + events + } + + fn corpus_key_from_chunks( + case: &crate::input::test_support::KeyboardCorpusCase<'_>, + chunks: &[&[u8]], + ) -> crate::input::TerminalKey { + let mut events = raw_events_from_host_chunks(chunks, case.family, true); + assert_eq!(events.len(), 1, "{} event count", case.family); + + let crate::raw_input::RawInputEvent::Key(key) = events.remove(0) else { + panic!("{} did not produce a key", case.family); + }; + case.assert_key_semantics(&key); + assert_eq!( + key.generated_text, case.generated_text, + "{} generated text", + case.family + ); + assert_eq!( + key.vt_bytes(), + Some(case.input.as_slice()), + "{} source bytes", + case.family + ); + key + } + + fn corpus_pane(profile: &str) -> GhosttyPaneTerminal { + let setup = match profile { + "legacy" => b"".as_slice(), + "application_cursor" => b"\x1b[?1h".as_slice(), + "application_keypad" => b"\x1b=".as_slice(), + "backarrow" => b"\x1b[?67h".as_slice(), + "modify_other_keys_1" => b"\x1b[>4;1m".as_slice(), + "modify_other_keys_2" => b"\x1b[>4;2m".as_slice(), + "kitty_1" => b"\x1b[>1u".as_slice(), + "kitty_3" => b"\x1b[>3u".as_slice(), + "kitty_5" => b"\x1b[>5u".as_slice(), + "kitty_7" => b"\x1b[>7u".as_slice(), + "kitty_11" => b"\x1b[>11u".as_slice(), + "kitty_13" => b"\x1b[>13u".as_slice(), + "kitty_15" => b"\x1b[>15u".as_slice(), + "kitty_25" => b"\x1b[>25u".as_slice(), + "kitty_31" => b"\x1b[>31u".as_slice(), + other => panic!("unsupported pane keyboard profile: {other}"), + }; + let (tx, _rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(); + if !setup.is_empty() { + pane.process_pty_bytes(PaneId::from_raw(1), 0, setup, &tx); + } + + match profile { + "application_cursor" => { + assert!( + pane.input_state() + .expect("pane input state") + .application_cursor + ); + } + "application_keypad" => { + let core = pane.core.lock().unwrap(); + assert!( + core.terminal.mode_get(66).unwrap(), + "application keypad mode" + ); + } + "backarrow" => { + let core = pane.core.lock().unwrap(); + assert!(core.terminal.mode_get(67).unwrap(), "backarrow mode"); + } + profile if profile.starts_with("kitty_") => { + let expected = profile.trim_start_matches("kitty_").parse::().unwrap(); + assert_eq!( + pane.keyboard_protocol(), + Some(crate::input::KeyboardProtocol::Kitty { flags: expected }) + ); + } + "legacy" => { + assert_eq!( + pane.keyboard_protocol(), + Some(crate::input::KeyboardProtocol::Legacy) + ); + } + _ => {} + } + pane + } + + #[test] + fn keyboard_corpus_survives_fragmentation_and_pane_encoding() { + let corpus = include_str!("../../tests/fixtures/keyboard_protocol_corpus.tsv"); + for case in crate::input::test_support::keyboard_corpus_cases(corpus) { + let whole = corpus_key_from_chunks(&case, &[case.input.as_slice()]); + + for split in 1..case.input.len() { + corpus_key_from_chunks(&case, &[&case.input[..split], &case.input[split..]]); + } + let byte_chunks: Vec<_> = case.input.chunks(1).collect(); + corpus_key_from_chunks(&case, &byte_chunks); + + let pane = corpus_pane(case.pane_profile); + let encoded = pane.encode_terminal_key(whole); + assert_eq!( + encoded, + crate::input::test_support::decode_hex(case.expected_pane_hex), + "{} pane output for {}", + case.family, + case.pane_profile + ); + } + } + + #[test] + fn keyboard_corpus_preserves_coalesced_event_order_and_sources() { + let corpus = include_str!("../../tests/fixtures/keyboard_protocol_corpus.tsv"); + let cases = crate::input::test_support::keyboard_corpus_cases(corpus); + let selected: Vec<_> = ["legacy_ctrl_b", "kitty_shift_letter", "kitty_alt_backspace"] + .into_iter() + .map(|family| { + cases + .iter() + .find(|case| case.family == family) + .unwrap_or_else(|| panic!("missing corpus case: {family}")) + }) + .collect(); + let input: Vec<_> = selected + .iter() + .flat_map(|case| case.input.iter().copied()) + .collect(); + let events = raw_events_from_host_chunks(&[&input], "coalesced keyboard corpus", false); + + assert_eq!(events.len(), selected.len()); + for (event, case) in events.into_iter().zip(selected) { + let crate::raw_input::RawInputEvent::Key(key) = event else { + panic!("{} did not produce a key", case.family); + }; + assert_eq!(key.code, case.code, "{} code", case.family); + assert_eq!(key.modifiers, case.modifiers, "{} modifiers", case.family); + assert_eq!(key.kind, case.kind, "{} kind", case.family); + assert_eq!( + key.vt_bytes(), + Some(case.input.as_slice()), + "{} source bytes", + case.family + ); + } + } + #[test] fn plain_page_keys_host_scroll_for_shell_like_decckm_with_bracketed_paste() { assert!(InputState { @@ -3278,6 +3509,7 @@ mod tests { mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Default, mouse_alternate_scroll: false, modify_other_keys: false, + modify_other_keys_mode: None, color_scheme_reporting: false, } .plain_page_keys_use_host_scrollback()); @@ -4192,17 +4424,49 @@ mod tests { let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); - let encoded = pane.encode_terminal_key( - crate::input::TerminalKey::new( - crossterm::event::KeyCode::Char('a'), - crossterm::event::KeyModifiers::empty(), - ), - crate::input::KeyboardProtocol::Legacy, - ); + let encoded = pane.encode_terminal_key(crate::input::TerminalKey::new( + crossterm::event::KeyCode::Char('a'), + crossterm::event::KeyModifiers::empty(), + )); assert_eq!(encoded, b"a"); } + #[test] + fn ghostty_adapter_normalizes_shifted_ascii_identity() { + let (tx, _rx) = mpsc::channel(4); + let mut terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); + terminal.write(b"\x1b[>13u"); + let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); + + for (character, expected) in [ + ('A', b"\x1b[97:65;2u".as_slice()), + ('!', b"\x1b[49:33;2u".as_slice()), + ] { + let key = crate::input::TerminalKey::new( + crossterm::event::KeyCode::Char(character), + crossterm::event::KeyModifiers::SHIFT, + ); + let event = ghostty_key_event_from_terminal_key(&key).unwrap(); + let encoded = pane.key_encoder.lock().unwrap().encode(&event).unwrap(); + assert_eq!(encoded, expected, "shifted {character}"); + } + + let mut events = crate::raw_input::parse_raw_input_bytes_sync(b"!"); + let crate::raw_input::RawInputEvent::Key(unmodified_bang) = events.remove(0) else { + panic!("expected key event"); + }; + let event = ghostty_key_event_from_terminal_key(&unmodified_bang).unwrap(); + let encoded = pane.key_encoder.lock().unwrap().encode(&event).unwrap(); + assert_eq!(encoded, b"\x1b[33u"); + + let non_us_shifted_pair = + crate::input::parse_terminal_key_sequence("\x1b[43:42;2u").unwrap(); + let event = ghostty_key_event_from_terminal_key(&non_us_shifted_pair).unwrap(); + let encoded = pane.key_encoder.lock().unwrap().encode(&event).unwrap(); + assert_eq!(encoded, b"\x1b[43:42;2u"); + } + #[test] fn ghostty_backtab_preserves_shift_across_keyboard_protocols() { for (kitty_flags, expected) in [ @@ -4215,16 +4479,15 @@ mod tests { terminal.write(format!("\x1b[>{flags}u").as_bytes()); } let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); - let protocol = pane.keyboard_protocol().unwrap(); for modifiers in [ crossterm::event::KeyModifiers::empty(), crossterm::event::KeyModifiers::SHIFT, ] { - let encoded = pane.encode_terminal_key( - crate::input::TerminalKey::new(crossterm::event::KeyCode::BackTab, modifiers), - protocol, - ); + let encoded = pane.encode_terminal_key(crate::input::TerminalKey::new( + crossterm::event::KeyCode::BackTab, + modifiers, + )); assert_eq!(encoded, expected, "backtab with modifiers {modifiers:?}"); } } @@ -4232,13 +4495,10 @@ mod tests { let (tx, _rx) = mpsc::channel(4); let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); - let encoded = pane.encode_terminal_key( - crate::input::TerminalKey::new( - crossterm::event::KeyCode::Tab, - crossterm::event::KeyModifiers::empty(), - ), - crate::input::KeyboardProtocol::Legacy, - ); + let encoded = pane.encode_terminal_key(crate::input::TerminalKey::new( + crossterm::event::KeyCode::Tab, + crossterm::event::KeyModifiers::empty(), + )); assert_eq!(encoded, b"\t"); } @@ -4252,18 +4512,110 @@ mod tests { crossterm::event::KeyModifiers::CONTROL, ); - assert_eq!( - legacy.encode_terminal_key(key.clone(), crate::input::KeyboardProtocol::Legacy), - b"\t" - ); + assert_eq!(legacy.encode_terminal_key(key.clone()), b"\t"); let mut terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); terminal.write(b"\x1b[>3u"); let kitty = GhosttyPaneTerminal::new(terminal, tx).unwrap(); + assert_eq!(kitty.encode_terminal_key(key), b"\x1b[9;5u"); + } + + #[test] + fn ghostty_consumed_shift_policy_respects_extended_keyboard_modes() { + let (tx, _rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); + let key = crate::input::TerminalKey::new( + crossterm::event::KeyCode::Char('c'), + crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::SHIFT, + ) + .with_shifted_codepoint('C' as u32); + + assert_eq!(pane.encode_terminal_key(key.clone()), b"\x03"); + pane.seed_history_ansi("\x1b[>4;2m"); + assert_eq!(pane.encode_terminal_key(key), b"\x1b[27;6;67~"); + } + + #[test] + fn ghostty_intentional_key_suppression_does_not_fall_back() { + let (tx, _rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); + let backspace = crate::input::TerminalKey::new( + crossterm::event::KeyCode::Backspace, + crossterm::event::KeyModifiers::empty(), + ) + .with_generated_text(Some("x".to_owned())) + .with_repeat_count(3); assert_eq!( - kitty.encode_terminal_key(key, crate::input::KeyboardProtocol::Kitty { flags: 3 }), - b"\x1b[9;5u" + pane.encode_terminal_key_once_outcome(backspace.clone()), + KeyEncodingOutcome::Suppressed ); + assert_eq!(pane.encode_terminal_key(backspace), b""); + + let enter = crate::input::TerminalKey::new( + crossterm::event::KeyCode::Enter, + crossterm::event::KeyModifiers::empty(), + ) + .with_generated_text(Some("x".to_owned())); + assert_eq!( + pane.encode_terminal_key_once_outcome(enter.clone()), + KeyEncodingOutcome::Encoded(b"x".to_vec()) + ); + assert_eq!(pane.encode_terminal_key(enter), b"x"); + } + + #[test] + fn ghostty_encodes_all_kitty_extended_function_keys() { + let (tx, _rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); + + for (function, expected) in [ + (26, b"\x1b[1;5Q".as_slice()), + (35, b"\x1b[23;5~".as_slice()), + ] { + let key = crate::input::TerminalKey::new( + crossterm::event::KeyCode::F(function), + crossterm::event::KeyModifiers::empty(), + ); + assert_eq!(pane.encode_terminal_key(key), expected); + } + } + + #[test] + fn grouped_repeats_use_the_ghostty_encoder() { + let (tx, _rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); + let key = crate::input::TerminalKey::new( + crossterm::event::KeyCode::Up, + crossterm::event::KeyModifiers::empty(), + ) + .with_repeat_count(3); + + assert_eq!(pane.encode_terminal_key(key), b"\x1b[A\x1b[A\x1b[A"); + } + + #[test] + fn poisoned_key_encoder_is_unavailable_and_suppresses_the_key() { + let (tx, _rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _guard = pane.key_encoder.lock().unwrap(); + panic!("poison key encoder for test"); + })); + let key = crate::input::TerminalKey::new( + crossterm::event::KeyCode::Enter, + crossterm::event::KeyModifiers::empty(), + ); + + assert_eq!( + pane.encode_terminal_key_once_outcome(key.clone()), + KeyEncodingOutcome::Unavailable(KeyEncodingUnavailable::EncoderLockPoisoned) + ); + assert_eq!(pane.encode_terminal_key(key), b""); } #[test] @@ -4276,14 +4628,13 @@ mod tests { crossterm::event::KeyCode::Enter, crossterm::event::KeyCode::Backspace, ] { - let press = pane.encode_terminal_key( - crate::input::TerminalKey::new(code, crossterm::event::KeyModifiers::empty()), - crate::input::KeyboardProtocol::Legacy, - ); + let press = pane.encode_terminal_key(crate::input::TerminalKey::new( + code, + crossterm::event::KeyModifiers::empty(), + )); let release = pane.encode_terminal_key( crate::input::TerminalKey::new(code, crossterm::event::KeyModifiers::empty()) .with_kind(crossterm::event::KeyEventKind::Release), - crate::input::KeyboardProtocol::Legacy, ); assert!(!press.is_empty(), "{code:?} press should emit bytes"); assert!( @@ -4305,10 +4656,10 @@ mod tests { (crossterm::event::KeyCode::Enter, b"\r".as_slice()), (crossterm::event::KeyCode::Backspace, b"\x7f".as_slice()), ] { - let press = pane.encode_terminal_key( - crate::input::TerminalKey::new(code, crossterm::event::KeyModifiers::empty()), - pane.keyboard_protocol().unwrap(), - ); + let press = pane.encode_terminal_key(crate::input::TerminalKey::new( + code, + crossterm::event::KeyModifiers::empty(), + )); assert_eq!( press, expected, "{code:?} press should stay legacy-compatible without REPORT_ALL_KEYS" @@ -4317,7 +4668,6 @@ mod tests { let release = pane.encode_terminal_key( crate::input::TerminalKey::new(code, crossterm::event::KeyModifiers::empty()) .with_kind(crossterm::event::KeyEventKind::Release), - pane.keyboard_protocol().unwrap(), ); assert!( release.is_empty(), @@ -4327,21 +4677,18 @@ mod tests { } #[test] - fn ghostty_char_keys_still_use_herdr_encoding() { + fn ghostty_encoder_owns_char_keys_and_pane_keyboard_mode() { let (tx, _rx) = mpsc::channel(4); let mut terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); terminal.write(b"\x1b[>1u"); let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); - let encoded = pane.encode_terminal_key( - crate::input::TerminalKey::new( - crossterm::event::KeyCode::Char('a'), - crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::SHIFT, - ), - crate::input::KeyboardProtocol::Legacy, - ); + let encoded = pane.encode_terminal_key(crate::input::TerminalKey::new( + crossterm::event::KeyCode::Char('a'), + crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::SHIFT, + )); - assert_eq!(encoded, vec![1]); + assert_eq!(encoded, b"\x1b[97;6u"); } #[test] @@ -4353,13 +4700,10 @@ mod tests { .unwrap(); let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); - let encoded = pane.encode_terminal_key( - crate::input::TerminalKey::new( - crossterm::event::KeyCode::Up, - crossterm::event::KeyModifiers::empty(), - ), - crate::input::KeyboardProtocol::Legacy, - ); + let encoded = pane.encode_terminal_key(crate::input::TerminalKey::new( + crossterm::event::KeyCode::Up, + crossterm::event::KeyModifiers::empty(), + )); assert_eq!(encoded, b"\x1bOA"); } @@ -4379,7 +4723,8 @@ mod tests { mouse_protocol_mode: crate::input::MouseProtocolMode::ButtonMotion, mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Sgr, mouse_alternate_scroll: true, - modify_other_keys: true, + modify_other_keys: false, + modify_other_keys_mode: Some(crate::input::ModifyOtherKeysMode::Mode1), color_scheme_reporting: true, }); @@ -4393,22 +4738,42 @@ mod tests { mouse_protocol_mode: crate::input::MouseProtocolMode::ButtonMotion, mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Sgr, mouse_alternate_scroll: true, - modify_other_keys: true, + modify_other_keys: false, + modify_other_keys_mode: Some(crate::input::ModifyOtherKeysMode::Mode1), color_scheme_reporting: true, }) ); - let encoded = pane.encode_terminal_key( - crate::input::TerminalKey::new( - crossterm::event::KeyCode::Up, - crossterm::event::KeyModifiers::empty(), - ), - crate::input::KeyboardProtocol::Legacy, + let (legacy_tx, _legacy_rx) = mpsc::channel(4); + let legacy_terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); + let legacy = GhosttyPaneTerminal::new(legacy_terminal, legacy_tx).unwrap(); + legacy.seed_handoff_input_state(InputState { + alternate_screen: false, + application_cursor: false, + bracketed_paste: false, + focus_reporting: false, + mouse_protocol_mode: crate::input::MouseProtocolMode::None, + mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Default, + mouse_alternate_scroll: false, + modify_other_keys: true, + modify_other_keys_mode: None, + color_scheme_reporting: false, + }); + assert_eq!( + legacy + .input_state() + .and_then(|state| state.modify_other_keys_mode), + Some(crate::input::ModifyOtherKeysMode::Mode2) ); + + let encoded = pane.encode_terminal_key(crate::input::TerminalKey::new( + crossterm::event::KeyCode::Up, + crossterm::event::KeyModifiers::empty(), + )); assert_eq!(encoded, b"\x1bOA"); let key = crate::input::parse_terminal_key_sequence("\x1b[13;2u").unwrap(); - let encoded = pane.encode_terminal_key(key.clone(), crate::input::KeyboardProtocol::Legacy); + let encoded = pane.encode_terminal_key(key.clone()); assert_eq!(encoded, b"\x1b[27;2;13~"); } @@ -4423,10 +4788,7 @@ mod tests { ) .with_repeat_count(3); - assert_eq!( - pane.encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy), - b"xxx" - ); + assert_eq!(pane.encode_terminal_key(key), b"xxx"); } #[test] @@ -4435,21 +4797,17 @@ mod tests { let mut terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); terminal.write(b"\x1b[>11u"); let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); - let protocol = pane.keyboard_protocol().unwrap(); let release = crate::input::TerminalKey::new( crossterm::event::KeyCode::Up, crossterm::event::KeyModifiers::empty(), ) .with_kind(crossterm::event::KeyEventKind::Release); - let expected = pane.encode_terminal_key(release.clone(), protocol); + let expected = pane.encode_terminal_key(release.clone()); assert!(!expected.is_empty()); let mut malformed_release = release; malformed_release.repeat_count = 3; - assert_eq!( - pane.encode_terminal_key(malformed_release, protocol), - expected - ); + assert_eq!(pane.encode_terminal_key(malformed_release), expected); } #[test] @@ -4459,24 +4817,18 @@ mod tests { let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(); let pane_id = PaneId::from_raw(1); - let before = pane.encode_terminal_key( - crate::input::TerminalKey::new( - crossterm::event::KeyCode::Up, - crossterm::event::KeyModifiers::empty(), - ), - crate::input::KeyboardProtocol::Legacy, - ); + let before = pane.encode_terminal_key(crate::input::TerminalKey::new( + crossterm::event::KeyCode::Up, + crossterm::event::KeyModifiers::empty(), + )); assert_eq!(before, b"\x1b[A"); pane.process_pty_bytes(pane_id, 0, b"\x1b[?1h", &tx); - let after = pane.encode_terminal_key( - crate::input::TerminalKey::new( - crossterm::event::KeyCode::Up, - crossterm::event::KeyModifiers::empty(), - ), - crate::input::KeyboardProtocol::Legacy, - ); + let after = pane.encode_terminal_key(crate::input::TerminalKey::new( + crossterm::event::KeyCode::Up, + crossterm::event::KeyModifiers::empty(), + )); assert_eq!(after, b"\x1bOA"); } @@ -4491,9 +4843,9 @@ mod tests { crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::SHIFT, ); - let before = pane.encode_terminal_key(key.clone(), crate::input::KeyboardProtocol::Legacy); + let before = pane.encode_terminal_key(key.clone()); pane.process_pty_bytes(pane_id, 0, b"\x1b[>1u", &tx); - let after = pane.encode_terminal_key(key.clone(), crate::input::KeyboardProtocol::Legacy); + let after = pane.encode_terminal_key(key.clone()); assert_ne!(before, after); assert_eq!(after, b"\x1b[13;6u"); @@ -4508,7 +4860,7 @@ mod tests { pane.process_pty_bytes(pane_id, 0, b"\x1b[>5u", &tx); let key = crate::input::parse_terminal_key_sequence("\x1b[13;2u").unwrap(); - let encoded = pane.encode_terminal_key(key.clone(), crate::input::KeyboardProtocol::Legacy); + let encoded = pane.encode_terminal_key(key.clone()); assert_eq!( pane.keyboard_protocol(), @@ -4526,7 +4878,7 @@ mod tests { pane.seed_keyboard_protocol_flags(5); let key = crate::input::parse_terminal_key_sequence("\x1b[13;2u").unwrap(); - let encoded = pane.encode_terminal_key(key.clone(), crate::input::KeyboardProtocol::Legacy); + let encoded = pane.encode_terminal_key(key.clone()); assert_eq!( pane.keyboard_protocol(), @@ -4571,10 +4923,7 @@ mod tests { let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); let key = crate::input::parse_terminal_key_sequence("\x1b[13;2u").unwrap(); - assert_eq!( - pane.encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy), - b"\x1b[13;28;13;1;16;1_" - ); + assert_eq!(pane.encode_terminal_key(key), b"\x1b[13;28;13;1;16;1_"); } #[test] @@ -4585,7 +4934,7 @@ mod tests { let key = crate::input::parse_terminal_key_sequence("\x1b[13;2u").unwrap(); pane.seed_history_ansi("\x1b[>4;1m"); - let encoded = pane.encode_terminal_key(key.clone(), crate::input::KeyboardProtocol::Legacy); + let encoded = pane.encode_terminal_key(key.clone()); assert_eq!(encoded, b"\x1b[27;2;13~"); } @@ -4599,11 +4948,79 @@ mod tests { pane.process_pty_bytes(pane_id, 0, b"\x1b[>1u", &tx); let key = crate::input::parse_terminal_key_sequence("\x1b\x7f").unwrap(); - let encoded = pane.encode_terminal_key(key.clone(), crate::input::KeyboardProtocol::Legacy); + let encoded = pane.encode_terminal_key(key.clone()); assert_eq!(encoded, b"\x1b[127;3u"); } + #[test] + fn ghostty_proxy_mode_is_independent_of_host_input_conventions() { + let (tx, _rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); + + let super_text = crate::input::TerminalKey::new( + crossterm::event::KeyCode::Char('b'), + crossterm::event::KeyModifiers::SUPER, + ); + assert_eq!(pane.encode_terminal_key(super_text), b"b"); + + pane.seed_history_ansi("\x1b[>4;2m"); + let alt_unicode = crate::input::TerminalKey::new( + crossterm::event::KeyCode::Char('é'), + crossterm::event::KeyModifiers::ALT, + ); + assert_eq!(pane.encode_terminal_key(alt_unicode), b"\x1b[27;3;233~"); + + pane.seed_history_ansi("\x1b[>31u"); + let kitty_alt = crate::input::TerminalKey::new( + crossterm::event::KeyCode::Char('w'), + crossterm::event::KeyModifiers::ALT, + ) + .with_generated_text(Some("∑".to_owned())); + assert_eq!(pane.encode_terminal_key(kitty_alt), b"\x1b[119;3u"); + } + + #[test] + fn ghostty_legacy_pane_preserves_alt_shift_printable_text() { + let (tx, _rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); + let key = crate::input::TerminalKey::new( + crossterm::event::KeyCode::Char('.'), + crossterm::event::KeyModifiers::ALT | crossterm::event::KeyModifiers::SHIFT, + ) + .with_shifted_codepoint('>' as u32); + + assert_eq!(pane.encode_terminal_key(key), b"\x1b>"); + } + + #[test] + fn ghostty_legacy_pane_preserves_windows_ctrl_minus_alias() { + let (tx, _rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); + let synthetic = crate::input::TerminalKey::new( + crossterm::event::KeyCode::Char('-'), + crossterm::event::KeyModifiers::CONTROL, + ); + assert_eq!(pane.encode_terminal_key(synthetic.clone()), b"\x1b[45;5u"); + + let windows = synthetic.with_windows_record(crate::input::WindowsKeyRecord { + key_down: true, + repeat_count: 1, + virtual_key_code: 0xbd, + virtual_scan_code: 0x0c, + unicode: 0x1f, + control_key_state: 0x0008, + }); + let encoded = pane.encode_terminal_key(windows); + #[cfg(not(windows))] + assert_eq!(encoded, b"\x1f"); + #[cfg(windows)] + assert_eq!(encoded, b"\x1b[189;12;31;1;8;1_"); + } + #[test] fn ghostty_kitty_pane_preserves_legacy_ctrl_alt_letter() { let (tx, _rx) = mpsc::channel(4); @@ -4616,7 +5033,7 @@ mod tests { let crate::raw_input::RawInputEvent::Key(key) = events.remove(0) else { panic!("expected key event"); }; - let encoded = pane.encode_terminal_key(key, pane.keyboard_protocol().unwrap()); + let encoded = pane.encode_terminal_key(key); assert_eq!(encoded, b"\x1b[102;7u"); } @@ -4634,22 +5051,13 @@ mod tests { crossterm::event::KeyCode::Backspace, crossterm::event::KeyModifiers::CONTROL, ); - assert_eq!( - legacy.encode_terminal_key( - ctrl_backspace.clone(), - crate::input::KeyboardProtocol::Legacy - ), - b"\x08" - ); + assert_eq!(legacy.encode_terminal_key(ctrl_backspace.clone()), b"\x08"); let plain_backspace = crate::input::TerminalKey::new( crossterm::event::KeyCode::Backspace, crossterm::event::KeyModifiers::empty(), ); - assert_eq!( - legacy.encode_terminal_key(plain_backspace, crate::input::KeyboardProtocol::Legacy), - b"\x7f" - ); + assert_eq!(legacy.encode_terminal_key(plain_backspace), b"\x7f"); let kitty = GhosttyPaneTerminal::new( crate::ghostty::Terminal::new(80, 24, 0).unwrap(), @@ -4659,10 +5067,7 @@ mod tests { let pane_id = PaneId::from_raw(1); kitty.process_pty_bytes(pane_id, 0, b"\x1b[>1u", &tx); - assert_eq!( - kitty.encode_terminal_key(ctrl_backspace, crate::input::KeyboardProtocol::Legacy), - b"\x1b[127;5u" - ); + assert_eq!(kitty.encode_terminal_key(ctrl_backspace), b"\x1b[127;5u"); } #[test] @@ -4681,20 +5086,14 @@ mod tests { first.process_pty_bytes(PaneId::from_raw(1), 0, b"\x1b[?1h", &tx); - let first_encoded = first.encode_terminal_key( - crate::input::TerminalKey::new( - crossterm::event::KeyCode::Up, - crossterm::event::KeyModifiers::empty(), - ), - crate::input::KeyboardProtocol::Legacy, - ); - let second_encoded = second.encode_terminal_key( - crate::input::TerminalKey::new( - crossterm::event::KeyCode::Up, - crossterm::event::KeyModifiers::empty(), - ), - crate::input::KeyboardProtocol::Legacy, - ); + let first_encoded = first.encode_terminal_key(crate::input::TerminalKey::new( + crossterm::event::KeyCode::Up, + crossterm::event::KeyModifiers::empty(), + )); + let second_encoded = second.encode_terminal_key(crate::input::TerminalKey::new( + crossterm::event::KeyCode::Up, + crossterm::event::KeyModifiers::empty(), + )); assert_eq!(first_encoded, b"\x1bOA"); assert_eq!(second_encoded, b"\x1b[A"); diff --git a/src/protocol/wire.rs b/src/protocol/wire.rs index 4a445361f5..e75f978e01 100644 --- a/src/protocol/wire.rs +++ b/src/protocol/wire.rs @@ -140,6 +140,9 @@ pub enum ClientKeySource { Synthesized, Vt { bytes: Vec, + shifted_codepoint: Option, + base_layout_codepoint: Option, + text_commit: bool, }, WindowsConsole { record: crate::input::WindowsKeyRecord, @@ -308,9 +311,36 @@ impl ClientInputEvent { ) .with_generated_text(generated_text.clone()); key = match source { - ClientKeySource::Synthesized => key, - ClientKeySource::Vt { bytes } => key.with_vt_bytes(bytes.clone()), - ClientKeySource::WindowsConsole { record } => key.with_windows_record(*record), + ClientKeySource::Synthesized => { + if generated_text.is_some() { + key.with_text_commit() + } else { + key + } + } + ClientKeySource::Vt { + bytes, + shifted_codepoint, + base_layout_codepoint, + text_commit, + } => { + let key = key + .with_vt_bytes(bytes.clone()) + .with_alternate_codepoints(*shifted_codepoint, *base_layout_codepoint); + if *text_commit { + key.with_text_commit() + } else { + key + } + } + ClientKeySource::WindowsConsole { record } => { + let key = key.with_windows_record(*record); + if generated_text.is_some() { + key.with_text_commit() + } else { + key + } + } }; key = key .with_repeat_count(*repeat_count) @@ -1148,13 +1178,16 @@ mod tests { source: crate::protocol::ClientKeySource::Synthesized, }, ClientInputEvent::Key { - code: ClientKeyCode::Backspace, - modifiers: 0, + code: ClientKeyCode::Char('l'), + modifiers: crossterm::event::KeyModifiers::SHIFT.bits(), kind: ClientKeyKind::Press, repeat_count: 3, - generated_text: None, + generated_text: Some("L".to_owned()), source: crate::protocol::ClientKeySource::Vt { - bytes: b"\x1b[127;1u".to_vec(), + bytes: b"\x1b[108:76:113;2;76u".to_vec(), + shifted_codepoint: Some('L' as u32), + base_layout_codepoint: Some('q' as u32), + text_commit: false, }, }, ClientInputEvent::Key { @@ -1188,9 +1221,10 @@ mod tests { assert_eq!( encoded, vec![ - 7, 5, 0, 15, 78, 1, 0, 1, 0, 0, 0, 0, 0, 0, 3, 0, 1, 8, 27, 91, 49, 50, 55, 59, 49, - 117, 0, 14, 0, 2, 1, 0, 2, 0, 1, 27, 1, 27, 0, 1, 7, 228, 189, 160, 240, 159, 153, - 130, 2, 0, 0, 3, 4, 0, + 7, 5, 0, 15, 78, 1, 0, 1, 0, 0, 0, 15, 108, 1, 0, 3, 1, 1, 76, 1, 18, 27, 91, 49, + 48, 56, 58, 55, 54, 58, 49, 49, 51, 59, 50, 59, 55, 54, 117, 1, 76, 1, 113, 0, 0, + 14, 0, 2, 1, 0, 2, 0, 1, 27, 1, 27, 0, 1, 7, 228, 189, 160, 240, 159, 153, 130, 2, + 0, 0, 3, 4, 0, ] ); let (decoded, _): (ClientMessage, _) = @@ -1219,6 +1253,54 @@ mod tests { } } + #[test] + fn vt_client_input_preserves_alternate_codepoints() { + let event = ClientInputEvent::Key { + code: ClientKeyCode::Char('a'), + modifiers: crossterm::event::KeyModifiers::SHIFT.bits(), + kind: ClientKeyKind::Press, + repeat_count: 1, + generated_text: Some("A\u{301}".to_owned()), + source: ClientKeySource::Vt { + bytes: b"\x1b[97:65:113;;65:769u".to_vec(), + shifted_codepoint: Some('A' as u32), + base_layout_codepoint: Some('q' as u32), + text_commit: false, + }, + }; + + let crate::raw_input::RawInputEvent::Key(key) = event.to_raw_input_event() else { + panic!("expected key event"); + }; + assert_eq!(key.shifted_codepoint, Some('A' as u32)); + assert_eq!(key.base_layout_codepoint, Some('q' as u32)); + assert_eq!(key.generated_text.as_deref(), Some("A\u{301}")); + assert!(!key.is_text_commit()); + } + + #[test] + fn vt_client_input_preserves_text_commit_semantics() { + let event = ClientInputEvent::Key { + code: ClientKeyCode::Char('A'), + modifiers: crossterm::event::KeyModifiers::SHIFT.bits(), + kind: ClientKeyKind::Press, + repeat_count: 1, + generated_text: Some("A".to_owned()), + source: ClientKeySource::Vt { + bytes: b"A".to_vec(), + shifted_codepoint: None, + base_layout_codepoint: None, + text_commit: true, + }, + }; + + let crate::raw_input::RawInputEvent::Key(key) = event.to_raw_input_event() else { + panic!("expected key event"); + }; + assert!(key.is_text_commit()); + assert_eq!(key.generated_text.as_deref(), Some("A")); + } + #[test] fn client_input_events_convert_to_raw_keys() { let record = crate::input::WindowsKeyRecord { diff --git a/src/raw_input.rs b/src/raw_input.rs index 510768fc92..4cee4cc1bd 100644 --- a/src/raw_input.rs +++ b/src/raw_input.rs @@ -181,6 +181,11 @@ impl RawInputFramer { self.byte_framer.has_pending_input() } + #[cfg(any(windows, test))] + pub(crate) fn requires_raw_continuation(&self) -> bool { + self.byte_framer.has_pending_input() || self.byte_framer.discard_until.is_some() + } + pub(crate) fn has_pending_incomplete_mouse_sequence(&self) -> bool { self.byte_framer.has_pending_incomplete_mouse_sequence() } @@ -197,17 +202,21 @@ impl RawInputFramer { fn events_from_chunks(chunks: Vec>) -> Vec { chunks .into_iter() - .filter_map(|chunk| { + .map(|chunk| { if chunk.as_slice() == [ESC] { - return Some(RawInputEvent::Key( + return RawInputEvent::Key( TerminalKey::new(crossterm::event::KeyCode::Esc, KeyModifiers::empty()) .with_vt_bytes(chunk), - )); + ); } - extract_one_event(&chunk).map(|(event, _consumed)| { - tracing::debug!(raw_bytes = ?chunk, event = ?event, "raw input event parsed"); - event - }) + let event = match decode_one_event(&chunk) { + RawInputDecodeOutcome::Complete { event, .. } => event, + RawInputDecodeOutcome::Unsupported { .. } | RawInputDecodeOutcome::NeedMore => { + RawInputEvent::Unsupported + } + }; + tracing::debug!(raw_bytes = ?chunk, event = ?event, "raw input event parsed"); + event }) .collect() } @@ -232,6 +241,9 @@ const HOST_COLOR_QUERY_REPLIES: u16 = 258; #[cfg(any(unix, test))] const HOST_CELL_SIZE_QUERY_REPLIES: u16 = 1; const MAX_ORPHANED_SGR_MOUSE_TAIL_BYTES: usize = 32; +const MAX_CSI_SEQUENCE_BYTES: usize = 4096; +const MAX_LEGACY_ESCAPE_PREFIXES: usize = 64; +const CSI_FINAL_BYTES: &[u8] = b"@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~"; impl RawInputByteFramer { pub(crate) fn for_host_input() -> Self { @@ -313,7 +325,10 @@ impl RawInputByteFramer { let mut chunks = self.drain_available_chunks(); if let Some(family) = self.discard_until { - if family == ControlStringFamily::HostReplyCsi { + if matches!( + family, + ControlStringFamily::CsiTail | ControlStringFamily::OversizedCsi + ) { return chunks; } if family == ControlStringFamily::OrphanedSgrMouseTail { @@ -408,7 +423,7 @@ impl RawInputByteFramer { ); self.host_cell_size_replies_awaited = 0; self.held_pending_host_reply_esc = false; - self.discard_until = Some(ControlStringFamily::HostReplyCsi); + self.discard_until = Some(ControlStringFamily::CsiTail); self.discarded_tail_bytes = 0; self.buffer.clear(); return chunks; @@ -429,7 +444,7 @@ impl RawInputByteFramer { ); self.host_appearance_reply_awaited = false; self.held_pending_host_reply_esc = false; - self.discard_until = Some(ControlStringFamily::HostReplyCsi); + self.discard_until = Some(ControlStringFamily::CsiTail); self.discarded_tail_bytes = 0; self.buffer.clear(); return chunks; @@ -496,6 +511,19 @@ impl RawInputByteFramer { let mut chunks = Vec::new(); loop { + let escape_count = self.buffer.iter().take_while(|byte| **byte == ESC).count(); + if self.discard_until.is_none() && escape_count > MAX_LEGACY_ESCAPE_PREFIXES { + let excess = escape_count - MAX_LEGACY_ESCAPE_PREFIXES; + tracing::warn!(excess, "splitting excessive legacy escape prefixes"); + chunks.extend((0..excess).map(|_| vec![ESC])); + self.buffer.drain(..excess); + continue; + } + + if self.discard_oversized_csi_prefix() { + continue; + } + if self.lone_escape_recently_flushed { if starts_with_incomplete_orphaned_sgr_mouse_tail(&self.buffer) { break; @@ -508,15 +536,21 @@ impl RawInputByteFramer { } if let Some(family) = self.discard_until { - if family == ControlStringFamily::HostReplyCsi { - if discard_host_reply_csi_tail(&mut self.buffer, &mut self.discarded_tail_bytes) - { + if family == ControlStringFamily::CsiTail { + if discard_csi_tail(&mut self.buffer, &mut self.discarded_tail_bytes) { self.discard_until = None; self.discarded_tail_bytes = 0; continue; } break; } + if family == ControlStringFamily::OversizedCsi { + if discard_oversized_csi_tail(&mut self.buffer) { + self.discard_until = None; + continue; + } + break; + } if family == ControlStringFamily::OrphanedSgrMouseTail { if discard_orphaned_sgr_mouse_tail( &mut self.buffer, @@ -546,8 +580,12 @@ impl RawInputByteFramer { continue; } - let Some((event, consumed)) = extract_one_event(&self.buffer) else { - break; + let (event, consumed) = match decode_one_event(&self.buffer) { + RawInputDecodeOutcome::Complete { event, consumed } => (event, consumed), + RawInputDecodeOutcome::Unsupported { consumed } => { + (RawInputEvent::Unsupported, consumed) + } + RawInputDecodeOutcome::NeedMore => break, }; if matches!( event, @@ -574,6 +612,36 @@ impl RawInputByteFramer { chunks } + + fn discard_oversized_csi_prefix(&mut self) -> bool { + if self.discard_until.is_some() { + return false; + } + let Some(offset) = csi_sequence_offset(&self.buffer) else { + return false; + }; + let csi = &self.buffer[offset..]; + + match find_csi_final(csi, CSI_FINAL_BYTES) { + Some(len) if len > MAX_CSI_SEQUENCE_BYTES => { + let consumed = offset + len; + tracing::warn!(len = consumed, "discarding oversized CSI input sequence"); + self.buffer.drain(..consumed); + true + } + None if csi.len() > MAX_CSI_SEQUENCE_BYTES => { + tracing::warn!( + len = self.buffer.len(), + "discarding oversized incomplete CSI input sequence" + ); + self.buffer.clear(); + self.discard_until = Some(ControlStringFamily::OversizedCsi); + self.discarded_tail_bytes = 0; + true + } + Some(_) | None => false, + } + } } const MAX_DISCARDED_CONTROL_TAIL_BYTES: usize = 128; @@ -602,7 +670,7 @@ fn plausible_control_string_tail(family: ControlStringFamily, buffer: &[u8]) -> ) }), ControlStringFamily::StTerminated => buffer.last() == Some(&ESC), - ControlStringFamily::HostReplyCsi => false, + ControlStringFamily::CsiTail | ControlStringFamily::OversizedCsi => false, ControlStringFamily::OrphanedSgrMouseTail => buffer .iter() .all(|byte| byte.is_ascii_digit() || matches!(*byte, b';' | b'M' | b'm')), @@ -818,90 +886,162 @@ fn poll_read_ready(fd: i32, timeout_ms: i32) -> Option { } } -fn extract_one_event(buffer: &[u8]) -> Option<(RawInputEvent, usize)> { +enum RawInputDecodeOutcome { + Complete { + event: RawInputEvent, + consumed: usize, + }, + NeedMore, + Unsupported { + consumed: usize, + }, +} + +fn decode_one_event(buffer: &[u8]) -> RawInputDecodeOutcome { if buffer.is_empty() { - return None; + return RawInputDecodeOutcome::NeedMore; } if buffer.starts_with(BRACKETED_PASTE_START) { - let end = find_subsequence(buffer, BRACKETED_PASTE_END)?; - let content = std::str::from_utf8(&buffer[BRACKETED_PASTE_START.len()..end]).ok()?; - return Some(( - RawInputEvent::Paste(content.to_string()), - end + BRACKETED_PASTE_END.len(), - )); + let Some(end) = find_subsequence(buffer, BRACKETED_PASTE_END) else { + return RawInputDecodeOutcome::NeedMore; + }; + let consumed = end + BRACKETED_PASTE_END.len(); + let Ok(content) = std::str::from_utf8(&buffer[BRACKETED_PASTE_START.len()..end]) else { + return RawInputDecodeOutcome::Unsupported { consumed }; + }; + return RawInputDecodeOutcome::Complete { + event: RawInputEvent::Paste(content.to_string()), + consumed, + }; } if buffer[0] == ESC { - let seq_len = complete_escape_sequence_len(buffer)?; + let seq_len = match frame_escape_sequence(buffer) { + EscapeFrameOutcome::Complete(len) => len, + EscapeFrameOutcome::NeedMore => return RawInputDecodeOutcome::NeedMore, + EscapeFrameOutcome::Unsupported(consumed) => { + return RawInputDecodeOutcome::Unsupported { consumed }; + } + }; if buffer[..seq_len].starts_with(b"\x1b[M") { - let event = parse_default_mouse(&buffer[..seq_len]) - .map(RawInputEvent::Mouse) - .unwrap_or(RawInputEvent::Unsupported); - return Some((event, seq_len)); + return match parse_default_mouse(&buffer[..seq_len]) { + Some(mouse) => RawInputDecodeOutcome::Complete { + event: RawInputEvent::Mouse(mouse), + consumed: seq_len, + }, + None => RawInputDecodeOutcome::Unsupported { consumed: seq_len }, + }; } - let seq = std::str::from_utf8(&buffer[..seq_len]).ok()?; + let Ok(seq) = std::str::from_utf8(&buffer[..seq_len]) else { + return RawInputDecodeOutcome::Unsupported { consumed: seq_len }; + }; if let Some((kind, color)) = parse_default_color_response(seq) { - return Some((RawInputEvent::HostDefaultColor { kind, color }, seq_len)); + return RawInputDecodeOutcome::Complete { + event: RawInputEvent::HostDefaultColor { kind, color }, + consumed: seq_len, + }; } if let Some((index, color)) = parse_palette_color_response(seq) { - return Some(( - RawInputEvent::HostPaletteColors { + return RawInputDecodeOutcome::Complete { + event: RawInputEvent::HostPaletteColors { colors: vec![(index, color)], }, - seq_len, - )); + consumed: seq_len, + }; } match seq { - "\x1b[I" => return Some((RawInputEvent::OuterFocusGained, seq_len)), - "\x1b[O" => return Some((RawInputEvent::OuterFocusLost, seq_len)), + "\x1b[I" => { + return RawInputDecodeOutcome::Complete { + event: RawInputEvent::OuterFocusGained, + consumed: seq_len, + }; + } + "\x1b[O" => { + return RawInputDecodeOutcome::Complete { + event: RawInputEvent::OuterFocusLost, + consumed: seq_len, + }; + } _ => {} } if let Some(appearance) = parse_host_color_scheme_report(&buffer[..seq_len]) { - return Some((RawInputEvent::HostColorSchemeChanged(appearance), seq_len)); + return RawInputDecodeOutcome::Complete { + event: RawInputEvent::HostColorSchemeChanged(appearance), + consumed: seq_len, + }; } if let Some((width_px, height_px)) = parse_host_cell_size_report(&buffer[..seq_len]) { - return Some(( - RawInputEvent::HostCellSizeReport { + return RawInputDecodeOutcome::Complete { + event: RawInputEvent::HostCellSizeReport { width_px, height_px, }, - seq_len, - )); + consumed: seq_len, + }; } if let Some(mouse) = parse_sgr_mouse(seq) { - return Some((RawInputEvent::Mouse(mouse), seq_len)); + return RawInputDecodeOutcome::Complete { + event: RawInputEvent::Mouse(mouse), + consumed: seq_len, + }; } if let Some(key) = parse_terminal_key_sequence(seq) { - return Some(( - RawInputEvent::Key(key.with_vt_bytes(buffer[..seq_len].to_vec())), - seq_len, - )); + return RawInputDecodeOutcome::Complete { + event: RawInputEvent::Key(key.with_vt_bytes(buffer[..seq_len].to_vec())), + consumed: seq_len, + }; } tracing::debug!(sequence = ?seq, "dropping unsupported escape sequence"); - return Some((RawInputEvent::Unsupported, seq_len)); + return RawInputDecodeOutcome::Unsupported { consumed: seq_len }; } - let consumed = first_complete_utf8_char_len(buffer)?; - let text = std::str::from_utf8(&buffer[..consumed]).ok()?; - let key = parse_terminal_key_sequence(text)? - .with_text_commit() - .with_vt_bytes(buffer[..consumed].to_vec()); - Some((RawInputEvent::Key(key), consumed)) + let Some(consumed) = first_complete_utf8_char_len(buffer) else { + return if starts_with_incomplete_utf8_char(buffer) { + RawInputDecodeOutcome::NeedMore + } else { + RawInputDecodeOutcome::Unsupported { consumed: 1 } + }; + }; + let Ok(text) = std::str::from_utf8(&buffer[..consumed]) else { + return RawInputDecodeOutcome::Unsupported { consumed }; + }; + let Some(key) = parse_terminal_key_sequence(text) else { + return RawInputDecodeOutcome::Unsupported { consumed }; + }; + RawInputDecodeOutcome::Complete { + event: RawInputEvent::Key( + key.with_text_commit() + .with_vt_bytes(buffer[..consumed].to_vec()), + ), + consumed, + } +} + +#[cfg(test)] +fn extract_one_event(buffer: &[u8]) -> Option<(RawInputEvent, usize)> { + match decode_one_event(buffer) { + RawInputDecodeOutcome::Complete { event, consumed } => Some((event, consumed)), + RawInputDecodeOutcome::Unsupported { consumed } => { + Some((RawInputEvent::Unsupported, consumed)) + } + RawInputDecodeOutcome::NeedMore => None, + } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum ControlStringFamily { Osc, StTerminated, - HostReplyCsi, + CsiTail, + OversizedCsi, OrphanedSgrMouseTail, } @@ -1022,16 +1162,27 @@ fn utf8_char_width(first: u8) -> Option { } } -fn complete_escape_sequence_len(buffer: &[u8]) -> Option { +fn csi_sequence_offset(buffer: &[u8]) -> Option { + let escape_count = buffer.iter().take_while(|byte| **byte == ESC).count(); + (escape_count > 0 && buffer.get(escape_count) == Some(&b'[')).then(|| escape_count - 1) +} + +enum EscapeFrameOutcome { + Complete(usize), + NeedMore, + Unsupported(usize), +} + +fn frame_escape_sequence(buffer: &[u8]) -> EscapeFrameOutcome { if buffer.len() == 1 { - return None; + return EscapeFrameOutcome::NeedMore; } if buffer.starts_with(b"\x1b\x1b[<") { if let Some(mouse_len) = find_csi_final(&buffer[1..], b"Mm") { - let mouse_sequence = std::str::from_utf8(&buffer[1..1 + mouse_len]).ok()?; - if parse_sgr_mouse(mouse_sequence).is_some() { - return Some(1); + let mouse_sequence = std::str::from_utf8(&buffer[1..1 + mouse_len]); + if mouse_sequence.ok().and_then(parse_sgr_mouse).is_some() { + return EscapeFrameOutcome::Complete(1); } } } @@ -1040,43 +1191,86 @@ fn complete_escape_sequence_len(buffer: &[u8]) -> Option { && buffer.starts_with(b"\x1b\x1b[M") && parse_default_mouse(&buffer[1..7]).is_some() { - return Some(1); + return EscapeFrameOutcome::Complete(1); } - if buffer.starts_with(b"\x1b\x1b") { - return complete_escape_sequence_len(&buffer[1..]).map(|len| len + 1); + let escape_count = buffer.iter().take_while(|byte| **byte == ESC).count(); + if escape_count > MAX_LEGACY_ESCAPE_PREFIXES { + return EscapeFrameOutcome::Complete(1); + } + let escape_offset = escape_count.saturating_sub(1); + let sequence = &buffer[escape_offset..]; + match frame_single_escape_sequence(sequence) { + EscapeFrameOutcome::Complete(len) => EscapeFrameOutcome::Complete(escape_offset + len), + EscapeFrameOutcome::Unsupported(consumed) => { + EscapeFrameOutcome::Unsupported(escape_offset + consumed) + } + EscapeFrameOutcome::NeedMore => EscapeFrameOutcome::NeedMore, + } +} + +fn frame_single_escape_sequence(buffer: &[u8]) -> EscapeFrameOutcome { + if buffer.len() == 1 { + return EscapeFrameOutcome::NeedMore; } if buffer.starts_with(b"\x1b[") { if buffer.starts_with(b"\x1b[<") { - return find_csi_final(buffer, b"Mm"); + return frame_csi_sequence(buffer, b"Mm"); } if buffer.starts_with(b"\x1b[M") { - return (buffer.len() >= 6).then_some(6); + return if buffer.len() >= 6 { + EscapeFrameOutcome::Complete(6) + } else { + EscapeFrameOutcome::NeedMore + }; } - return find_csi_final( - buffer, - b"@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~", - ); + return frame_csi_sequence(buffer, CSI_FINAL_BYTES); } if let Some(control) = control_string(buffer) { return match control { - ControlString::Complete { len, .. } => Some(len), - ControlString::Incomplete { .. } => None, + ControlString::Complete { len, .. } => EscapeFrameOutcome::Complete(len), + ControlString::Incomplete { .. } => EscapeFrameOutcome::NeedMore, }; } if buffer.starts_with(b"\x1bO") { - return (buffer.len() >= 3).then_some(3); + return if buffer.len() >= 3 { + EscapeFrameOutcome::Complete(3) + } else { + EscapeFrameOutcome::NeedMore + }; } - let escaped_char_width = utf8_char_width(buffer[1])?; - if buffer.len() < 1 + escaped_char_width { - return None; + let Some(escaped_char_width) = utf8_char_width(buffer[1]) else { + return EscapeFrameOutcome::Unsupported(2); + }; + let escaped = &buffer[1..buffer.len().min(1 + escaped_char_width)]; + match std::str::from_utf8(escaped) { + Ok(_) if escaped.len() == escaped_char_width => { + EscapeFrameOutcome::Complete(1 + escaped_char_width) + } + Ok(_) => EscapeFrameOutcome::NeedMore, + Err(error) if error.error_len().is_none() => EscapeFrameOutcome::NeedMore, + Err(error) => EscapeFrameOutcome::Unsupported(1 + error.error_len().unwrap_or(1)), } - std::str::from_utf8(&buffer[1..1 + escaped_char_width]).ok()?; - Some(1 + escaped_char_width) +} + +fn frame_csi_sequence(buffer: &[u8], allowed_finals: &[u8]) -> EscapeFrameOutcome { + let mut intermediates_started = false; + for (index, byte) in buffer.iter().copied().enumerate().skip(2) { + match byte { + 0x30..=0x3f if !intermediates_started => {} + 0x20..=0x2f => intermediates_started = true, + 0x40..=0x7e if allowed_finals.contains(&byte) => { + return EscapeFrameOutcome::Complete(index + 1); + } + 0x40..=0x7e => return EscapeFrameOutcome::Unsupported(index + 1), + _ => return EscapeFrameOutcome::Unsupported(index), + } + } + EscapeFrameOutcome::NeedMore } fn starts_with_incomplete_sgr_mouse_sequence(buffer: &[u8]) -> bool { @@ -1136,7 +1330,26 @@ fn discard_or_buffer_orphaned_sgr_mouse_tail( } } -fn discard_host_reply_csi_tail(buffer: &mut Vec, discarded_tail_bytes: &mut usize) -> bool { +fn discard_oversized_csi_tail(buffer: &mut Vec) -> bool { + for (index, byte) in buffer.iter().copied().enumerate() { + match byte { + 0x20..=0x3f => {} + 0x40..=0x7e => { + buffer.drain(..=index); + return true; + } + _ => { + buffer.drain(..index); + return true; + } + } + } + + buffer.clear(); + false +} + +fn discard_csi_tail(buffer: &mut Vec, discarded_tail_bytes: &mut usize) -> bool { let remaining = MAX_DISCARDED_CONTROL_TAIL_BYTES.saturating_sub(*discarded_tail_bytes); let inspected = buffer.len().min(remaining); @@ -1213,7 +1426,7 @@ fn control_string_terminator_for_family( match family { ControlStringFamily::Osc => osc_string_terminator(buffer), ControlStringFamily::StTerminated => st_string_terminator(buffer), - ControlStringFamily::HostReplyCsi => None, + ControlStringFamily::CsiTail | ControlStringFamily::OversizedCsi => None, ControlStringFamily::OrphanedSgrMouseTail => buffer .iter() .position(|byte| matches!(*byte, b'M' | b'm')) @@ -1398,6 +1611,40 @@ mod tests { drain_buffer(buffer, tx); } + #[test] + fn raw_decoder_distinguishes_complete_partial_and_unsupported() { + let RawInputDecodeOutcome::Complete { event, consumed } = decode_one_event(b"\x1b[Arest") + else { + panic!("expected complete event"); + }; + assert!(matches!(event, RawInputEvent::Key(_))); + assert_eq!(consumed, 3); + + assert!(matches!( + decode_one_event(b"\x1b[1;"), + RawInputDecodeOutcome::NeedMore + )); + assert!(matches!( + decode_one_event(b"\x1b[14;3~x"), + RawInputDecodeOutcome::Unsupported { consumed: 7 } + )); + } + + #[test] + fn malformed_escape_sequences_do_not_consume_following_keys() { + for input in [b"\x1b\xc2x".as_slice(), b"\x1b[\xc2x".as_slice()] { + let events = parse_raw_input_bytes_sync(input); + + assert!(events.len() >= 2, "{input:?}"); + assert!(matches!(events[0], RawInputEvent::Unsupported)); + assert_raw_key( + events.into_iter().last().unwrap(), + KeyCode::Char('x'), + KeyModifiers::empty(), + ); + } + } + #[test] fn parses_kitty_shift_letter_release() { let (RawInputEvent::Key(key), consumed) = extract_one_event(b"\x1b[108:76;2:3u").unwrap() @@ -1921,7 +2168,34 @@ mod tests { #[test] fn raw_input_corpus_fixture_extracts_whole_events() { let corpus = include_str!("../tests/fixtures/keyboard_protocol_corpus.tsv"); - assert_fixture_extracts_whole_events(corpus, false); + for case in crate::input::test_support::keyboard_corpus_cases(corpus) { + let event = if case.input.as_slice() == [ESC] { + let mut events = parse_raw_input_bytes_sync(&case.input); + assert_eq!(events.len(), 1, "{} event count", case.family); + events.remove(0) + } else { + let (event, consumed) = extract_one_event(&case.input) + .unwrap_or_else(|| panic!("fixture failed to extract: {}", case.family)); + assert_eq!(consumed, case.input.len(), "{} consumed bytes", case.family); + event + }; + + let RawInputEvent::Key(key) = event else { + panic!("fixture did not produce a key: {}", case.family); + }; + case.assert_key_semantics(&key); + assert_eq!( + key.generated_text, case.generated_text, + "{} generated text", + case.family + ); + assert_eq!( + key.vt_bytes(), + Some(case.input.as_slice()), + "{} source bytes", + case.family + ); + } } #[test] @@ -2276,6 +2550,82 @@ mod tests { ); } + #[test] + fn oversized_csi_input_is_discarded_without_losing_following_keys() { + let mut incomplete = RawInputByteFramer::default(); + let mut oversized = b"\x1b[".to_vec(); + oversized.extend(std::iter::repeat_n(b'1', MAX_CSI_SEQUENCE_BYTES)); + assert!(incomplete.push(&oversized).is_empty()); + assert!(!incomplete.has_pending_input()); + assert!(incomplete + .push(&[b'2'; MAX_DISCARDED_CONTROL_TAIL_BYTES + 1]) + .is_empty()); + assert!(!incomplete.has_pending_input()); + assert!(incomplete.push(b"u").is_empty()); + assert_eq!(incomplete.push(b"x"), vec![b"x".to_vec()]); + + let mut complete = RawInputByteFramer::default(); + oversized.extend_from_slice(b"uy"); + assert_eq!(complete.push(&oversized), vec![b"y".to_vec()]); + assert!(!complete.has_pending_input()); + } + + #[test] + fn oversized_csi_discard_preserves_a_following_escape_sequence() { + let mut framer = RawInputByteFramer::default(); + let mut oversized = b"\x1b[".to_vec(); + oversized.extend(std::iter::repeat_n(b'1', MAX_CSI_SEQUENCE_BYTES)); + assert!(framer.push(&oversized).is_empty()); + + assert_eq!(framer.push(b"\x1b[A"), vec![b"\x1b[A".to_vec()]); + assert!(!framer.has_pending_input()); + } + + #[test] + fn oversized_csi_discard_preserves_excess_escape_batching() { + let mut framer = RawInputByteFramer::default(); + let mut oversized = b"\x1b[".to_vec(); + oversized.extend(std::iter::repeat_n(b'1', MAX_CSI_SEQUENCE_BYTES)); + assert!(framer.push(&oversized).is_empty()); + + let mut continuation = vec![ESC; MAX_LEGACY_ESCAPE_PREFIXES + 1]; + continuation.push(b'x'); + let rebuilt = framer + .push(&continuation) + .into_iter() + .flatten() + .collect::>(); + + assert_eq!(rebuilt, continuation); + assert!(!framer.has_pending_input()); + assert!(framer.discard_until.is_none()); + } + + #[test] + fn doubled_escape_cannot_bypass_oversized_csi_limit() { + let mut framer = RawInputByteFramer::with_host_input_policy(true); + let mut oversized = b"\x1b\x1b[".to_vec(); + oversized.extend(std::iter::repeat_n(b'1', MAX_CSI_SEQUENCE_BYTES)); + + assert!(framer.push(&oversized).is_empty()); + assert!(!framer.has_pending_input()); + assert!(framer.push(b"u").is_empty()); + assert_eq!(framer.push(b"x"), vec![b"x".to_vec()]); + } + + #[test] + fn repeated_escape_prefixes_are_drained_iteratively() { + let mut framer = RawInputByteFramer::with_host_input_policy(true); + let mut input = vec![ESC; MAX_LEGACY_ESCAPE_PREFIXES * 1024]; + input.extend_from_slice(b"[A"); + + let chunks = framer.push(&input); + let rebuilt = chunks.into_iter().flatten().collect::>(); + + assert_eq!(rebuilt, input); + assert!(!framer.has_pending_input()); + } + #[test] fn chunked_bracketed_paste_waits_for_terminator() { let (tx, mut rx) = mpsc::channel(8); @@ -2338,11 +2688,16 @@ mod tests { } #[test] - fn invalid_utf8_lead_byte_is_flushed_instead_of_buffered_forever() { - let mut buffer = vec![0xC0]; + fn invalid_utf8_is_unsupported_without_losing_following_input() { + let events = parse_raw_input_bytes_sync(&[0xC2, b'x']); - assert_eq!(flush_incomplete_input_bytes(&mut buffer), None); - assert!(buffer.is_empty()); + assert_eq!(events.len(), 2); + assert!(matches!(events[0], RawInputEvent::Unsupported)); + assert_raw_key( + events.into_iter().last().unwrap(), + KeyCode::Char('x'), + KeyModifiers::empty(), + ); } #[test] diff --git a/src/server/client_transport.rs b/src/server/client_transport.rs index b5023b977d..e3304a5dc3 100644 --- a/src/server/client_transport.rs +++ b/src/server/client_transport.rs @@ -451,7 +451,7 @@ fn input_event_limit(events: &[ClientInputEvent]) -> InputEventLimit { .saturating_mul(usize::from((*repeat_count).max(1))), ); } - if let crate::protocol::ClientKeySource::Vt { bytes } = source { + if let crate::protocol::ClientKeySource::Vt { bytes, .. } = source { input_bytes = input_bytes.saturating_add(bytes.len()); } } diff --git a/src/server/headless.rs b/src/server/headless.rs index 61c825b8da..319e7c707e 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -7830,7 +7830,7 @@ next_tab = "" })); assert_eq!( input_rx.try_recv().expect("forwarded press"), - Bytes::from_static(b"\x1b[106;1:1u") + Bytes::from_static(b"\x1b[106u") ); assert_eq!( input_rx diff --git a/tests/fixtures/keyboard_protocol_corpus.tsv b/tests/fixtures/keyboard_protocol_corpus.tsv index e405326dd5..4715d46e95 100644 --- a/tests/fixtures/keyboard_protocol_corpus.tsv +++ b/tests/fixtures/keyboard_protocol_corpus.tsv @@ -1,40 +1,145 @@ -# family bytes_hex code modifiers kind shifted_codepoint -legacy_ctrl_b 02 char:b control press -legacy_ctrl_z 1a char:z control press -legacy_enter 0d enter - press -legacy_tab 09 tab - press -legacy_backspace 7f backspace - press -legacy_alt_backspace 1b7f backspace alt press -legacy_up 1b5b41 up - press -legacy_home 1b5b48 home - press -xterm_alt_up 1b5b313b3341 up alt press -xterm_ctrl_left 1b5b313b3544 left control press -xterm_shift_pageup 1b5b353b327e pageup shift press -ghostty_enhanced_up_press 1b5b313b313a3141 up - press -ghostty_enhanced_up_release 1b5b313b313a3341 up - release -modify_other_keys_ctrl_shift_l 1b5b32373b363b3130387e char:l control+shift press -kitty_alt_backspace 1b5b3132373b3375 backspace alt press -kitty_shift_letter 1b5b3130383a37363b323a3175 char:l shift press 76 -kitty_shift_symbol 1b5b34393a33333b323a3175 char:1 shift press 33 -kitty_omitted_shift_modifier 1b5b3131343a38323b3175 char:r shift press 82 -kitty_release_letter 1b5b3130383a37363b323a3375 char:l shift release 76 -kitty_keypad_0 1b5b35373339393b3175 char:0 - press -kitty_keypad_1 1b5b35373430303b3175 char:1 - press -kitty_keypad_2 1b5b35373430313b3175 char:2 - press -kitty_keypad_3 1b5b35373430323b3175 char:3 - press -kitty_keypad_4 1b5b35373430333b3175 char:4 - press -kitty_keypad_5 1b5b35373430343b3175 char:5 - press -kitty_keypad_6 1b5b35373430353b3175 char:6 - press -kitty_keypad_7 1b5b35373430363b3175 char:7 - press -kitty_keypad_8 1b5b35373430373b3175 char:8 - press -kitty_keypad_9 1b5b35373430383b3175 char:9 - press -kitty_keypad_decimal 1b5b35373430393b3175 char:. - press -kitty_keypad_divide 1b5b35373431303b3175 char:/ - press -kitty_keypad_multiply 1b5b35373431313b3175 char:* - press -kitty_keypad_subtract 1b5b35373431323b3175 char:- - press -kitty_keypad_add 1b5b35373431333b3175 char:+ - press -kitty_keypad_enter 1b5b35373431343b3175 enter - press -kitty_keypad_equal 1b5b35373431353b3175 char:= - press -kitty_keypad_separator 1b5b35373431363b3175 char:, - press -kitty_functional_up 1b5b35373431393b3175 up - press -kitty_functional_home 1b5b35373432333b3175 home - press +# family bytes_hex code modifiers kind shifted_codepoint generated_text_hex pane_profile pane_bytes_hex base_layout_codepoint(optional) +legacy_ctrl_space 00 char: control press - legacy 00 +legacy_ctrl_a 01 char:a control press - legacy 01 +legacy_ctrl_b 02 char:b control press - legacy 02 +legacy_ctrl_c 03 char:c control press - legacy 03 +legacy_ctrl_d 04 char:d control press - legacy 04 +legacy_ctrl_e 05 char:e control press - legacy 05 +legacy_ctrl_f 06 char:f control press - legacy 06 +legacy_ctrl_g 07 char:g control press - legacy 07 +legacy_backspace_ctrl_h_alias 08 char:h control press - legacy 08 +legacy_tab 09 tab - press - legacy 09 +legacy_ctrl_j 0a char:j control press - legacy 0a +legacy_ctrl_k 0b char:k control press - legacy 0b +legacy_ctrl_l 0c char:l control press - legacy 0c +legacy_enter 0d enter - press - legacy 0d +legacy_ctrl_n 0e char:n control press - legacy 0e +legacy_ctrl_o 0f char:o control press - legacy 0f +legacy_ctrl_p 10 char:p control press - legacy 10 +legacy_ctrl_q 11 char:q control press - legacy 11 +legacy_ctrl_r 12 char:r control press - legacy 12 +legacy_ctrl_s 13 char:s control press - legacy 13 +legacy_ctrl_t 14 char:t control press - legacy 14 +legacy_ctrl_u 15 char:u control press - legacy 15 +legacy_ctrl_v 16 char:v control press - legacy 16 +legacy_ctrl_w 17 char:w control press - legacy 17 +legacy_ctrl_x 18 char:x control press - legacy 18 +legacy_ctrl_y 19 char:y control press - legacy 19 +legacy_ctrl_z 1a char:z control press - legacy 1a +legacy_escape 1b esc - press - legacy 1b +legacy_ctrl_backslash 1c char:\ control press - legacy 1c +legacy_ctrl_right_bracket 1d char:] control press - legacy 1d +legacy_ctrl_caret 1e char:^ control press - legacy 1e +legacy_ctrl_underscore 1f char:_ control press - legacy 1f +legacy_alt_ctrl_space 1b00 char: control+alt press - legacy 1b00 +legacy_alt_ctrl_a 1b01 char:a control+alt press - legacy 1b01 +legacy_alt_ctrl_b 1b02 char:b control+alt press - legacy 1b02 +legacy_alt_ctrl_c 1b03 char:c control+alt press - legacy 1b03 +legacy_alt_ctrl_d 1b04 char:d control+alt press - legacy 1b04 +legacy_alt_ctrl_e 1b05 char:e control+alt press - legacy 1b05 +legacy_alt_ctrl_f 1b06 char:f control+alt press - legacy 1b06 +legacy_alt_ctrl_g 1b07 char:g control+alt press - legacy 1b07 +legacy_alt_ctrl_h 1b08 char:h control+alt press - legacy 1b08 +legacy_alt_tab 1b09 tab alt press - legacy 1b09 +legacy_alt_ctrl_j 1b0a char:j control+alt press - legacy 1b0a +legacy_alt_ctrl_k 1b0b char:k control+alt press - legacy 1b0b +legacy_alt_ctrl_l 1b0c char:l control+alt press - legacy 1b0c +legacy_alt_enter 1b0d enter alt press - legacy 1b0d +legacy_alt_ctrl_n 1b0e char:n control+alt press - legacy 1b0e +legacy_alt_ctrl_o 1b0f char:o control+alt press - legacy 1b0f +legacy_alt_ctrl_p 1b10 char:p control+alt press - legacy 1b10 +legacy_alt_ctrl_q 1b11 char:q control+alt press - legacy 1b11 +legacy_alt_ctrl_r 1b12 char:r control+alt press - legacy 1b12 +legacy_alt_ctrl_s 1b13 char:s control+alt press - legacy 1b13 +legacy_alt_ctrl_t 1b14 char:t control+alt press - legacy 1b14 +legacy_alt_ctrl_u 1b15 char:u control+alt press - legacy 1b15 +legacy_alt_ctrl_v 1b16 char:v control+alt press - legacy 1b16 +legacy_alt_ctrl_w 1b17 char:w control+alt press - legacy 1b17 +legacy_alt_ctrl_x 1b18 char:x control+alt press - legacy 1b18 +legacy_alt_ctrl_y 1b19 char:y control+alt press - legacy 1b19 +legacy_alt_ctrl_z 1b1a char:z control+alt press - legacy 1b1a +legacy_alt_ctrl_backslash 1b1c char:\ control+alt press - legacy 1b1c +legacy_alt_ctrl_right_bracket 1b1d char:] control+alt press - legacy 1b1d +legacy_alt_ctrl_caret 1b1e char:^ control+alt press - legacy 1b1e +legacy_alt_ctrl_underscore 1b1f char:_ control+alt press - legacy 1b1f +legacy_backspace 7f backspace - press - legacy 7f +legacy_alt_backspace 1b7f backspace alt press - legacy 1b7f +legacy_up 1b5b41 up - press - legacy 1b5b41 +legacy_home 1b5b48 home - press - legacy 1b5b48 +xterm_alt_up 1b5b313b3341 up alt press - legacy 1b5b313b3341 +xterm_ctrl_left 1b5b313b3544 left control press - legacy 1b5b313b3544 +xterm_shift_pageup 1b5b353b327e pageup shift press - legacy 1b5b353b327e +ghostty_enhanced_up_press 1b5b313b313a3141 up - press - legacy 1b5b41 +ghostty_enhanced_up_release 1b5b313b313a3341 up - release - legacy empty +modify_other_keys_ctrl_shift_l 1b5b32373b363b3130387e char:l control+shift press - legacy 0c +kitty_alt_backspace 1b5b3132373b3375 backspace alt press - legacy 1b7f +kitty_shift_letter 1b5b3130383a37363b323a3175 char:l shift press 76 - legacy 4c +kitty_shift_symbol 1b5b34393a33333b323a3175 char:1 shift press 33 - legacy 21 +kitty_omitted_shift_modifier 1b5b3131343a38323b3175 char:r shift press 82 - legacy 52 +kitty_release_letter 1b5b3130383a37363b323a3375 char:l shift release 76 - legacy empty +kitty_keypad_0 1b5b35373339393b3175 char:0 - press - legacy 30 +kitty_keypad_1 1b5b35373430303b3175 char:1 - press - legacy 31 +kitty_keypad_2 1b5b35373430313b3175 char:2 - press - legacy 32 +kitty_keypad_3 1b5b35373430323b3175 char:3 - press - legacy 33 +kitty_keypad_4 1b5b35373430333b3175 char:4 - press - legacy 34 +kitty_keypad_5 1b5b35373430343b3175 char:5 - press - legacy 35 +kitty_keypad_6 1b5b35373430353b3175 char:6 - press - legacy 36 +kitty_keypad_7 1b5b35373430363b3175 char:7 - press - legacy 37 +kitty_keypad_8 1b5b35373430373b3175 char:8 - press - legacy 38 +kitty_keypad_9 1b5b35373430383b3175 char:9 - press - legacy 39 +kitty_keypad_decimal 1b5b35373430393b3175 char:. - press - legacy 2e +kitty_keypad_divide 1b5b35373431303b3175 char:/ - press - legacy 2f +kitty_keypad_multiply 1b5b35373431313b3175 char:* - press - legacy 2a +kitty_keypad_subtract 1b5b35373431323b3175 char:- - press - legacy 2d +kitty_keypad_add 1b5b35373431333b3175 char:+ - press - legacy 2b +kitty_keypad_enter 1b5b35373431343b3175 enter - press - legacy 0d +kitty_keypad_equal 1b5b35373431353b3175 char:= - press - legacy 3d +kitty_keypad_separator 1b5b35373431363b3175 char:, - press - legacy 2c +kitty_functional_up 1b5b35373431393b3175 up - press - legacy 1b5b41 +kitty_functional_home 1b5b35373432333b3175 home - press - legacy 1b5b48 +legacy_up_application_cursor 1b5b41 up - press - application_cursor 1b4f41 +kitty_shift_enter_modify_other_keys_1 1b5b31333b3275 enter shift press - modify_other_keys_1 1b5b32373b323b31337e +kitty_shift_enter_modify_other_keys_2 1b5b31333b3275 enter shift press - modify_other_keys_2 1b5b32373b323b31337e +kitty_shift_backspace_modify_other_keys_1 1b5b3132373b3275 backspace shift press - modify_other_keys_1 7f +kitty_shift_backspace_modify_other_keys_2 1b5b3132373b3275 backspace shift press - modify_other_keys_2 1b5b32373b323b3132377e +legacy_alt_backspace_kitty_1 1b7f backspace alt press - kitty_1 1b5b3132373b3375 +kitty_release_letter_kitty_3 1b5b3130383a37363b323a3375 char:l shift release 76 - kitty_3 1b5b3130383b323a3375 +kitty_shift_letter_kitty_7 1b5b3130383a37363b323a3175 char:l shift press 76 - kitty_7 4c +kitty_ambiguous_shift_digit_kitty_7 1b5b34393b3275 char:1 shift press - kitty_7 1b5b34393b3275 +kitty_shift_letter_kitty_15 1b5b3130383a37363b323a3175 char:l shift press 76 - kitty_15 1b5b3130383a37363b3275 +kitty_shift_letter_kitty_31 1b5b3130383a37363b323a3175 char:l shift press 76 - kitty_31 1b5b3130383a37363b323b373675 +kitty_ctrl_shift_letter_kitty_5 1b5b3130383a37363b363a3175 char:l control+shift press 76 - kitty_5 1b5b3130383a37363b3675 +kitty_shift_letter_kitty_25 1b5b3130383a37363b323a3175 char:l shift press 76 - kitty_25 1b5b3130383b323b373675 +legacy_plain_a 61 char:a - press 61 legacy 61 +legacy_alt_b 1b62 char:b alt press - legacy 1b62 +legacy_alt_e_acute 1bc3a9 char:é alt press - legacy 1bc3a9 +legacy_shift_a 41 char:A shift press 41 legacy 41 +legacy_shift_a_kitty_13 41 char:A shift press 41 kitty_13 41 +legacy_bang_kitty_13 21 char:! - press 21 kitty_13 21 +legacy_unicode_e_acute c3a9 char:é - press c3a9 legacy c3a9 +legacy_f1 1b4f50 f:1 - press - legacy 1b4f50 +legacy_ctrl_f3 1b5b313b3552 f:3 control press - legacy 1b5b313b3552 +kitty_f13_legacy 1b5b35373337363b3175 f:13 - press - legacy 1b5b313b3250 +kitty_f24_legacy 1b5b35373338373b3175 f:24 - press - legacy 1b5b32343b327e +kitty_f25_legacy 1b5b35373338383b3175 f:25 - press - legacy 1b5b313b3550 +kitty_f26_legacy 1b5b35373338393b3175 f:26 - press - legacy 1b5b313b3551 +kitty_f35_legacy 1b5b35373339383b3175 f:35 - press - legacy 1b5b32333b357e +kitty_f13_kitty_1 1b5b35373337363b3175 f:13 - press - kitty_1 1b5b353733373675 +kitty_f35_kitty_1 1b5b35373339383b3175 f:35 - press - kitty_1 1b5b353733393875 +legacy_backspace_backarrow 7f backspace - press - backarrow 08 +kitty_keypad_1_application_keypad_characterized 1b5b35373430303b3175 char:1 - press - application_keypad 31 +kitty_ctrl_a_legacy 1b5b39373b3575 char:a control press - legacy 01 +kitty_ctrl_a_kitty_1 1b5b39373b3575 char:a control press - kitty_1 1b5b39373b3575 +kitty_hyper_a_kitty_1 1b5b39373b313775 char:a hyper press - kitty_1 1b5b39373b313775 +kitty_meta_a_kitty_1 1b5b39373b333375 char:a meta press - kitty_1 1b5b39373b333375 +kitty_hyper_enter_kitty_1 1b5b31333b313775 enter hyper press - kitty_1 1b5b31333b313775 +kitty_meta_enter_kitty_1 1b5b31333b333375 enter meta press - kitty_1 1b5b31333b333375 +kitty_repeat_a_kitty_3 1b5b39373b313a3275 char:a - repeat - kitty_3 1b5b39373b313a3275 +kitty_repeat_a_kitty_11 1b5b39373b313a3275 char:a - repeat - kitty_11 1b5b39373b313a3275 +kitty_super_a_kitty_31 1b5b39373b3975 char:a super press - kitty_31 1b5b39373b3975 +kitty_hyper_a_kitty_31 1b5b39373b313775 char:a hyper press - kitty_31 1b5b39373b313775 +kitty_meta_a_kitty_31 1b5b39373b333375 char:a meta press - kitty_31 1b5b39373b333375 +kitty_full_alternates_kitty_13 1b5b39373a36353a3131333b3275 char:a shift press 65 - kitty_13 1b5b39373a36353a3131333b3275 113 +kitty_full_alternates_associated_text 1b5b39373a36353a3131333b3b36353a37363975 char:a shift press 65 41cc81 legacy 41cc81 113 +kitty_full_alternates_associated_text_kitty_31 1b5b39373a36353a3131333b3b36353a37363975 char:a shift press 65 41cc81 kitty_31 1b5b39373a36353a3131333b323b36353a37363975 113 +kitty_ctrl_tab_legacy 1b5b393b3575 tab control press - legacy 09 diff --git a/tests/live_handoff.rs b/tests/live_handoff.rs index 832079e8db..db889cca2b 100644 --- a/tests/live_handoff.rs +++ b/tests/live_handoff.rs @@ -1035,7 +1035,7 @@ pathlib.Path({received:?}).write_text(data.hex()) } #[test] -fn live_handoff_preserves_modify_other_keys_for_client_input() { +fn live_handoff_preserves_modify_other_keys_mode_one_for_client_input() { let _lock = test_lock(); let base = unique_test_dir(); let config_home = base.join("config"); @@ -1056,7 +1056,7 @@ import select import sys import tty -sys.stdout.buffer.write(b"\x1b[>4;2m") +sys.stdout.buffer.write(b"\x1b[>4;1m") sys.stdout.flush() pathlib.Path({ready:?}).write_text("ready") tty.setraw(sys.stdin.fileno()) @@ -1114,13 +1114,9 @@ pathlib.Path({received:?}).write_text(data.hex()) let (server_protocol, error) = client_handshake(&mut client_stream, protocol, 80, 24).unwrap(); assert_eq!(server_protocol, protocol); assert!(error.is_none(), "client handshake failed: {error:?}"); - send_input(&mut client_stream, b"\x1b[13;2u").unwrap(); + send_input(&mut client_stream, b"\x1b[127;2u").unwrap(); - wait_for_file_contains( - &received_marker, - "1b5b32373b323b31337e", - Duration::from_secs(5), - ); + wait_for_file_contains(&received_marker, "7f", Duration::from_secs(5)); let _ = request( &api_socket, diff --git a/vendor/libghostty-vt.patches.md b/vendor/libghostty-vt.patches.md index 043059600a..654f7db1a8 100644 --- a/vendor/libghostty-vt.patches.md +++ b/vendor/libghostty-vt.patches.md @@ -38,3 +38,194 @@ cargo nextest run --locked grapheme_cluster_mode_is_default_and_survives_full_re cargo nextest run --locked grapheme_cluster_mode_renders_flag_emoji_in_single_wide_cell cargo nextest run --locked grapheme_cluster_mode_renders_zwj_family_in_single_wide_cell ``` + +## 0002 preserve proxied Kitty key metadata + +status: active + +patch: `vendor/patches/libghostty-vt/0002-proxied-kitty-key-metadata.patch` + +herdr issue: https://github.com/herdrdev/herdr/issues/2514 + +upstream discussion: not opened; this extension is currently specific to terminal-proxy input + +upstream pr: not opened + +vendored base: `c5a21edfcbc2d5b46540ad91b7980aca31f5f1f3` + +local files: + +- `vendor/libghostty-vt/include/ghostty/vt/key/event.h` +- `vendor/libghostty-vt/src/input/key.zig` +- `vendor/libghostty-vt/src/input/key_encode.zig` +- `vendor/libghostty-vt/src/input/key_mods.zig` +- `vendor/libghostty-vt/src/lib_vt.zig` +- `vendor/libghostty-vt/src/terminal/c/key_event.zig` +- `vendor/libghostty-vt/src/terminal/c/main.zig` + +reason: Herdr proxies rich Kitty key reports between terminals. The source event +can contain explicit shifted/base-layout alternates and Hyper/Meta modifiers +that libghostty-vt cannot reconstruct from local physical-key and layout data. +The extension preserves those fields so Ghostty can become Herdr's single pane +key encoder without losing protocol metadata. + +remove when: upstream libghostty-vt exposes equivalent proxy-event alternate +codepoints and Hyper/Meta modifier support, and Herdr's encoder parity corpus +passes without this patch. + +verification: + +```sh +cd vendor/libghostty-vt && zig build test-lib-vt -Dsimd=true +just test-one keyboard_corpus_survives_fragmentation_and_pane_encoding +``` + +## 0003 report Kitty repeat events + +status: active + +patch: `vendor/patches/libghostty-vt/0003-report-kitty-repeat-events.patch` + +herdr issue: https://github.com/herdrdev/herdr/issues/2514 + +upstream discussion: not opened + +upstream pr: not opened + +vendored base: `c5a21edfcbc2d5b46540ad91b7980aca31f5f1f3` + +local files: + +- `vendor/libghostty-vt/src/input/key_encode.zig` + +reason: when Kitty event-type reporting is enabled, repeat events must remain +CSI-u events so applications can distinguish them from presses. Encoding a +repeat as plain text discards the event type at the pane boundary. + +remove when: upstream libghostty-vt emits CSI-u for text-producing repeat +events whenever Kitty event-type reporting is enabled. + +verification: + +```sh +cd vendor/libghostty-vt && zig build test-lib-vt -Dsimd=true +just test-one keyboard_corpus_survives_fragmentation_and_pane_encoding +``` + +## 0004 encode extended function keys + +status: active + +patch: `vendor/patches/libghostty-vt/0004-encode-extended-function-keys.patch` + +herdr issue: https://github.com/herdrdev/herdr/issues/2514 + +upstream discussion: not opened + +upstream pr: not opened + +vendored base: `c5a21edfcbc2d5b46540ad91b7980aca31f5f1f3` + +local files: + +- `vendor/libghostty-vt/src/input/function_keys.zig` +- `vendor/libghostty-vt/src/input/key_encode.zig` + +reason: libghostty-vt models F13-F25 but its legacy encoder has no entries for +them, silently suppressing keys that Herdr receives through Kitty input. The +extension uses the standard xterm/terminfo sequences, corrects modified F3 to +that same standard, and composes additional modifiers with each extended key's +implicit Shift or Control modifier. Modified F3 therefore shares the +`CSI 1;modifier R` byte shape used by a cursor position report, but terminal +input and terminal responses travel in opposite directions and are interpreted +in that context. + +remove when: upstream libghostty-vt encodes F13-F25 in legacy mode with the +standard xterm sequences and modifier composition, and emits the standard +modified F3 sequence. + +verification: + +```sh +cd vendor/libghostty-vt && zig build test-lib-vt -Dsimd=true +just test-one keyboard_corpus_survives_fragmentation_and_pane_encoding +``` + +## 0005 encode terminal proxy key events deterministically + +status: active + +patch: `vendor/patches/libghostty-vt/0005-proxy-key-encoding.patch` + +herdr issue: https://github.com/herdrdev/herdr/issues/2514 + +upstream discussion: not opened; this extension defines a terminal-proxy input +mode rather than changing Ghostty's local terminal input policy + +upstream pr: not opened + +vendored base: `c5a21edfcbc2d5b46540ad91b7980aca31f5f1f3` + +local files: + +- `vendor/libghostty-vt/include/ghostty/vt/key/encoder.h` +- `vendor/libghostty-vt/src/input/key_encode.zig` +- `vendor/libghostty-vt/src/terminal/c/key_encode.zig` + +reason: Herdr receives semantic key events from another terminal. Applying the +server host's macOS Option and Command conventions to those events makes the +same input encode differently on macOS and Linux. Proxy mode trusts the event's +modifiers and generated text, and preserves complete Alt-prefixed UTF-8 +without changing Ghostty's default local input behavior. Herdr reapplies the +caller-owned option after every terminal-state refresh. + +remove when: upstream libghostty-vt exposes equivalent host-independent proxy +encoding semantics and Herdr's cross-platform keyboard corpus passes without +this patch. + +verification: + +```sh +cd vendor/libghostty-vt && zig build test-lib-vt -Dsimd=true +just test-one keyboard_corpus_survives_fragmentation_and_pane_encoding +uv run python -m unittest scripts.test_vendor_libghostty_vt +``` + +## 0006 support Kitty function keys through F35 + +status: active + +patch: `vendor/patches/libghostty-vt/0006-extended-function-keys-f35.patch` + +herdr issue: https://github.com/herdrdev/herdr/issues/2514 + +upstream discussion: not opened + +upstream pr: not opened + +vendored base: `c5a21edfcbc2d5b46540ad91b7980aca31f5f1f3` + +local files: + +- `vendor/libghostty-vt/include/ghostty/vt/key/event.h` +- `vendor/libghostty-vt/src/input/function_keys.zig` +- `vendor/libghostty-vt/src/input/key.zig` +- `vendor/libghostty-vt/src/input/key_encode.zig` +- `vendor/libghostty-vt/src/input/kitty.zig` + +reason: the Kitty protocol defines F13-F35, but libghostty-vt stops its key +model at F25. Herdr can receive F26-F35 from its host terminal, so dropping +those events makes the proxy incomplete. The extension appends ABI values, +preserves existing key constants, and maps F26-F35 to their standard Kitty and +legacy xterm sequences. + +remove when: upstream libghostty-vt models and encodes F26-F35 and Herdr's +keyboard corpus passes without this patch. + +verification: + +```sh +cd vendor/libghostty-vt && zig build test-lib-vt -Dsimd=true +just test-one ghostty_encodes_all_kitty_extended_function_keys +just test-one keyboard_corpus_survives_fragmentation_and_pane_encoding +``` diff --git a/vendor/libghostty-vt/include/ghostty/vt/key/encoder.h b/vendor/libghostty-vt/include/ghostty/vt/key/encoder.h index 3aeec6597b..76246e150d 100644 --- a/vendor/libghostty-vt/include/ghostty/vt/key/encoder.h +++ b/vendor/libghostty-vt/include/ghostty/vt/key/encoder.h @@ -113,6 +113,12 @@ typedef enum GHOSTTY_ENUM_TYPED { */ GHOSTTY_KEY_ENCODER_OPT_BACKARROW_KEY_MODE = 7, + /** Input events originated in another terminal and already carry semantic + * modifiers and generated text (value: bool). This makes encoding + * independent of host OS input conventions. + */ + GHOSTTY_KEY_ENCODER_OPT_PROXY_EVENTS = 8, + GHOSTTY_KEY_ENCODER_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyKeyEncoderOption; diff --git a/vendor/libghostty-vt/include/ghostty/vt/key/event.h b/vendor/libghostty-vt/include/ghostty/vt/key/event.h index eba433c6a5..be647e0903 100644 --- a/vendor/libghostty-vt/include/ghostty/vt/key/event.h +++ b/vendor/libghostty-vt/include/ghostty/vt/key/event.h @@ -68,6 +68,10 @@ typedef uint16_t GhosttyMods; #define GHOSTTY_MODS_CAPS_LOCK (1 << 4) /** Num Lock is active */ #define GHOSTTY_MODS_NUM_LOCK (1 << 5) +/** Hyper key is pressed */ +#define GHOSTTY_MODS_HYPER (1 << 10) +/** Meta key is pressed */ +#define GHOSTTY_MODS_META (1 << 11) /** * Right shift is pressed (0 = left, 1 = right). @@ -297,6 +301,18 @@ typedef enum GHOSTTY_ENUM_TYPED { GHOSTTY_KEY_COPY, GHOSTTY_KEY_CUT, GHOSTTY_KEY_PASTE, + + // Kitty protocol extended function keys, appended for ABI stability. + GHOSTTY_KEY_F26, + GHOSTTY_KEY_F27, + GHOSTTY_KEY_F28, + GHOSTTY_KEY_F29, + GHOSTTY_KEY_F30, + GHOSTTY_KEY_F31, + GHOSTTY_KEY_F32, + GHOSTTY_KEY_F33, + GHOSTTY_KEY_F34, + GHOSTTY_KEY_F35, GHOSTTY_KEY_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyKey; @@ -479,4 +495,16 @@ GHOSTTY_API void ghostty_key_event_set_unshifted_codepoint(GhosttyKeyEvent event */ GHOSTTY_API uint32_t ghostty_key_event_get_unshifted_codepoint(GhosttyKeyEvent event); +/** Set an explicit Kitty shifted alternate codepoint. */ +GHOSTTY_API void ghostty_key_event_set_shifted_codepoint(GhosttyKeyEvent event, uint32_t codepoint); + +/** Get the explicit Kitty shifted alternate codepoint, or zero when absent. */ +GHOSTTY_API uint32_t ghostty_key_event_get_shifted_codepoint(GhosttyKeyEvent event); + +/** Set an explicit Kitty base-layout alternate codepoint. */ +GHOSTTY_API void ghostty_key_event_set_base_layout_codepoint(GhosttyKeyEvent event, uint32_t codepoint); + +/** Get the explicit Kitty base-layout alternate codepoint, or zero when absent. */ +GHOSTTY_API uint32_t ghostty_key_event_get_base_layout_codepoint(GhosttyKeyEvent event); + #endif /* GHOSTTY_VT_KEY_EVENT_H */ diff --git a/vendor/libghostty-vt/src/input/function_keys.zig b/vendor/libghostty-vt/src/input/function_keys.zig index 66ab4bc4d5..0d89dcff29 100644 --- a/vendor/libghostty-vt/src/input/function_keys.zig +++ b/vendor/libghostty-vt/src/input/function_keys.zig @@ -89,10 +89,10 @@ pub const keys = keys: { result.set(.page_up, pcStyle("\x1b[5;{}~") ++ .{Entry{ .sequence = "\x1B[5~" }}); result.set(.page_down, pcStyle("\x1b[6;{}~") ++ .{Entry{ .sequence = "\x1B[6~" }}); - // Function Keys. todo: f13-f35 but we need to add to input.Key + // Function Keys. result.set(.f1, pcStyle("\x1b[1;{}P") ++ .{Entry{ .sequence = "\x1BOP" }}); result.set(.f2, pcStyle("\x1b[1;{}Q") ++ .{Entry{ .sequence = "\x1BOQ" }}); - result.set(.f3, pcStyle("\x1b[13;{}~") ++ .{Entry{ .sequence = "\x1BOR" }}); + result.set(.f3, pcStyle("\x1b[1;{}R") ++ .{Entry{ .sequence = "\x1BOR" }}); result.set(.f4, pcStyle("\x1b[1;{}S") ++ .{Entry{ .sequence = "\x1BOS" }}); result.set(.f5, pcStyle("\x1b[15;{}~") ++ .{Entry{ .sequence = "\x1B[15~" }}); result.set(.f6, pcStyle("\x1b[17;{}~") ++ .{Entry{ .sequence = "\x1B[17~" }}); @@ -102,6 +102,29 @@ pub const keys = keys: { result.set(.f10, pcStyle("\x1b[21;{}~") ++ .{Entry{ .sequence = "\x1B[21~" }}); result.set(.f11, pcStyle("\x1b[23;{}~") ++ .{Entry{ .sequence = "\x1B[23~" }}); result.set(.f12, pcStyle("\x1b[24;{}~") ++ .{Entry{ .sequence = "\x1B[24~" }}); + result.set(.f13, pcStyleWithImplicitMods("\x1b[1;{}P", .{ .shift = true }) ++ .{Entry{ .mods_empty_is_any = false, .sequence = "\x1b[1;2P" }}); + result.set(.f14, pcStyleWithImplicitMods("\x1b[1;{}Q", .{ .shift = true }) ++ .{Entry{ .mods_empty_is_any = false, .sequence = "\x1b[1;2Q" }}); + result.set(.f15, pcStyleWithImplicitMods("\x1b[1;{}R", .{ .shift = true }) ++ .{Entry{ .mods_empty_is_any = false, .sequence = "\x1b[1;2R" }}); + result.set(.f16, pcStyleWithImplicitMods("\x1b[1;{}S", .{ .shift = true }) ++ .{Entry{ .mods_empty_is_any = false, .sequence = "\x1b[1;2S" }}); + result.set(.f17, pcStyleWithImplicitMods("\x1b[15;{}~", .{ .shift = true }) ++ .{Entry{ .mods_empty_is_any = false, .sequence = "\x1b[15;2~" }}); + result.set(.f18, pcStyleWithImplicitMods("\x1b[17;{}~", .{ .shift = true }) ++ .{Entry{ .mods_empty_is_any = false, .sequence = "\x1b[17;2~" }}); + result.set(.f19, pcStyleWithImplicitMods("\x1b[18;{}~", .{ .shift = true }) ++ .{Entry{ .mods_empty_is_any = false, .sequence = "\x1b[18;2~" }}); + result.set(.f20, pcStyleWithImplicitMods("\x1b[19;{}~", .{ .shift = true }) ++ .{Entry{ .mods_empty_is_any = false, .sequence = "\x1b[19;2~" }}); + result.set(.f21, pcStyleWithImplicitMods("\x1b[20;{}~", .{ .shift = true }) ++ .{Entry{ .mods_empty_is_any = false, .sequence = "\x1b[20;2~" }}); + result.set(.f22, pcStyleWithImplicitMods("\x1b[21;{}~", .{ .shift = true }) ++ .{Entry{ .mods_empty_is_any = false, .sequence = "\x1b[21;2~" }}); + result.set(.f23, pcStyleWithImplicitMods("\x1b[23;{}~", .{ .shift = true }) ++ .{Entry{ .mods_empty_is_any = false, .sequence = "\x1b[23;2~" }}); + result.set(.f24, pcStyleWithImplicitMods("\x1b[24;{}~", .{ .shift = true }) ++ .{Entry{ .mods_empty_is_any = false, .sequence = "\x1b[24;2~" }}); + result.set(.f25, pcStyleWithImplicitMods("\x1b[1;{}P", .{ .ctrl = true }) ++ .{Entry{ .mods_empty_is_any = false, .sequence = "\x1b[1;5P" }}); + result.set(.f26, pcStyleWithImplicitMods("\x1b[1;{}Q", .{ .ctrl = true }) ++ .{Entry{ .mods_empty_is_any = false, .sequence = "\x1b[1;5Q" }}); + result.set(.f27, pcStyleWithImplicitMods("\x1b[1;{}R", .{ .ctrl = true }) ++ .{Entry{ .mods_empty_is_any = false, .sequence = "\x1b[1;5R" }}); + result.set(.f28, pcStyleWithImplicitMods("\x1b[1;{}S", .{ .ctrl = true }) ++ .{Entry{ .mods_empty_is_any = false, .sequence = "\x1b[1;5S" }}); + result.set(.f29, pcStyleWithImplicitMods("\x1b[15;{}~", .{ .ctrl = true }) ++ .{Entry{ .mods_empty_is_any = false, .sequence = "\x1b[15;5~" }}); + result.set(.f30, pcStyleWithImplicitMods("\x1b[17;{}~", .{ .ctrl = true }) ++ .{Entry{ .mods_empty_is_any = false, .sequence = "\x1b[17;5~" }}); + result.set(.f31, pcStyleWithImplicitMods("\x1b[18;{}~", .{ .ctrl = true }) ++ .{Entry{ .mods_empty_is_any = false, .sequence = "\x1b[18;5~" }}); + result.set(.f32, pcStyleWithImplicitMods("\x1b[19;{}~", .{ .ctrl = true }) ++ .{Entry{ .mods_empty_is_any = false, .sequence = "\x1b[19;5~" }}); + result.set(.f33, pcStyleWithImplicitMods("\x1b[20;{}~", .{ .ctrl = true }) ++ .{Entry{ .mods_empty_is_any = false, .sequence = "\x1b[20;5~" }}); + result.set(.f34, pcStyleWithImplicitMods("\x1b[21;{}~", .{ .ctrl = true }) ++ .{Entry{ .mods_empty_is_any = false, .sequence = "\x1b[21;5~" }}); + result.set(.f35, pcStyleWithImplicitMods("\x1b[23;{}~", .{ .ctrl = true }) ++ .{Entry{ .mods_empty_is_any = false, .sequence = "\x1b[23;5~" }}); // Keypad keys result.set(.numpad_0, kpKeys("p")); @@ -294,6 +317,25 @@ fn pcStyle(comptime fmt: []const u8) []Entry { } } +fn pcStyleWithImplicitMods(comptime fmt: []const u8, comptime implicit: key.Mods) []Entry { + comptime { + @setEvalBranchQuota(500_000); + var entries: [modifiers.len]Entry = undefined; + for (modifiers, 0..) |mods, i| { + const code: u8 = 1 + + @as(u8, @intFromBool(mods.shift or implicit.shift)) + + 2 * @as(u8, @intFromBool(mods.alt or implicit.alt)) + + 4 * @as(u8, @intFromBool(mods.ctrl or implicit.ctrl)) + + 8 * @as(u8, @intFromBool(mods.super or implicit.super)); + entries[i] = .{ + .mods = mods, + .sequence = std.fmt.comptimePrint(fmt, .{code}), + }; + } + return &entries; + } +} + test "keys" { const testing = std.testing; switch (@import("terminal_options").artifact) { diff --git a/vendor/libghostty-vt/src/input/key.zig b/vendor/libghostty-vt/src/input/key.zig index 9c04f01e04..12d63529e8 100644 --- a/vendor/libghostty-vt/src/input/key.zig +++ b/vendor/libghostty-vt/src/input/key.zig @@ -47,6 +47,12 @@ pub const KeyEvent = struct { /// shift+a is "A" in UTF-8 but unshifted would provide 'a'. unshifted_codepoint: u21 = 0, + /// Explicit Kitty shifted alternate supplied by a terminal proxy. + shifted_codepoint: u21 = 0, + + /// Explicit Kitty base-layout alternate supplied by a terminal proxy. + base_layout_codepoint: u21 = 0, + /// Returns the effective modifiers for this event. The effective /// modifiers are the mods that should be considered for keybindings. pub fn effectiveMods(self: KeyEvent) Mods { @@ -305,6 +311,18 @@ pub const Key = enum(c_int) { cut, paste, + // Kitty protocol extended function keys, appended for ABI stability. + f26, + f27, + f28, + f29, + f30, + f31, + f32, + f33, + f34, + f35, + /// Converts an ASCII character to a key, if possible. This returns /// null if the character is unknown. /// @@ -530,7 +548,7 @@ pub const Key = enum(c_int) { return switch (self) { inline else => |tag| { return comptime result: { - @setEvalBranchQuota(10_000); + @setEvalBranchQuota(20_000); for (codepoint_map) |entry| { if (entry[1] == tag) break :result entry[0]; } @@ -683,6 +701,16 @@ pub const Key = enum(c_int) { .f23, .f24, .f25, + .f26, + .f27, + .f28, + .f29, + .f30, + .f31, + .f32, + .f33, + .f34, + .f35, .intl_backslash, .intl_ro, .intl_yen, diff --git a/vendor/libghostty-vt/src/input/key_encode.zig b/vendor/libghostty-vt/src/input/key_encode.zig index 6ab5a4cc85..d48c975def 100644 --- a/vendor/libghostty-vt/src/input/key_encode.zig +++ b/vendor/libghostty-vt/src/input/key_encode.zig @@ -42,6 +42,11 @@ pub const Options = struct { /// docs for a more detailed description of why this is needed. macos_option_as_alt: OptionAsAlt = .false, + /// Input events originated in another terminal and already carry + /// semantic modifiers and generated text. This disables host OS input + /// reinterpretation so encoding is independent of the proxy's host. + proxy_events: bool = false, + pub const default: Options = .{ .cursor_key_application = false, .keypad_key_application = false, @@ -50,6 +55,7 @@ pub const Options = struct { .modify_other_keys_state_2 = false, .kitty_flags = .disabled, .macos_option_as_alt = .false, + .proxy_events = false, }; /// Initialize our options from the terminal state. @@ -68,6 +74,7 @@ pub const Options = struct { // These can't be known from the terminal state. .macos_option_as_alt = .false, + .proxy_events = false, }; } }; @@ -198,7 +205,8 @@ fn kitty( // We don't send release events because those are specially encoded. if (event.utf8.len > 0 and binding_mods.empty() and - event.action != .release) + (event.action == .press or + (event.action == .repeat and !opts.kitty_flags.report_events))) plain_text: { // We only do this for printable characters. We should // inspect the real unicode codepoint properties here but @@ -287,6 +295,20 @@ fn kitty( if (base != seq.key) seq.alternates[1] = base; } } + + // Terminal proxies may know alternates that cannot be recovered + // from physical identity or multi-codepoint generated text. + if (event.shifted_codepoint > 0 and + event.shifted_codepoint != seq.key and + seq.mods.shift) + { + seq.alternates[0] = event.shifted_codepoint; + } + if (event.base_layout_codepoint > 0 and + event.base_layout_codepoint != seq.key) + { + seq.alternates[1] = event.base_layout_codepoint; + } } if (opts.kitty_flags.report_associated and @@ -295,7 +317,7 @@ fn kitty( // Determine if the Alt modifier should be treated as an actual // modifier (in which case it prevents associated text) or as // the macOS Option key, which does not prevent associated text. - const alt_prevents_text = if (comptime builtin.os.tag == .macos) + const alt_prevents_text = if (builtin.os.tag == .macos and !opts.proxy_events) switch (opts.macos_option_as_alt) { .left => all_mods.sides.alt == .left, .right => all_mods.sides.alt == .right, @@ -401,7 +423,11 @@ fn legacy( // alt-prefix handling of unshifted codepoints... so we process that. const utf8 = event.utf8; if (utf8.len == 0) { - if (try legacyAltPrefix( + if (opts.proxy_events and proxyAltPrefixEnabled(binding_mods, opts)) { + if (std.math.cast(u8, event.unshifted_codepoint)) |byte| { + try writer.print("\x1B{c}", .{byte}); + } + } else if (try legacyAltPrefix( event, binding_mods, all_mods, @@ -429,7 +455,7 @@ fn legacy( // super, alt unless it is actually option). const mods = mods: { var mods_binding = event.mods.binding(); - if (comptime builtin.target.os.tag.isDarwin()) alt: { + if (builtin.target.os.tag.isDarwin() and !opts.proxy_events) alt: { switch (opts.macos_option_as_alt) { .false => {}, .true => break :alt, @@ -522,7 +548,12 @@ fn legacy( // If we have alt-pressed and alt-esc-prefix is enabled, then // we need to prefix the utf8 sequence with an esc. - if (try legacyAltPrefix( + if (opts.proxy_events) { + if (proxyAltPrefixEnabled(binding_mods, opts)) { + try writer.writeByte(0x1B); + return try writer.writeAll(utf8); + } + } else if (try legacyAltPrefix( event, binding_mods, all_mods, @@ -540,13 +571,17 @@ fn legacy( // For Linux, we continue to encode text because it is typical. // For example on Gnome Console Super+b will encode a "b" character // with legacy encoding. - if ((comptime builtin.os.tag == .macos) and all_mods.super) { + if (builtin.os.tag == .macos and !opts.proxy_events and all_mods.super) { return; } return try writer.writeAll(utf8); } +fn proxyAltPrefixEnabled(binding_mods: key.Mods, opts: Options) bool { + return binding_mods.alt and opts.alt_esc_prefix; +} + fn legacyAltPrefix( event: key.KeyEvent, binding_mods: key.Mods, @@ -918,6 +953,8 @@ const KittyMods = packed struct(u8) { .alt = mods.alt, .ctrl = mods.ctrl, .super = mods.super, + .hyper = mods.hyper, + .meta = mods.meta, .caps_lock = mods.caps_lock, .num_lock = mods.num_lock, }; @@ -1247,6 +1284,21 @@ test "kitty: repeat with just disambiguate" { }); try testing.expectEqualStrings("a", writer.buffered()); } + +test "kitty: repeat with event reporting" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .key_a, + .action = .repeat, + .mods = .{}, + .utf8 = "a", + .unshifted_codepoint = 'a', + }, .{ + .kitty_flags = .{ .disambiguate = true, .report_events = true }, + }); + try testing.expectEqualStrings("\x1b[97;1:2u", writer.buffered()); +} // test "kitty: enter, backspace, tab" { var buf: [128]u8 = undefined; @@ -1700,6 +1752,26 @@ test "kitty: left shift with report all" { try testing.expectEqualStrings("\x1b[57441u", writer.buffered()); } +test "kitty: proxy alt is a modifier for associated text" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .key_w, + .mods = .{ .alt = true }, + .utf8 = "∑", + .unshifted_codepoint = 119, + }, .{ + .kitty_flags = .{ + .disambiguate = true, + .report_all = true, + .report_alternates = true, + .report_associated = true, + }, + .proxy_events = true, + }); + try testing.expectEqualStrings("\x1b[119;3u", writer.buffered()); +} + test "kitty: report associated with alt text on macOS with option" { if (comptime !builtin.target.os.tag.isDarwin()) return error.SkipZigTest; @@ -1993,6 +2065,93 @@ test "legacy: ctrl+alt+c" { try testing.expectEqualStrings("\x1b\x03", writer.buffered()); } +test "legacy: proxy alt without generated text ignores host option policy" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .key_c, + .mods = .{ .alt = true }, + .unshifted_codepoint = 'c', + }, .{ + .alt_esc_prefix = true, + .proxy_events = true, + }); + try testing.expectEqualStrings("\x1bc", writer.buffered()); +} + +test "legacy: proxy super preserves semantic text" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .key_b, + .mods = .{ .super = true }, + .utf8 = "b", + .unshifted_codepoint = 'b', + }, .{ .proxy_events = true }); + try testing.expectEqualStrings("b", writer.buffered()); +} + +test "legacy: proxy alt with modify other keys preserves the modifier" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .key_e, + .mods = .{ .alt = true }, + .utf8 = "é", + .unshifted_codepoint = 'e', + }, .{ + .modify_other_keys_state_2 = true, + .proxy_events = true, + }); + try testing.expectEqualStrings("\x1b[27;3;233~", writer.buffered()); +} + +test "legacy: alt+unicode prefixes the complete utf8 text" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .unidentified, + .mods = .{ .alt = true }, + .utf8 = "é", + .unshifted_codepoint = 'é', + }, .{ + .alt_esc_prefix = true, + .proxy_events = true, + }); + try testing.expectEqualStrings("\x1bé", writer.buffered()); +} + +test "legacy: alt with invalid unshifted codepoint preserves utf8 text" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .unidentified, + .mods = .{ .alt = true }, + .utf8 = "é", + .unshifted_codepoint = 0xD800, + }, .{ + .alt_esc_prefix = true, + .proxy_events = true, + }); + try testing.expectEqualStrings("\x1bé", writer.buffered()); +} + +test "legacy: alt+shift preserves shifted text" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .period, + .mods = .{ .alt = true, .shift = true }, + .consumed_mods = .{ .shift = true }, + .utf8 = ">", + .unshifted_codepoint = '.', + }, .{ + .alt_esc_prefix = true, + .proxy_events = true, + }); + try testing.expectEqualStrings("\x1b>", writer.buffered()); +} + test "legacy: alt+c" { var buf: [128]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); @@ -2369,7 +2528,7 @@ test "legacy: f1" { .mods = .{ .ctrl = true }, .consumed_mods = .{}, }, .{}); - try testing.expectEqualStrings("\x1b[13;5~", writer.buffered()); + try testing.expectEqualStrings("\x1b[1;5R", writer.buffered()); } // F4 @@ -2395,6 +2554,30 @@ test "legacy: f1" { } } +test "legacy: extended function keys" { + var buf: [128]u8 = undefined; + + const cases = .{ + .{ key.Key.f13, "\x1b[1;2P" }, + .{ key.Key.f24, "\x1b[24;2~" }, + .{ key.Key.f25, "\x1b[1;5P" }, + .{ key.Key.f26, "\x1b[1;5Q" }, + .{ key.Key.f35, "\x1b[23;5~" }, + }; + inline for (cases) |case| { + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ .key = case[0] }, .{}); + try testing.expectEqualStrings(case[1], writer.buffered()); + } + + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .f13, + .mods = .{ .ctrl = true }, + }, .{}); + try testing.expectEqualStrings("\x1b[1;6P", writer.buffered()); +} + test "legacy: left_shift+tab" { var buf: [128]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); diff --git a/vendor/libghostty-vt/src/input/key_mods.zig b/vendor/libghostty-vt/src/input/key_mods.zig index 35e1c10383..0c7f65104e 100644 --- a/vendor/libghostty-vt/src/input/key_mods.zig +++ b/vendor/libghostty-vt/src/input/key_mods.zig @@ -36,7 +36,9 @@ pub const Mods = packed struct(Mods.Backing) { caps_lock: bool = false, num_lock: bool = false, sides: Side = .{}, - _padding: u6 = 0, + hyper: bool = false, + meta: bool = false, + _padding: u4 = 0, /// The standard modifier keys only. Does not include the lock keys, /// only standard bindable keys. @@ -109,6 +111,8 @@ pub const Mods = packed struct(Mods.Backing) { .ctrl = self.ctrl, .alt = self.alt, .super = self.super, + .hyper = self.hyper, + .meta = self.meta, }; } @@ -164,6 +168,14 @@ pub const Mods = packed struct(Mods.Backing) { @as(Backing, @bitCast(Mods{ .shift = true })), @as(Backing, 0b0000_0001), ); + try testing.expectEqual( + @as(Backing, @bitCast(Mods{ .hyper = true })), + @as(Backing, 1 << 10), + ); + try testing.expectEqual( + @as(Backing, @bitCast(Mods{ .meta = true })), + @as(Backing, 1 << 11), + ); } test "translation macos-option-as-alt" { diff --git a/vendor/libghostty-vt/src/input/kitty.zig b/vendor/libghostty-vt/src/input/kitty.zig index e5789cc402..16809c2590 100644 --- a/vendor/libghostty-vt/src/input/kitty.zig +++ b/vendor/libghostty-vt/src/input/kitty.zig @@ -87,6 +87,16 @@ const raw_entries: []const RawEntry = &.{ .{ .f23, 57386, 'u', false }, .{ .f24, 57387, 'u', false }, .{ .f25, 57388, 'u', false }, + .{ .f26, 57389, 'u', false }, + .{ .f27, 57390, 'u', false }, + .{ .f28, 57391, 'u', false }, + .{ .f29, 57392, 'u', false }, + .{ .f30, 57393, 'u', false }, + .{ .f31, 57394, 'u', false }, + .{ .f32, 57395, 'u', false }, + .{ .f33, 57396, 'u', false }, + .{ .f34, 57397, 'u', false }, + .{ .f35, 57398, 'u', false }, .{ .numpad_0, 57399, 'u', false }, .{ .numpad_1, 57400, 'u', false }, diff --git a/vendor/libghostty-vt/src/lib_vt.zig b/vendor/libghostty-vt/src/lib_vt.zig index e01cdbb892..096f1a8799 100644 --- a/vendor/libghostty-vt/src/lib_vt.zig +++ b/vendor/libghostty-vt/src/lib_vt.zig @@ -162,6 +162,10 @@ comptime { @export(&c.key_event_get_utf8, .{ .name = "ghostty_key_event_get_utf8" }); @export(&c.key_event_set_unshifted_codepoint, .{ .name = "ghostty_key_event_set_unshifted_codepoint" }); @export(&c.key_event_get_unshifted_codepoint, .{ .name = "ghostty_key_event_get_unshifted_codepoint" }); + @export(&c.key_event_set_shifted_codepoint, .{ .name = "ghostty_key_event_set_shifted_codepoint" }); + @export(&c.key_event_get_shifted_codepoint, .{ .name = "ghostty_key_event_get_shifted_codepoint" }); + @export(&c.key_event_set_base_layout_codepoint, .{ .name = "ghostty_key_event_set_base_layout_codepoint" }); + @export(&c.key_event_get_base_layout_codepoint, .{ .name = "ghostty_key_event_get_base_layout_codepoint" }); @export(&c.key_encoder_new, .{ .name = "ghostty_key_encoder_new" }); @export(&c.key_encoder_free, .{ .name = "ghostty_key_encoder_free" }); @export(&c.key_encoder_setopt, .{ .name = "ghostty_key_encoder_setopt" }); diff --git a/vendor/libghostty-vt/src/terminal/c/key_encode.zig b/vendor/libghostty-vt/src/terminal/c/key_encode.zig index f5d459f01e..4398c6e6c4 100644 --- a/vendor/libghostty-vt/src/terminal/c/key_encode.zig +++ b/vendor/libghostty-vt/src/terminal/c/key_encode.zig @@ -57,6 +57,8 @@ pub const Option = enum(c_int) { /// If `false` (the default), `backspace` emits 0x7f /// If `true`, `backspace` emits 0x08 backarrow_key_mode = 7, + /// Events originated in another terminal and already have semantic input. + proxy_events = 8, /// Input type expected for setting the option. pub fn InType(comptime self: Option) type { @@ -67,6 +69,7 @@ pub const Option = enum(c_int) { .alt_esc_prefix, .modify_other_keys_state_2, .backarrow_key_mode, + .proxy_events, => bool, .kitty_flags => u8, .macos_option_as_alt => OptionAsAlt, @@ -121,6 +124,7 @@ fn setoptTyped( opts.macos_option_as_alt = value.*; }, .backarrow_key_mode => opts.backarrow_key_mode = value.*, + .proxy_events => opts.proxy_events = value.*, } } @@ -198,6 +202,9 @@ test "setopt bool" { setopt(e, .keypad_key_application, &val_true); try testing.expect(e.?.opts.keypad_key_application); + + setopt(e, .proxy_events, &val_true); + try testing.expect(e.?.opts.proxy_events); } test "setopt kitty flags" { diff --git a/vendor/libghostty-vt/src/terminal/c/key_event.zig b/vendor/libghostty-vt/src/terminal/c/key_event.zig index 1feac9ac58..f2e71f6505 100644 --- a/vendor/libghostty-vt/src/terminal/c/key_event.zig +++ b/vendor/libghostty-vt/src/terminal/c/key_event.zig @@ -121,6 +121,26 @@ pub fn get_unshifted_codepoint(event_: Event) callconv(lib.calling_conv) u32 { return event.unshifted_codepoint; } +pub fn set_shifted_codepoint(event_: Event, codepoint: u32) callconv(lib.calling_conv) void { + const event: *key.KeyEvent = &event_.?.event; + event.shifted_codepoint = @truncate(codepoint); +} + +pub fn get_shifted_codepoint(event_: Event) callconv(lib.calling_conv) u32 { + const event: *key.KeyEvent = &event_.?.event; + return event.shifted_codepoint; +} + +pub fn set_base_layout_codepoint(event_: Event, codepoint: u32) callconv(lib.calling_conv) void { + const event: *key.KeyEvent = &event_.?.event; + event.base_layout_codepoint = @truncate(codepoint); +} + +pub fn get_base_layout_codepoint(event_: Event) callconv(lib.calling_conv) u32 { + const event: *key.KeyEvent = &event_.?.event; + return event.base_layout_codepoint; +} + test "alloc" { const testing = std.testing; var e: Event = undefined; @@ -176,6 +196,10 @@ test "set" { // Test unshifted codepoint set_unshifted_codepoint(e, 'a'); try testing.expectEqual(@as(u21, 'a'), e.?.event.unshifted_codepoint); + set_shifted_codepoint(e, 'A'); + try testing.expectEqual(@as(u21, 'A'), e.?.event.shifted_codepoint); + set_base_layout_codepoint(e, 'q'); + try testing.expectEqual(@as(u21, 'q'), e.?.event.base_layout_codepoint); } test "get" { @@ -203,6 +227,8 @@ test "get" { set_utf8(e, text.ptr, text.len); set_unshifted_codepoint(e, 'z'); + set_shifted_codepoint(e, 'Z'); + set_base_layout_codepoint(e, 'y'); // Get them back try testing.expectEqual(key.Action.repeat, get_action(e)); @@ -225,6 +251,8 @@ test "get" { try testing.expectEqualStrings("test", got_utf8.?[0..utf8_len]); try testing.expectEqual(@as(u32, 'z'), get_unshifted_codepoint(e)); + try testing.expectEqual(@as(u32, 'Z'), get_shifted_codepoint(e)); + try testing.expectEqual(@as(u32, 'y'), get_base_layout_codepoint(e)); } test "complete key event" { diff --git a/vendor/libghostty-vt/src/terminal/c/main.zig b/vendor/libghostty-vt/src/terminal/c/main.zig index 37dc57684a..e45a499b49 100644 --- a/vendor/libghostty-vt/src/terminal/c/main.zig +++ b/vendor/libghostty-vt/src/terminal/c/main.zig @@ -130,6 +130,10 @@ pub const key_event_set_utf8 = key_event.set_utf8; pub const key_event_get_utf8 = key_event.get_utf8; pub const key_event_set_unshifted_codepoint = key_event.set_unshifted_codepoint; pub const key_event_get_unshifted_codepoint = key_event.get_unshifted_codepoint; +pub const key_event_set_shifted_codepoint = key_event.set_shifted_codepoint; +pub const key_event_get_shifted_codepoint = key_event.get_shifted_codepoint; +pub const key_event_set_base_layout_codepoint = key_event.set_base_layout_codepoint; +pub const key_event_get_base_layout_codepoint = key_event.get_base_layout_codepoint; pub const key_encoder_new = key_encode.new; pub const key_encoder_free = key_encode.free; diff --git a/vendor/patches/libghostty-vt/0002-proxied-kitty-key-metadata.patch b/vendor/patches/libghostty-vt/0002-proxied-kitty-key-metadata.patch new file mode 100644 index 0000000000..e4b1f56b4d --- /dev/null +++ b/vendor/patches/libghostty-vt/0002-proxied-kitty-key-metadata.patch @@ -0,0 +1,227 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: herdr maintainers +Date: Sun, 9 Aug 2026 00:00:00 +0000 +Subject: [PATCH] preserve proxied kitty key metadata + +Extend the lib-vt key event C API so terminal proxies can preserve explicit +Kitty alternate codepoints and Hyper/Meta modifiers that cannot be derived +from local physical-key or layout state. + +Herdr issue: https://github.com/herdrdev/herdr/issues/2514 +Vendored base: c5a21edfcbc2d5b46540ad91b7980aca31f5f1f3 +--- +diff --git a/vendor/libghostty-vt/include/ghostty/vt/key/event.h b/vendor/libghostty-vt/include/ghostty/vt/key/event.h +index eba433c6..5777209a 100644 +--- a/vendor/libghostty-vt/include/ghostty/vt/key/event.h ++++ b/vendor/libghostty-vt/include/ghostty/vt/key/event.h +@@ -68,6 +68,10 @@ typedef uint16_t GhosttyMods; + #define GHOSTTY_MODS_CAPS_LOCK (1 << 4) + /** Num Lock is active */ + #define GHOSTTY_MODS_NUM_LOCK (1 << 5) ++/** Hyper key is pressed */ ++#define GHOSTTY_MODS_HYPER (1 << 10) ++/** Meta key is pressed */ ++#define GHOSTTY_MODS_META (1 << 11) + + /** + * Right shift is pressed (0 = left, 1 = right). +@@ -479,4 +483,16 @@ GHOSTTY_API void ghostty_key_event_set_unshifted_codepoint(GhosttyKeyEvent event + */ + GHOSTTY_API uint32_t ghostty_key_event_get_unshifted_codepoint(GhosttyKeyEvent event); + ++/** Set an explicit Kitty shifted alternate codepoint. */ ++GHOSTTY_API void ghostty_key_event_set_shifted_codepoint(GhosttyKeyEvent event, uint32_t codepoint); ++ ++/** Get the explicit Kitty shifted alternate codepoint, or zero when absent. */ ++GHOSTTY_API uint32_t ghostty_key_event_get_shifted_codepoint(GhosttyKeyEvent event); ++ ++/** Set an explicit Kitty base-layout alternate codepoint. */ ++GHOSTTY_API void ghostty_key_event_set_base_layout_codepoint(GhosttyKeyEvent event, uint32_t codepoint); ++ ++/** Get the explicit Kitty base-layout alternate codepoint, or zero when absent. */ ++GHOSTTY_API uint32_t ghostty_key_event_get_base_layout_codepoint(GhosttyKeyEvent event); ++ + #endif /* GHOSTTY_VT_KEY_EVENT_H */ +diff --git a/vendor/libghostty-vt/src/input/key.zig b/vendor/libghostty-vt/src/input/key.zig +index 9c04f01e..a2139805 100644 +--- a/vendor/libghostty-vt/src/input/key.zig ++++ b/vendor/libghostty-vt/src/input/key.zig +@@ -47,6 +47,12 @@ pub const KeyEvent = struct { + /// shift+a is "A" in UTF-8 but unshifted would provide 'a'. + unshifted_codepoint: u21 = 0, + ++ /// Explicit Kitty shifted alternate supplied by a terminal proxy. ++ shifted_codepoint: u21 = 0, ++ ++ /// Explicit Kitty base-layout alternate supplied by a terminal proxy. ++ base_layout_codepoint: u21 = 0, ++ + /// Returns the effective modifiers for this event. The effective + /// modifiers are the mods that should be considered for keybindings. + pub fn effectiveMods(self: KeyEvent) Mods { +diff --git a/vendor/libghostty-vt/src/input/key_encode.zig b/vendor/libghostty-vt/src/input/key_encode.zig +index 6ab5a4cc..08f56b04 100644 +--- a/vendor/libghostty-vt/src/input/key_encode.zig ++++ b/vendor/libghostty-vt/src/input/key_encode.zig +@@ -287,6 +287,20 @@ fn kitty( + if (base != seq.key) seq.alternates[1] = base; + } + } ++ ++ // Terminal proxies may know alternates that cannot be recovered ++ // from physical identity or multi-codepoint generated text. ++ if (event.shifted_codepoint > 0 and ++ event.shifted_codepoint != seq.key and ++ seq.mods.shift) ++ { ++ seq.alternates[0] = event.shifted_codepoint; ++ } ++ if (event.base_layout_codepoint > 0 and ++ event.base_layout_codepoint != seq.key) ++ { ++ seq.alternates[1] = event.base_layout_codepoint; ++ } + } + + if (opts.kitty_flags.report_associated and +@@ -918,6 +932,8 @@ const KittyMods = packed struct(u8) { + .alt = mods.alt, + .ctrl = mods.ctrl, + .super = mods.super, ++ .hyper = mods.hyper, ++ .meta = mods.meta, + .caps_lock = mods.caps_lock, + .num_lock = mods.num_lock, + }; +diff --git a/vendor/libghostty-vt/src/input/key_mods.zig b/vendor/libghostty-vt/src/input/key_mods.zig +index 35e1c103..d89e2674 100644 +--- a/vendor/libghostty-vt/src/input/key_mods.zig ++++ b/vendor/libghostty-vt/src/input/key_mods.zig +@@ -36,7 +36,9 @@ pub const Mods = packed struct(Mods.Backing) { + caps_lock: bool = false, + num_lock: bool = false, + sides: Side = .{}, +- _padding: u6 = 0, ++ hyper: bool = false, ++ meta: bool = false, ++ _padding: u4 = 0, + + /// The standard modifier keys only. Does not include the lock keys, + /// only standard bindable keys. +@@ -109,6 +111,8 @@ pub const Mods = packed struct(Mods.Backing) { + .ctrl = self.ctrl, + .alt = self.alt, + .super = self.super, ++ .hyper = self.hyper, ++ .meta = self.meta, + }; + } + +@@ -159,9 +163,17 @@ pub const Mods = packed struct(Mods.Backing) { + // For our own understanding + test { + const testing = std.testing; + try testing.expectEqual(@as(Backing, @bitCast(Mods{})), @as(Backing, 0b0)); + try testing.expectEqual( + @as(Backing, @bitCast(Mods{ .shift = true })), + @as(Backing, 0b0000_0001), + ); ++ try testing.expectEqual( ++ @as(Backing, @bitCast(Mods{ .hyper = true })), ++ @as(Backing, 1 << 10), ++ ); ++ try testing.expectEqual( ++ @as(Backing, @bitCast(Mods{ .meta = true })), ++ @as(Backing, 1 << 11), ++ ); + } +diff --git a/vendor/libghostty-vt/src/lib_vt.zig b/vendor/libghostty-vt/src/lib_vt.zig +index e01cdbb8..096f1a87 100644 +--- a/vendor/libghostty-vt/src/lib_vt.zig ++++ b/vendor/libghostty-vt/src/lib_vt.zig +@@ -162,6 +162,10 @@ comptime { + @export(&c.key_event_get_utf8, .{ .name = "ghostty_key_event_get_utf8" }); + @export(&c.key_event_set_unshifted_codepoint, .{ .name = "ghostty_key_event_set_unshifted_codepoint" }); + @export(&c.key_event_get_unshifted_codepoint, .{ .name = "ghostty_key_event_get_unshifted_codepoint" }); ++ @export(&c.key_event_set_shifted_codepoint, .{ .name = "ghostty_key_event_set_shifted_codepoint" }); ++ @export(&c.key_event_get_shifted_codepoint, .{ .name = "ghostty_key_event_get_shifted_codepoint" }); ++ @export(&c.key_event_set_base_layout_codepoint, .{ .name = "ghostty_key_event_set_base_layout_codepoint" }); ++ @export(&c.key_event_get_base_layout_codepoint, .{ .name = "ghostty_key_event_get_base_layout_codepoint" }); + @export(&c.key_encoder_new, .{ .name = "ghostty_key_encoder_new" }); + @export(&c.key_encoder_free, .{ .name = "ghostty_key_encoder_free" }); + @export(&c.key_encoder_setopt, .{ .name = "ghostty_key_encoder_setopt" }); +diff --git a/vendor/libghostty-vt/src/terminal/c/key_event.zig b/vendor/libghostty-vt/src/terminal/c/key_event.zig +index 1feac9ac..f2e71f65 100644 +--- a/vendor/libghostty-vt/src/terminal/c/key_event.zig ++++ b/vendor/libghostty-vt/src/terminal/c/key_event.zig +@@ -121,6 +121,26 @@ pub fn get_unshifted_codepoint(event_: Event) callconv(lib.calling_conv) u32 { + return event.unshifted_codepoint; + } + ++pub fn set_shifted_codepoint(event_: Event, codepoint: u32) callconv(lib.calling_conv) void { ++ const event: *key.KeyEvent = &event_.?.event; ++ event.shifted_codepoint = @truncate(codepoint); ++} ++ ++pub fn get_shifted_codepoint(event_: Event) callconv(lib.calling_conv) u32 { ++ const event: *key.KeyEvent = &event_.?.event; ++ return event.shifted_codepoint; ++} ++ ++pub fn set_base_layout_codepoint(event_: Event, codepoint: u32) callconv(lib.calling_conv) void { ++ const event: *key.KeyEvent = &event_.?.event; ++ event.base_layout_codepoint = @truncate(codepoint); ++} ++ ++pub fn get_base_layout_codepoint(event_: Event) callconv(lib.calling_conv) u32 { ++ const event: *key.KeyEvent = &event_.?.event; ++ return event.base_layout_codepoint; ++} ++ + test "alloc" { + const testing = std.testing; + var e: Event = undefined; +@@ -176,6 +196,10 @@ test "set" { + // Test unshifted codepoint + set_unshifted_codepoint(e, 'a'); + try testing.expectEqual(@as(u21, 'a'), e.?.event.unshifted_codepoint); ++ set_shifted_codepoint(e, 'A'); ++ try testing.expectEqual(@as(u21, 'A'), e.?.event.shifted_codepoint); ++ set_base_layout_codepoint(e, 'q'); ++ try testing.expectEqual(@as(u21, 'q'), e.?.event.base_layout_codepoint); + } + + test "get" { +@@ -203,6 +227,8 @@ test "get" { + set_utf8(e, text.ptr, text.len); + + set_unshifted_codepoint(e, 'z'); ++ set_shifted_codepoint(e, 'Z'); ++ set_base_layout_codepoint(e, 'y'); + + // Get them back + try testing.expectEqual(key.Action.repeat, get_action(e)); +@@ -225,6 +251,8 @@ test "get" { + try testing.expectEqualStrings("test", got_utf8.?[0..utf8_len]); + + try testing.expectEqual(@as(u32, 'z'), get_unshifted_codepoint(e)); ++ try testing.expectEqual(@as(u32, 'Z'), get_shifted_codepoint(e)); ++ try testing.expectEqual(@as(u32, 'y'), get_base_layout_codepoint(e)); + } + + test "complete key event" { +diff --git a/vendor/libghostty-vt/src/terminal/c/main.zig b/vendor/libghostty-vt/src/terminal/c/main.zig +index 37dc5768..e45a499b 100644 +--- a/vendor/libghostty-vt/src/terminal/c/main.zig ++++ b/vendor/libghostty-vt/src/terminal/c/main.zig +@@ -130,6 +130,10 @@ pub const key_event_set_utf8 = key_event.set_utf8; + pub const key_event_get_utf8 = key_event.get_utf8; + pub const key_event_set_unshifted_codepoint = key_event.set_unshifted_codepoint; + pub const key_event_get_unshifted_codepoint = key_event.get_unshifted_codepoint; ++pub const key_event_set_shifted_codepoint = key_event.set_shifted_codepoint; ++pub const key_event_get_shifted_codepoint = key_event.get_shifted_codepoint; ++pub const key_event_set_base_layout_codepoint = key_event.set_base_layout_codepoint; ++pub const key_event_get_base_layout_codepoint = key_event.get_base_layout_codepoint; + + pub const key_encoder_new = key_encode.new; + pub const key_encoder_free = key_encode.free; diff --git a/vendor/patches/libghostty-vt/0003-report-kitty-repeat-events.patch b/vendor/patches/libghostty-vt/0003-report-kitty-repeat-events.patch new file mode 100644 index 0000000000..aaa9c34a77 --- /dev/null +++ b/vendor/patches/libghostty-vt/0003-report-kitty-repeat-events.patch @@ -0,0 +1,48 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: herdr maintainers +Date: Sun, 9 Aug 2026 00:00:00 +0000 +Subject: [PATCH] report kitty repeat events + +Do not collapse repeat events into plain text when Kitty event-type reporting +is active. Applications need the CSI-u event suffix to distinguish a repeat +from a press. + +Herdr issue: https://github.com/herdrdev/herdr/issues/2514 +Vendored base: c5a21edfcbc2d5b46540ad91b7980aca31f5f1f3 +--- +diff --git a/vendor/libghostty-vt/src/input/key_encode.zig b/vendor/libghostty-vt/src/input/key_encode.zig +index 08f56b04..d04e4f37 100644 +--- a/vendor/libghostty-vt/src/input/key_encode.zig ++++ b/vendor/libghostty-vt/src/input/key_encode.zig +@@ -198,7 +198,8 @@ fn kitty( + // We don't send release events because those are specially encoded. + if (event.utf8.len > 0 and + binding_mods.empty() and +- event.action != .release) ++ (event.action == .press or ++ (event.action == .repeat and !opts.kitty_flags.report_events))) + plain_text: { + // We only do this for printable characters. We should + // inspect the real unicode codepoint properties here but +@@ -1263,6 +1264,21 @@ test "kitty: repeat with just disambiguate" { + }); + try testing.expectEqualStrings("a", writer.buffered()); + } ++ ++test "kitty: repeat with event reporting" { ++ var buf: [128]u8 = undefined; ++ var writer: std.Io.Writer = .fixed(&buf); ++ try kitty(&writer, .{ ++ .key = .key_a, ++ .action = .repeat, ++ .mods = .{}, ++ .utf8 = "a", ++ .unshifted_codepoint = 'a', ++ }, .{ ++ .kitty_flags = .{ .disambiguate = true, .report_events = true }, ++ }); ++ try testing.expectEqualStrings("\x1b[97;1:2u", writer.buffered()); ++} + // + test "kitty: enter, backspace, tab" { + var buf: [128]u8 = undefined; diff --git a/vendor/patches/libghostty-vt/0004-encode-extended-function-keys.patch b/vendor/patches/libghostty-vt/0004-encode-extended-function-keys.patch new file mode 100644 index 0000000000..3bece489d9 --- /dev/null +++ b/vendor/patches/libghostty-vt/0004-encode-extended-function-keys.patch @@ -0,0 +1,119 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: herdr maintainers +Date: Sun, 9 Aug 2026 00:00:00 +0000 +Subject: [PATCH] encode extended function keys + +Encode F13-F25 in legacy panes using the standard xterm/terminfo sequences, +including composition with additional modifiers. + +Herdr issue: https://github.com/herdrdev/herdr/issues/2514 +Vendored base: c5a21edfcbc2d5b46540ad91b7980aca31f5f1f3 +--- +diff --git a/vendor/libghostty-vt/src/input/function_keys.zig b/vendor/libghostty-vt/src/input/function_keys.zig +index 66ab4bc4..b1a0f0a5 100644 +--- a/vendor/libghostty-vt/src/input/function_keys.zig ++++ b/vendor/libghostty-vt/src/input/function_keys.zig +@@ -89,10 +89,10 @@ pub const keys = keys: { + result.set(.page_up, pcStyle("\x1b[5;{}~") ++ .{Entry{ .sequence = "\x1B[5~" }}); + result.set(.page_down, pcStyle("\x1b[6;{}~") ++ .{Entry{ .sequence = "\x1B[6~" }}); + +- // Function Keys. todo: f13-f35 but we need to add to input.Key ++ // Function Keys. + result.set(.f1, pcStyle("\x1b[1;{}P") ++ .{Entry{ .sequence = "\x1BOP" }}); + result.set(.f2, pcStyle("\x1b[1;{}Q") ++ .{Entry{ .sequence = "\x1BOQ" }}); +- result.set(.f3, pcStyle("\x1b[13;{}~") ++ .{Entry{ .sequence = "\x1BOR" }}); ++ result.set(.f3, pcStyle("\x1b[1;{}R") ++ .{Entry{ .sequence = "\x1BOR" }}); + result.set(.f4, pcStyle("\x1b[1;{}S") ++ .{Entry{ .sequence = "\x1BOS" }}); + result.set(.f5, pcStyle("\x1b[15;{}~") ++ .{Entry{ .sequence = "\x1B[15~" }}); + result.set(.f6, pcStyle("\x1b[17;{}~") ++ .{Entry{ .sequence = "\x1B[17~" }}); +@@ -102,6 +102,19 @@ pub const keys = keys: { + result.set(.f10, pcStyle("\x1b[21;{}~") ++ .{Entry{ .sequence = "\x1B[21~" }}); + result.set(.f11, pcStyle("\x1b[23;{}~") ++ .{Entry{ .sequence = "\x1B[23~" }}); + result.set(.f12, pcStyle("\x1b[24;{}~") ++ .{Entry{ .sequence = "\x1B[24~" }}); ++ result.set(.f13, pcStyleWithImplicitMods("\x1b[1;{}P", .{ .shift = true }) ++ .{Entry{ .mods_empty_is_any = false, .sequence = "\x1b[1;2P" }}); ++ result.set(.f14, pcStyleWithImplicitMods("\x1b[1;{}Q", .{ .shift = true }) ++ .{Entry{ .mods_empty_is_any = false, .sequence = "\x1b[1;2Q" }}); ++ result.set(.f15, pcStyleWithImplicitMods("\x1b[1;{}R", .{ .shift = true }) ++ .{Entry{ .mods_empty_is_any = false, .sequence = "\x1b[1;2R" }}); ++ result.set(.f16, pcStyleWithImplicitMods("\x1b[1;{}S", .{ .shift = true }) ++ .{Entry{ .mods_empty_is_any = false, .sequence = "\x1b[1;2S" }}); ++ result.set(.f17, pcStyleWithImplicitMods("\x1b[15;{}~", .{ .shift = true }) ++ .{Entry{ .mods_empty_is_any = false, .sequence = "\x1b[15;2~" }}); ++ result.set(.f18, pcStyleWithImplicitMods("\x1b[17;{}~", .{ .shift = true }) ++ .{Entry{ .mods_empty_is_any = false, .sequence = "\x1b[17;2~" }}); ++ result.set(.f19, pcStyleWithImplicitMods("\x1b[18;{}~", .{ .shift = true }) ++ .{Entry{ .mods_empty_is_any = false, .sequence = "\x1b[18;2~" }}); ++ result.set(.f20, pcStyleWithImplicitMods("\x1b[19;{}~", .{ .shift = true }) ++ .{Entry{ .mods_empty_is_any = false, .sequence = "\x1b[19;2~" }}); ++ result.set(.f21, pcStyleWithImplicitMods("\x1b[20;{}~", .{ .shift = true }) ++ .{Entry{ .mods_empty_is_any = false, .sequence = "\x1b[20;2~" }}); ++ result.set(.f22, pcStyleWithImplicitMods("\x1b[21;{}~", .{ .shift = true }) ++ .{Entry{ .mods_empty_is_any = false, .sequence = "\x1b[21;2~" }}); ++ result.set(.f23, pcStyleWithImplicitMods("\x1b[23;{}~", .{ .shift = true }) ++ .{Entry{ .mods_empty_is_any = false, .sequence = "\x1b[23;2~" }}); ++ result.set(.f24, pcStyleWithImplicitMods("\x1b[24;{}~", .{ .shift = true }) ++ .{Entry{ .mods_empty_is_any = false, .sequence = "\x1b[24;2~" }}); ++ result.set(.f25, pcStyleWithImplicitMods("\x1b[1;{}P", .{ .ctrl = true }) ++ .{Entry{ .mods_empty_is_any = false, .sequence = "\x1b[1;5P" }}); + + // Keypad keys + result.set(.numpad_0, kpKeys("p")); +@@ -294,6 +307,25 @@ fn pcStyle(comptime fmt: []const u8) []Entry { + } + } + ++fn pcStyleWithImplicitMods(comptime fmt: []const u8, comptime implicit: key.Mods) []Entry { ++ comptime { ++ @setEvalBranchQuota(500_000); ++ var entries: [modifiers.len]Entry = undefined; ++ for (modifiers, 0..) |mods, i| { ++ const code: u8 = 1 + ++ @as(u8, @intFromBool(mods.shift or implicit.shift)) + ++ 2 * @as(u8, @intFromBool(mods.alt or implicit.alt)) + ++ 4 * @as(u8, @intFromBool(mods.ctrl or implicit.ctrl)) + ++ 8 * @as(u8, @intFromBool(mods.super or implicit.super)); ++ entries[i] = .{ ++ .mods = mods, ++ .sequence = std.fmt.comptimePrint(fmt, .{code}), ++ }; ++ } ++ return &entries; ++ } ++} ++ + test "keys" { + const testing = std.testing; + switch (@import("terminal_options").artifact) { +diff --git a/vendor/libghostty-vt/src/input/key_encode.zig b/vendor/libghostty-vt/src/input/key_encode.zig +index d04e4f37..a3074601 100644 +--- a/vendor/libghostty-vt/src/input/key_encode.zig ++++ b/vendor/libghostty-vt/src/input/key_encode.zig +@@ -2409,10 +2409,10 @@ test "legacy: f1" { + // F3 + { + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .f3, + .mods = .{ .ctrl = true }, + .consumed_mods = .{}, + }, .{}); +- try testing.expectEqualStrings("\x1b[13;5~", writer.buffered()); ++ try testing.expectEqualStrings("\x1b[1;5R", writer.buffered()); + } +@@ -2427,6 +2427,28 @@ test "legacy: f1" { + } + } + ++test "legacy: extended function keys" { ++ var buf: [128]u8 = undefined; ++ ++ const cases = .{ ++ .{ key.Key.f13, "\x1b[1;2P" }, ++ .{ key.Key.f24, "\x1b[24;2~" }, ++ .{ key.Key.f25, "\x1b[1;5P" }, ++ }; ++ inline for (cases) |case| { ++ var writer: std.Io.Writer = .fixed(&buf); ++ try legacy(&writer, .{ .key = case[0] }, .{}); ++ try testing.expectEqualStrings(case[1], writer.buffered()); ++ } ++ ++ var writer: std.Io.Writer = .fixed(&buf); ++ try legacy(&writer, .{ ++ .key = .f13, ++ .mods = .{ .ctrl = true }, ++ }, .{}); ++ try testing.expectEqualStrings("\x1b[1;6P", writer.buffered()); ++} ++ + test "legacy: left_shift+tab" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); diff --git a/vendor/patches/libghostty-vt/0005-proxy-key-encoding.patch b/vendor/patches/libghostty-vt/0005-proxy-key-encoding.patch new file mode 100644 index 0000000000..e56d605992 --- /dev/null +++ b/vendor/patches/libghostty-vt/0005-proxy-key-encoding.patch @@ -0,0 +1,286 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: herdr maintainers +Date: Sun, 9 Aug 2026 00:00:00 +0000 +Subject: [PATCH] encode terminal proxy key events deterministically + +Add an explicit proxy-event mode that trusts semantic modifiers and generated +text instead of applying input conventions from the encoder's host OS. + +Herdr issue: https://github.com/herdrdev/herdr/issues/2514 +Vendored base: c5a21edfcbc2d5b46540ad91b7980aca31f5f1f3 +--- +diff --git a/vendor/libghostty-vt/include/ghostty/vt/key/encoder.h b/vendor/libghostty-vt/include/ghostty/vt/key/encoder.h +index 3aeec65..76246e1 100644 +--- a/vendor/libghostty-vt/include/ghostty/vt/key/encoder.h ++++ b/vendor/libghostty-vt/include/ghostty/vt/key/encoder.h +@@ -113,6 +113,12 @@ typedef enum GHOSTTY_ENUM_TYPED { + */ + GHOSTTY_KEY_ENCODER_OPT_BACKARROW_KEY_MODE = 7, + ++ /** Input events originated in another terminal and already carry semantic ++ * modifiers and generated text (value: bool). This makes encoding ++ * independent of host OS input conventions. ++ */ ++ GHOSTTY_KEY_ENCODER_OPT_PROXY_EVENTS = 8, ++ + GHOSTTY_KEY_ENCODER_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, + } GhosttyKeyEncoderOption; + +diff --git a/vendor/libghostty-vt/src/input/key_encode.zig b/vendor/libghostty-vt/src/input/key_encode.zig +index 60c1ec4..090bf2f 100644 +--- a/vendor/libghostty-vt/src/input/key_encode.zig ++++ b/vendor/libghostty-vt/src/input/key_encode.zig +@@ -42,6 +42,11 @@ pub const Options = struct { + /// docs for a more detailed description of why this is needed. + macos_option_as_alt: OptionAsAlt = .false, + ++ /// Input events originated in another terminal and already carry ++ /// semantic modifiers and generated text. This disables host OS input ++ /// reinterpretation so encoding is independent of the proxy's host. ++ proxy_events: bool = false, ++ + pub const default: Options = .{ + .cursor_key_application = false, + .keypad_key_application = false, +@@ -50,6 +55,7 @@ pub const Options = struct { + .modify_other_keys_state_2 = false, + .kitty_flags = .disabled, + .macos_option_as_alt = .false, ++ .proxy_events = false, + }; + + /// Initialize our options from the terminal state. +@@ -68,6 +74,7 @@ pub const Options = struct { + + // These can't be known from the terminal state. + .macos_option_as_alt = .false, ++ .proxy_events = false, + }; + } + }; +@@ -310,7 +317,7 @@ fn kitty( + // Determine if the Alt modifier should be treated as an actual + // modifier (in which case it prevents associated text) or as + // the macOS Option key, which does not prevent associated text. +- const alt_prevents_text = if (comptime builtin.os.tag == .macos) ++ const alt_prevents_text = if (builtin.os.tag == .macos and !opts.proxy_events) + switch (opts.macos_option_as_alt) { + .left => all_mods.sides.alt == .left, + .right => all_mods.sides.alt == .right, +@@ -416,7 +423,11 @@ fn legacy( + // alt-prefix handling of unshifted codepoints... so we process that. + const utf8 = event.utf8; + if (utf8.len == 0) { +- if (try legacyAltPrefix( ++ if (opts.proxy_events and proxyAltPrefixEnabled(binding_mods, opts)) { ++ if (std.math.cast(u8, event.unshifted_codepoint)) |byte| { ++ try writer.print("\x1B{c}", .{byte}); ++ } ++ } else if (try legacyAltPrefix( + event, + binding_mods, + all_mods, +@@ -444,7 +455,7 @@ fn legacy( + // super, alt unless it is actually option). + const mods = mods: { + var mods_binding = event.mods.binding(); +- if (comptime builtin.target.os.tag.isDarwin()) alt: { ++ if (builtin.target.os.tag.isDarwin() and !opts.proxy_events) alt: { + switch (opts.macos_option_as_alt) { + .false => {}, + .true => break :alt, +@@ -537,7 +548,12 @@ fn legacy( + + // If we have alt-pressed and alt-esc-prefix is enabled, then + // we need to prefix the utf8 sequence with an esc. +- if (try legacyAltPrefix( ++ if (opts.proxy_events) { ++ if (proxyAltPrefixEnabled(binding_mods, opts)) { ++ try writer.writeByte(0x1B); ++ return try writer.writeAll(utf8); ++ } ++ } else if (try legacyAltPrefix( + event, + binding_mods, + all_mods, +@@ -555,13 +571,17 @@ fn legacy( + // For Linux, we continue to encode text because it is typical. + // For example on Gnome Console Super+b will encode a "b" character + // with legacy encoding. +- if ((comptime builtin.os.tag == .macos) and all_mods.super) { ++ if (builtin.os.tag == .macos and !opts.proxy_events and all_mods.super) { + return; + } + + return try writer.writeAll(utf8); + } + ++fn proxyAltPrefixEnabled(binding_mods: key.Mods, opts: Options) bool { ++ return binding_mods.alt and opts.alt_esc_prefix; ++} ++ + fn legacyAltPrefix( + event: key.KeyEvent, + binding_mods: key.Mods, +@@ -1732,6 +1752,26 @@ test "kitty: left shift with report all" { + try testing.expectEqualStrings("\x1b[57441u", writer.buffered()); + } + ++test "kitty: proxy alt is a modifier for associated text" { ++ var buf: [128]u8 = undefined; ++ var writer: std.Io.Writer = .fixed(&buf); ++ try kitty(&writer, .{ ++ .key = .key_w, ++ .mods = .{ .alt = true }, ++ .utf8 = "∑", ++ .unshifted_codepoint = 119, ++ }, .{ ++ .kitty_flags = .{ ++ .disambiguate = true, ++ .report_all = true, ++ .report_alternates = true, ++ .report_associated = true, ++ }, ++ .proxy_events = true, ++ }); ++ try testing.expectEqualStrings("\x1b[119;3u", writer.buffered()); ++} ++ + test "kitty: report associated with alt text on macOS with option" { + if (comptime !builtin.target.os.tag.isDarwin()) return error.SkipZigTest; + +@@ -2025,6 +2065,93 @@ test "legacy: ctrl+alt+c" { + try testing.expectEqualStrings("\x1b\x03", writer.buffered()); + } + ++test "legacy: proxy alt without generated text ignores host option policy" { ++ var buf: [128]u8 = undefined; ++ var writer: std.Io.Writer = .fixed(&buf); ++ try legacy(&writer, .{ ++ .key = .key_c, ++ .mods = .{ .alt = true }, ++ .unshifted_codepoint = 'c', ++ }, .{ ++ .alt_esc_prefix = true, ++ .proxy_events = true, ++ }); ++ try testing.expectEqualStrings("\x1bc", writer.buffered()); ++} ++ ++test "legacy: proxy super preserves semantic text" { ++ var buf: [128]u8 = undefined; ++ var writer: std.Io.Writer = .fixed(&buf); ++ try legacy(&writer, .{ ++ .key = .key_b, ++ .mods = .{ .super = true }, ++ .utf8 = "b", ++ .unshifted_codepoint = 'b', ++ }, .{ .proxy_events = true }); ++ try testing.expectEqualStrings("b", writer.buffered()); ++} ++ ++test "legacy: proxy alt with modify other keys preserves the modifier" { ++ var buf: [128]u8 = undefined; ++ var writer: std.Io.Writer = .fixed(&buf); ++ try legacy(&writer, .{ ++ .key = .key_e, ++ .mods = .{ .alt = true }, ++ .utf8 = "é", ++ .unshifted_codepoint = 'e', ++ }, .{ ++ .modify_other_keys_state_2 = true, ++ .proxy_events = true, ++ }); ++ try testing.expectEqualStrings("\x1b[27;3;233~", writer.buffered()); ++} ++ ++test "legacy: alt+unicode prefixes the complete utf8 text" { ++ var buf: [128]u8 = undefined; ++ var writer: std.Io.Writer = .fixed(&buf); ++ try legacy(&writer, .{ ++ .key = .unidentified, ++ .mods = .{ .alt = true }, ++ .utf8 = "é", ++ .unshifted_codepoint = 'é', ++ }, .{ ++ .alt_esc_prefix = true, ++ .proxy_events = true, ++ }); ++ try testing.expectEqualStrings("\x1bé", writer.buffered()); ++} ++ ++test "legacy: alt with invalid unshifted codepoint preserves utf8 text" { ++ var buf: [128]u8 = undefined; ++ var writer: std.Io.Writer = .fixed(&buf); ++ try legacy(&writer, .{ ++ .key = .unidentified, ++ .mods = .{ .alt = true }, ++ .utf8 = "é", ++ .unshifted_codepoint = 0xD800, ++ }, .{ ++ .alt_esc_prefix = true, ++ .proxy_events = true, ++ }); ++ try testing.expectEqualStrings("\x1bé", writer.buffered()); ++} ++ ++test "legacy: alt+shift preserves shifted text" { ++ var buf: [128]u8 = undefined; ++ var writer: std.Io.Writer = .fixed(&buf); ++ try legacy(&writer, .{ ++ .key = .period, ++ .mods = .{ .alt = true, .shift = true }, ++ .consumed_mods = .{ .shift = true }, ++ .utf8 = ">", ++ .unshifted_codepoint = '.', ++ }, .{ ++ .alt_esc_prefix = true, ++ .proxy_events = true, ++ }); ++ try testing.expectEqualStrings("\x1b>", writer.buffered()); ++} ++ + test "legacy: alt+c" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); +diff --git a/vendor/libghostty-vt/src/terminal/c/key_encode.zig b/vendor/libghostty-vt/src/terminal/c/key_encode.zig +index f5d459f..4398c6e 100644 +--- a/vendor/libghostty-vt/src/terminal/c/key_encode.zig ++++ b/vendor/libghostty-vt/src/terminal/c/key_encode.zig +@@ -57,6 +57,8 @@ pub const Option = enum(c_int) { + /// If `false` (the default), `backspace` emits 0x7f + /// If `true`, `backspace` emits 0x08 + backarrow_key_mode = 7, ++ /// Events originated in another terminal and already have semantic input. ++ proxy_events = 8, + + /// Input type expected for setting the option. + pub fn InType(comptime self: Option) type { +@@ -67,6 +69,7 @@ pub const Option = enum(c_int) { + .alt_esc_prefix, + .modify_other_keys_state_2, + .backarrow_key_mode, ++ .proxy_events, + => bool, + .kitty_flags => u8, + .macos_option_as_alt => OptionAsAlt, +@@ -121,6 +124,7 @@ fn setoptTyped( + opts.macos_option_as_alt = value.*; + }, + .backarrow_key_mode => opts.backarrow_key_mode = value.*, ++ .proxy_events => opts.proxy_events = value.*, + } + } + +@@ -198,6 +202,9 @@ test "setopt bool" { + + setopt(e, .keypad_key_application, &val_true); + try testing.expect(e.?.opts.keypad_key_application); ++ ++ setopt(e, .proxy_events, &val_true); ++ try testing.expect(e.?.opts.proxy_events); + } + + test "setopt kitty flags" { +-- +2.50.1 diff --git a/vendor/patches/libghostty-vt/0006-extended-function-keys-f35.patch b/vendor/patches/libghostty-vt/0006-extended-function-keys-f35.patch new file mode 100644 index 0000000000..73ac3a6467 --- /dev/null +++ b/vendor/patches/libghostty-vt/0006-extended-function-keys-f35.patch @@ -0,0 +1,140 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: herdr maintainers +Date: Sun, 9 Aug 2026 00:00:00 +0000 +Subject: [PATCH] support Kitty function keys through F35 + +Extend the key model, Kitty map, and legacy xterm encoding through F35 while +appending C ABI values so existing key constants remain stable. + +Herdr issue: https://github.com/herdrdev/herdr/issues/2514 +Vendored base: c5a21edfcbc2d5b46540ad91b7980aca31f5f1f3 +--- +diff --git a/vendor/libghostty-vt/include/ghostty/vt/key/event.h b/vendor/libghostty-vt/include/ghostty/vt/key/event.h +index 5777209..be647e0 100644 +--- a/vendor/libghostty-vt/include/ghostty/vt/key/event.h ++++ b/vendor/libghostty-vt/include/ghostty/vt/key/event.h +@@ -301,6 +301,18 @@ typedef enum GHOSTTY_ENUM_TYPED { + GHOSTTY_KEY_COPY, + GHOSTTY_KEY_CUT, + GHOSTTY_KEY_PASTE, ++ ++ // Kitty protocol extended function keys, appended for ABI stability. ++ GHOSTTY_KEY_F26, ++ GHOSTTY_KEY_F27, ++ GHOSTTY_KEY_F28, ++ GHOSTTY_KEY_F29, ++ GHOSTTY_KEY_F30, ++ GHOSTTY_KEY_F31, ++ GHOSTTY_KEY_F32, ++ GHOSTTY_KEY_F33, ++ GHOSTTY_KEY_F34, ++ GHOSTTY_KEY_F35, + GHOSTTY_KEY_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, + } GhosttyKey; + +diff --git a/vendor/libghostty-vt/src/input/function_keys.zig b/vendor/libghostty-vt/src/input/function_keys.zig +index bdcaaa9..0d89dcf 100644 +--- a/vendor/libghostty-vt/src/input/function_keys.zig ++++ b/vendor/libghostty-vt/src/input/function_keys.zig +@@ -115,6 +115,16 @@ pub const keys = keys: { + result.set(.f23, pcStyleWithImplicitMods("\x1b[23;{}~", .{ .shift = true }) ++ .{Entry{ .mods_empty_is_any = false, .sequence = "\x1b[23;2~" }}); + result.set(.f24, pcStyleWithImplicitMods("\x1b[24;{}~", .{ .shift = true }) ++ .{Entry{ .mods_empty_is_any = false, .sequence = "\x1b[24;2~" }}); + result.set(.f25, pcStyleWithImplicitMods("\x1b[1;{}P", .{ .ctrl = true }) ++ .{Entry{ .mods_empty_is_any = false, .sequence = "\x1b[1;5P" }}); ++ result.set(.f26, pcStyleWithImplicitMods("\x1b[1;{}Q", .{ .ctrl = true }) ++ .{Entry{ .mods_empty_is_any = false, .sequence = "\x1b[1;5Q" }}); ++ result.set(.f27, pcStyleWithImplicitMods("\x1b[1;{}R", .{ .ctrl = true }) ++ .{Entry{ .mods_empty_is_any = false, .sequence = "\x1b[1;5R" }}); ++ result.set(.f28, pcStyleWithImplicitMods("\x1b[1;{}S", .{ .ctrl = true }) ++ .{Entry{ .mods_empty_is_any = false, .sequence = "\x1b[1;5S" }}); ++ result.set(.f29, pcStyleWithImplicitMods("\x1b[15;{}~", .{ .ctrl = true }) ++ .{Entry{ .mods_empty_is_any = false, .sequence = "\x1b[15;5~" }}); ++ result.set(.f30, pcStyleWithImplicitMods("\x1b[17;{}~", .{ .ctrl = true }) ++ .{Entry{ .mods_empty_is_any = false, .sequence = "\x1b[17;5~" }}); ++ result.set(.f31, pcStyleWithImplicitMods("\x1b[18;{}~", .{ .ctrl = true }) ++ .{Entry{ .mods_empty_is_any = false, .sequence = "\x1b[18;5~" }}); ++ result.set(.f32, pcStyleWithImplicitMods("\x1b[19;{}~", .{ .ctrl = true }) ++ .{Entry{ .mods_empty_is_any = false, .sequence = "\x1b[19;5~" }}); ++ result.set(.f33, pcStyleWithImplicitMods("\x1b[20;{}~", .{ .ctrl = true }) ++ .{Entry{ .mods_empty_is_any = false, .sequence = "\x1b[20;5~" }}); ++ result.set(.f34, pcStyleWithImplicitMods("\x1b[21;{}~", .{ .ctrl = true }) ++ .{Entry{ .mods_empty_is_any = false, .sequence = "\x1b[21;5~" }}); ++ result.set(.f35, pcStyleWithImplicitMods("\x1b[23;{}~", .{ .ctrl = true }) ++ .{Entry{ .mods_empty_is_any = false, .sequence = "\x1b[23;5~" }}); + + // Keypad keys + result.set(.numpad_0, kpKeys("p")); +diff --git a/vendor/libghostty-vt/src/input/key.zig b/vendor/libghostty-vt/src/input/key.zig +index a213980..12d6352 100644 +--- a/vendor/libghostty-vt/src/input/key.zig ++++ b/vendor/libghostty-vt/src/input/key.zig +@@ -311,6 +311,18 @@ pub const Key = enum(c_int) { + cut, + paste, + ++ // Kitty protocol extended function keys, appended for ABI stability. ++ f26, ++ f27, ++ f28, ++ f29, ++ f30, ++ f31, ++ f32, ++ f33, ++ f34, ++ f35, ++ + /// Converts an ASCII character to a key, if possible. This returns + /// null if the character is unknown. + /// +@@ -536,7 +548,7 @@ pub const Key = enum(c_int) { + return switch (self) { + inline else => |tag| { + return comptime result: { +- @setEvalBranchQuota(10_000); ++ @setEvalBranchQuota(20_000); + for (codepoint_map) |entry| { + if (entry[1] == tag) break :result entry[0]; + } +@@ -689,6 +701,16 @@ pub const Key = enum(c_int) { + .f23, + .f24, + .f25, ++ .f26, ++ .f27, ++ .f28, ++ .f29, ++ .f30, ++ .f31, ++ .f32, ++ .f33, ++ .f34, ++ .f35, + .intl_backslash, + .intl_ro, + .intl_yen, +diff --git a/vendor/libghostty-vt/src/input/key_encode.zig b/vendor/libghostty-vt/src/input/key_encode.zig +index 283ccca..fbd97f1 100644 +--- a/vendor/libghostty-vt/src/input/key_encode.zig ++++ b/vendor/libghostty-vt/src/input/key_encode.zig +@@ -2543,6 +2543,8 @@ test "legacy: extended function keys" { + .{ key.Key.f13, "\x1b[1;2P" }, + .{ key.Key.f24, "\x1b[24;2~" }, + .{ key.Key.f25, "\x1b[1;5P" }, ++ .{ key.Key.f26, "\x1b[1;5Q" }, ++ .{ key.Key.f35, "\x1b[23;5~" }, + }; + inline for (cases) |case| { + var writer: std.Io.Writer = .fixed(&buf); +diff --git a/vendor/libghostty-vt/src/input/kitty.zig b/vendor/libghostty-vt/src/input/kitty.zig +index e5789cc..16809c2 100644 +--- a/vendor/libghostty-vt/src/input/kitty.zig ++++ b/vendor/libghostty-vt/src/input/kitty.zig +@@ -87,6 +87,16 @@ const raw_entries: []const RawEntry = &.{ + .{ .f23, 57386, 'u', false }, + .{ .f24, 57387, 'u', false }, + .{ .f25, 57388, 'u', false }, ++ .{ .f26, 57389, 'u', false }, ++ .{ .f27, 57390, 'u', false }, ++ .{ .f28, 57391, 'u', false }, ++ .{ .f29, 57392, 'u', false }, ++ .{ .f30, 57393, 'u', false }, ++ .{ .f31, 57394, 'u', false }, ++ .{ .f32, 57395, 'u', false }, ++ .{ .f33, 57396, 'u', false }, ++ .{ .f34, 57397, 'u', false }, ++ .{ .f35, 57398, 'u', false }, + + .{ .numpad_0, 57399, 'u', false }, + .{ .numpad_1, 57400, 'u', false }, +-- +2.50.1 diff --git a/vendor/patches/libghostty-vt/series b/vendor/patches/libghostty-vt/series new file mode 100644 index 0000000000..f6d6e33cff --- /dev/null +++ b/vendor/patches/libghostty-vt/series @@ -0,0 +1,6 @@ +0001-default-grapheme-cluster-mode.patch +0002-proxied-kitty-key-metadata.patch +0003-report-kitty-repeat-events.patch +0004-encode-extended-function-keys.patch +0005-proxy-key-encoding.patch +0006-extended-function-keys-f35.patch