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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apodex/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
12 changes: 11 additions & 1 deletion apodex/docker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"]
# 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",
"TEXTUAL_DISABLE_KITTY_KEY",
):
if name in environ:
args += ["-e", f"{name}={environ[name]}"]
return args


Expand Down
24 changes: 22 additions & 2 deletions apodex/terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

from __future__ import annotations

from collections.abc import Mapping
from collections.abc import Mapping, MutableMapping
from dataclasses import dataclass


Expand All @@ -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.

Expand Down Expand Up @@ -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",
]
73 changes: 73 additions & 0 deletions apodex/tests/test_terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
[
Expand Down Expand Up @@ -188,3 +219,45 @@ 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"


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"
Loading