From f08023c7144340554f31116fb1ff349b77e80f58 Mon Sep 17 00:00:00 2001 From: Danqi Ye Date: Thu, 27 Aug 2026 16:44:38 +0800 Subject: [PATCH 1/2] fix: disable Kitty keyboard mode in iTerm2 --- apodex/cli.py | 6 ++++ apodex/docker.py | 12 +++++++- apodex/terminal.py | 24 ++++++++++++++-- apodex/tests/test_terminal.py | 53 +++++++++++++++++++++++++++++++++++ 4 files changed, 92 insertions(+), 3 deletions(-) diff --git a/apodex/cli.py b/apodex/cli.py index 17416bc..624a729 100644 --- a/apodex/cli.py +++ b/apodex/cli.py @@ -303,6 +303,12 @@ async def _amain(argv: list[str] | None = None) -> int: # owned by TerminalSession._authorize_workspace.) _load_env() + # Textual's Kitty keyboard negotiation drops IME commits in iTerm2. Set + # the compatibility fallback before either starting the native TUI or + # constructing the Docker environment for the default macOS path. + from apodex.terminal import configure_terminal_keyboard + configure_terminal_keyboard(os.environ) + if args.cwd: try: os.chdir(args.cwd) diff --git a/apodex/docker.py b/apodex/docker.py index f695769..6f4ae06 100644 --- a/apodex/docker.py +++ b/apodex/docker.py @@ -33,7 +33,7 @@ def terminal_env(environ: Mapping[str, str]) -> list[str]: - """Return ``-e`` args carrying the host terminal's colour capability inward. + """Return ``-e`` args carrying the host terminal contract inward. Docker sets ``TERM=xterm`` inside a ``-it`` container and forwards no ``COLORTERM`` at all. Rich and Textual read exactly those two variables, and @@ -58,6 +58,16 @@ def terminal_env(environ: Mapping[str, str]) -> list[str]: args += ["-e", f"COLORTERM={color_term}"] elif not term.endswith(("-256color", "-truecolor", "-direct")): args += ["-e", "COLORTERM=truecolor"] + # The default macOS launch re-executes inside Docker. Keep terminal + # identity available for compatibility decisions there, and preserve the + # Textual keyboard override chosen by the host CLI or supplied by the user. + for name in ( + "TERM_PROGRAM", + "TERM_PROGRAM_VERSION", + "TEXTUAL_DISABLE_KITTY_KEY", + ): + if name in environ: + args += ["-e", f"{name}={environ[name]}"] return args diff --git a/apodex/terminal.py b/apodex/terminal.py index bf97d9e..77298f8 100644 --- a/apodex/terminal.py +++ b/apodex/terminal.py @@ -7,7 +7,7 @@ from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Mapping, MutableMapping from dataclasses import dataclass @@ -23,6 +23,21 @@ class TerminalUI: color_warning: str = "" +def configure_terminal_keyboard(environ: MutableMapping[str, str]) -> None: + """Select a safe Textual keyboard protocol for the host terminal. + + iTerm2 currently drops input-method commits after Textual enables Kitty + keyboard reporting. Prefer working text input there until the terminal can + reliably report associated text. An explicitly supplied value wins; an + empty value therefore remains an escape hatch for testing future versions. + """ + if ( + environ.get("TERM_PROGRAM", "").strip() == "iTerm.app" + and "TEXTUAL_DISABLE_KITTY_KEY" not in environ + ): + environ["TEXTUAL_DISABLE_KITTY_KEY"] = "1" + + def detect_color_depth(environ: Mapping[str, str]) -> int: """Number of colours Rich and Textual will actually use. @@ -105,4 +120,9 @@ def resolve_terminal_ui( ) -__all__ = ["TerminalUI", "detect_color_depth", "resolve_terminal_ui"] +__all__ = [ + "TerminalUI", + "configure_terminal_keyboard", + "detect_color_depth", + "resolve_terminal_ui", +] diff --git a/apodex/tests/test_terminal.py b/apodex/tests/test_terminal.py index c3268bf..cfd4c4b 100644 --- a/apodex/tests/test_terminal.py +++ b/apodex/tests/test_terminal.py @@ -31,6 +31,37 @@ def test_cli_accepts_repeatable_inputs() -> None: assert args.input == ["a.pdf", "b.png"] +def test_iterm_disables_textual_kitty_keyboard_by_default() -> None: + from apodex.terminal import configure_terminal_keyboard + + environ = {"TERM_PROGRAM": "iTerm.app"} + configure_terminal_keyboard(environ) + + assert environ["TEXTUAL_DISABLE_KITTY_KEY"] == "1" + + +def test_keyboard_fallback_leaves_other_terminals_unchanged() -> None: + from apodex.terminal import configure_terminal_keyboard + + environ = {"TERM_PROGRAM": "Apple_Terminal"} + configure_terminal_keyboard(environ) + + assert "TEXTUAL_DISABLE_KITTY_KEY" not in environ + + +@pytest.mark.parametrize("explicit", ["", "1", "custom"]) +def test_keyboard_fallback_preserves_an_explicit_user_value(explicit) -> None: + from apodex.terminal import configure_terminal_keyboard + + environ = { + "TERM_PROGRAM": "iTerm.app", + "TEXTUAL_DISABLE_KITTY_KEY": explicit, + } + configure_terminal_keyboard(environ) + + assert environ["TEXTUAL_DISABLE_KITTY_KEY"] == explicit + + @pytest.mark.parametrize( ("stdin_tty", "stdout_tty", "one_shot", "no_tui", "theme", "env", "use_tui"), [ @@ -188,3 +219,25 @@ def test_container_still_honours_an_explicitly_dumb_terminal() -> None: assert terminal_env({"TERM": "dumb"}) == ["-e", "TERM=dumb"] assert "COLORTERM=truecolor" not in terminal_env({"TERM": "dumb"}) + + +def test_container_receives_terminal_identity_and_keyboard_override() -> None: + from apodex.docker import terminal_env + from apodex.terminal import configure_terminal_keyboard + + host = { + "TERM": "xterm-256color", + "TERM_PROGRAM": "iTerm.app", + "TERM_PROGRAM_VERSION": "3.6.11", + } + configure_terminal_keyboard(host) + args = terminal_env(host) + passed = dict( + pair.split("=", 1) + for flag, pair in zip(args[::2], args[1::2], strict=False) + if flag == "-e" + ) + + assert passed["TERM_PROGRAM"] == "iTerm.app" + assert passed["TERM_PROGRAM_VERSION"] == "3.6.11" + assert passed["TEXTUAL_DISABLE_KITTY_KEY"] == "1" From 271fd22bf191a85f85b8f7554e3680fa70509666 Mon Sep 17 00:00:00 2001 From: Danqi Ye Date: Thu, 27 Aug 2026 16:52:34 +0800 Subject: [PATCH 2/2] test: cover terminal settings in Docker --- apodex/docker.py | 6 +++--- apodex/tests/test_terminal.py | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/apodex/docker.py b/apodex/docker.py index 6f4ae06..9de2c3a 100644 --- a/apodex/docker.py +++ b/apodex/docker.py @@ -58,9 +58,9 @@ def terminal_env(environ: Mapping[str, str]) -> list[str]: args += ["-e", f"COLORTERM={color_term}"] elif not term.endswith(("-256color", "-truecolor", "-direct")): args += ["-e", "COLORTERM=truecolor"] - # The default macOS launch re-executes inside Docker. Keep terminal - # identity available for compatibility decisions there, and preserve the - # Textual keyboard override chosen by the host CLI or supplied by the user. + # Keep host terminal identity available for compatibility decisions in + # Docker on every OS, and preserve a Textual keyboard override chosen by + # the host CLI or supplied explicitly by the user. for name in ( "TERM_PROGRAM", "TERM_PROGRAM_VERSION", diff --git a/apodex/tests/test_terminal.py b/apodex/tests/test_terminal.py index cfd4c4b..187daca 100644 --- a/apodex/tests/test_terminal.py +++ b/apodex/tests/test_terminal.py @@ -241,3 +241,23 @@ def test_container_receives_terminal_identity_and_keyboard_override() -> None: assert passed["TERM_PROGRAM"] == "iTerm.app" assert passed["TERM_PROGRAM_VERSION"] == "3.6.11" assert passed["TEXTUAL_DISABLE_KITTY_KEY"] == "1" + + +def test_explicit_keyboard_override_reaches_docker_on_other_hosts() -> None: + from apodex.docker import terminal_env + + args = terminal_env({ + "TERM": "xterm-256color", + "TERM_PROGRAM": "WezTerm", + "TERM_PROGRAM_VERSION": "20240203", + "TEXTUAL_DISABLE_KITTY_KEY": "1", + }) + passed = dict( + pair.split("=", 1) + for flag, pair in zip(args[::2], args[1::2], strict=False) + if flag == "-e" + ) + + assert passed["TERM_PROGRAM"] == "WezTerm" + assert passed["TERM_PROGRAM_VERSION"] == "20240203" + assert passed["TEXTUAL_DISABLE_KITTY_KEY"] == "1"