diff --git a/src/kitty_workbench/backends/__init__.py b/src/kitty_workbench/backends/__init__.py new file mode 100644 index 0000000..8daa689 --- /dev/null +++ b/src/kitty_workbench/backends/__init__.py @@ -0,0 +1,31 @@ +"""Terminal backend abstraction — detect and adapt to kitty, tmux, or plain terminal.""" +from __future__ import annotations +import os +from abc import ABC, abstractmethod +from typing import Union + +class Backend(ABC): + @abstractmethod + def launch_pane(self, command: list[str], title: str) -> Union[int, str]: + """Split the terminal and run command in new pane. Returns pane ID.""" + @abstractmethod + def close_pane(self, pane_id: Union[int, str]) -> None: + """Close the pane.""" + @abstractmethod + def image_protocol(self) -> str: + """Return supported image protocol: 'kitty', 'sixel', or 'none'.""" + @property + @abstractmethod + def name(self) -> str: + """Backend name for display/logging.""" + +def detect_backend() -> Backend: + if os.environ.get("KITTY_PID"): + from kitty_workbench.backends.kitty import KittyBackend + return KittyBackend() + elif os.environ.get("TMUX"): + from kitty_workbench.backends.tmux import TmuxBackend + return TmuxBackend() + else: + from kitty_workbench.backends.plain import PlainBackend + return PlainBackend() diff --git a/src/kitty_workbench/backends/__pycache__/__init__.cpython-313.pyc b/src/kitty_workbench/backends/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..0b1de8b Binary files /dev/null and b/src/kitty_workbench/backends/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/kitty_workbench/backends/__pycache__/kitty.cpython-313.pyc b/src/kitty_workbench/backends/__pycache__/kitty.cpython-313.pyc new file mode 100644 index 0000000..67b23e2 Binary files /dev/null and b/src/kitty_workbench/backends/__pycache__/kitty.cpython-313.pyc differ diff --git a/src/kitty_workbench/backends/__pycache__/plain.cpython-313.pyc b/src/kitty_workbench/backends/__pycache__/plain.cpython-313.pyc new file mode 100644 index 0000000..de18a60 Binary files /dev/null and b/src/kitty_workbench/backends/__pycache__/plain.cpython-313.pyc differ diff --git a/src/kitty_workbench/backends/__pycache__/tmux.cpython-313.pyc b/src/kitty_workbench/backends/__pycache__/tmux.cpython-313.pyc new file mode 100644 index 0000000..0ceaf03 Binary files /dev/null and b/src/kitty_workbench/backends/__pycache__/tmux.cpython-313.pyc differ diff --git a/src/kitty_workbench/backends/kitty.py b/src/kitty_workbench/backends/kitty.py new file mode 100644 index 0000000..67577ac --- /dev/null +++ b/src/kitty_workbench/backends/kitty.py @@ -0,0 +1,18 @@ +"""Kitty terminal backend — native splits via kitty @ remote control.""" +from __future__ import annotations +import subprocess +from typing import Union +from kitty_workbench.backends import Backend + +class KittyBackend(Backend): + name = "kitty" + def launch_pane(self, command: list[str], title: str) -> int: + result = subprocess.run( + ["kitty", "@", "launch", "--location=vsplit", "--title", title] + command, + capture_output=True, text=True, + ) + return int(result.stdout.strip()) + def close_pane(self, pane_id: Union[int, str]) -> None: + subprocess.run(["kitty", "@", "close-window", f"--match=id:{pane_id}"], capture_output=True) + def image_protocol(self) -> str: + return "kitty" diff --git a/src/kitty_workbench/backends/plain.py b/src/kitty_workbench/backends/plain.py new file mode 100644 index 0000000..3ff43d2 --- /dev/null +++ b/src/kitty_workbench/backends/plain.py @@ -0,0 +1,38 @@ +"""Plain terminal backend — opens a new terminal window.""" +from __future__ import annotations +import os +import platform +import shutil +import subprocess +from typing import Union +from kitty_workbench.backends import Backend + +class PlainBackend(Backend): + name = "plain" + def launch_pane(self, command: list[str], title: str) -> str: + terminal = os.environ.get("TERMINAL") + if terminal and shutil.which(terminal): + proc = subprocess.Popen([terminal, "-e"] + command) + return str(proc.pid) + system = platform.system() + if system == "Darwin": + proc = subprocess.Popen(["open", "-a", "Terminal.app", command[0], "--args"] + command[1:]) + return str(proc.pid) + for term_cmd in ["x-terminal-emulator", "gnome-terminal", "konsole", "xterm"]: + if shutil.which(term_cmd): + if term_cmd == "gnome-terminal": + proc = subprocess.Popen([term_cmd, "--", *command]) + else: + proc = subprocess.Popen([term_cmd, "-e", *command]) + return str(proc.pid) + raise RuntimeError(f"Could not find a terminal emulator. Please run manually:\n {' '.join(command)}") + def close_pane(self, pane_id: Union[int, str]) -> None: + pass + def image_protocol(self) -> str: + term = os.environ.get("TERM_PROGRAM", "") + if term in {"iTerm.app", "iTerm2", "WezTerm", "foot", "contour", "xterm", "mlterm", "mintty"}: + return "sixel" + vte = os.environ.get("VTE_VERSION", "") + if vte and int(vte) >= 7600: + return "sixel" + return "none" diff --git a/src/kitty_workbench/backends/tmux.py b/src/kitty_workbench/backends/tmux.py new file mode 100644 index 0000000..eba293a --- /dev/null +++ b/src/kitty_workbench/backends/tmux.py @@ -0,0 +1,27 @@ +"""Tmux backend — pane splits via tmux split-window.""" +from __future__ import annotations +import os +import subprocess +from typing import Union +from kitty_workbench.backends import Backend + +SIXEL_TERMINALS = {"iTerm.app", "iTerm2", "WezTerm", "foot", "contour", "xterm", "mlterm", "mintty"} + +class TmuxBackend(Backend): + name = "tmux" + def launch_pane(self, command: list[str], title: str) -> str: + result = subprocess.run( + ["tmux", "split-window", "-h", "-d", "-P", "-F", "#{pane_id}"] + command, + capture_output=True, text=True, + ) + return result.stdout.strip() + def close_pane(self, pane_id: Union[int, str]) -> None: + subprocess.run(["tmux", "kill-pane", "-t", str(pane_id)], capture_output=True) + def image_protocol(self) -> str: + term = os.environ.get("TERM_PROGRAM", "") + if term in SIXEL_TERMINALS: + return "sixel" + vte = os.environ.get("VTE_VERSION", "") + if vte and int(vte) >= 7600: + return "sixel" + return "none" diff --git a/tests/test_backends.py b/tests/test_backends.py new file mode 100644 index 0000000..11ad288 --- /dev/null +++ b/tests/test_backends.py @@ -0,0 +1,61 @@ +import os +from unittest.mock import patch, MagicMock +from kitty_workbench.backends import detect_backend, Backend +from kitty_workbench.backends.kitty import KittyBackend +from kitty_workbench.backends.tmux import TmuxBackend +from kitty_workbench.backends.plain import PlainBackend + + +def test_detect_kitty(): + with patch.dict(os.environ, {"KITTY_PID": "12345"}, clear=False): + backend = detect_backend() + assert isinstance(backend, KittyBackend) + +def test_detect_tmux(): + env = os.environ.copy() + env.pop("KITTY_PID", None) + env["TMUX"] = "/tmp/tmux-1000/default,12345,0" + with patch.dict(os.environ, env, clear=True): + backend = detect_backend() + assert isinstance(backend, TmuxBackend) + +def test_detect_plain(): + env = os.environ.copy() + env.pop("KITTY_PID", None) + env.pop("TMUX", None) + with patch.dict(os.environ, env, clear=True): + backend = detect_backend() + assert isinstance(backend, PlainBackend) + +def test_kitty_image_protocol(): + b = KittyBackend() + assert b.image_protocol() == "kitty" + +def test_tmux_image_protocol(): + b = TmuxBackend() + with patch.dict(os.environ, {"TERM_PROGRAM": "unknown"}, clear=False): + assert b.image_protocol() in ("sixel", "none") + +def test_plain_image_protocol(): + b = PlainBackend() + assert b.image_protocol() in ("sixel", "none") + +def test_kitty_launch_pane(): + b = KittyBackend() + with patch("kitty_workbench.backends.kitty.subprocess") as mock_sp: + mock_sp.run.return_value = MagicMock(stdout="42", returncode=0) + pane_id = b.launch_pane(["kitty-workbench", "tui", "test"], "Test") + assert pane_id == 42 + call_args = mock_sp.run.call_args[0][0] + assert "kitty" in call_args[0] + assert "--location=vsplit" in call_args + +def test_tmux_launch_pane(): + b = TmuxBackend() + with patch("kitty_workbench.backends.tmux.subprocess") as mock_sp: + mock_sp.run.return_value = MagicMock(stdout="%5", returncode=0) + pane_id = b.launch_pane(["kitty-workbench", "tui", "test"], "Test") + assert pane_id == "%5" + call_args = mock_sp.run.call_args[0][0] + assert "tmux" in call_args[0] + assert "split-window" in call_args