Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
3e790a8
test(input): add keyboard pipeline compatibility corpus
ogulcancelik Aug 9, 2026
5fe634b
fix(ghostty): retain key event text through encoding
ogulcancelik Aug 9, 2026
ca56ad2
test(input): characterize pane encoder parity
ogulcancelik Aug 9, 2026
b30b3c2
feat(input): preserve kitty key metadata
ogulcancelik Aug 9, 2026
e10a261
refactor(input): type raw decode outcomes
ogulcancelik Aug 9, 2026
18a8fb0
fix(input): preserve modifyotherkeys mode in handoff
ogulcancelik Aug 9, 2026
926d641
fix(input): close ghostty text adapter gaps
ogulcancelik Aug 9, 2026
491935a
feat(input): preserve proxied kitty metadata
ogulcancelik Aug 9, 2026
5ebce8a
refactor(input): type pane key encoding outcomes
ogulcancelik Aug 9, 2026
47ad1c6
fix(input): close pane encoder adapter gaps
ogulcancelik Aug 9, 2026
b9fd158
refactor(input): make ghostty the pane key encoder
ogulcancelik Aug 9, 2026
1a1289e
refactor(input): remove the duplicate key encoder
ogulcancelik Aug 9, 2026
aefb265
docs: note robust pane key forwarding
ogulcancelik Aug 9, 2026
44c0a49
fix(input): close final proxy key compatibility gaps
ogulcancelik Aug 9, 2026
faefa14
refactor(input): simplify pane encoder surface
ogulcancelik Aug 9, 2026
6a15ddd
fix(input): address cross-platform encoding regressions
ogulcancelik Aug 9, 2026
fe1443a
refactor(input): harden terminal proxy key encoding
ogulcancelik Aug 9, 2026
0f363a1
test(input): account for windows conpty key fallback
ogulcancelik Aug 9, 2026
0690d0a
fix(input): encode no-text proxy alt events consistently
ogulcancelik Aug 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/next/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
195 changes: 180 additions & 15 deletions scripts/test_vendor_libghostty_vt.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import io
import shutil
import subprocess
import tarfile
import tempfile
Expand All @@ -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"
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -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"
Expand Down
100 changes: 95 additions & 5 deletions scripts/vendor_libghostty_vt.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import json
import shutil
import subprocess
import sys
import tarfile
import tempfile
from dataclasses import asdict, dataclass
Expand All @@ -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 = {
Expand Down Expand Up @@ -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)

Expand All @@ -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),
Expand All @@ -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")

Expand Down
6 changes: 3 additions & 3 deletions scripts/windows_conpty_enhanced_input_probe.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading
Loading