From 5a509cbbbb815b348eacfbfe746623b2e1dba08c Mon Sep 17 00:00:00 2001 From: Mortdecai Date: Sun, 29 Mar 2026 19:08:02 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20terminal=20backend=20abstraction=20?= =?UTF-8?q?=E2=80=94=20kitty,=20tmux,=20and=20plain=20backends?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/kitty_workbench/backends/__init__.py | 31 +++++++++ .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 2199 bytes .../__pycache__/kitty.cpython-313.pyc | Bin 0 -> 1643 bytes .../__pycache__/plain.cpython-313.pyc | Bin 0 -> 2932 bytes .../backends/__pycache__/tmux.cpython-313.pyc | Bin 0 -> 2033 bytes src/kitty_workbench/backends/kitty.py | 18 ++++++ src/kitty_workbench/backends/plain.py | 38 +++++++++++ src/kitty_workbench/backends/tmux.py | 27 ++++++++ tests/test_backends.py | 61 ++++++++++++++++++ 9 files changed, 175 insertions(+) create mode 100644 src/kitty_workbench/backends/__init__.py create mode 100644 src/kitty_workbench/backends/__pycache__/__init__.cpython-313.pyc create mode 100644 src/kitty_workbench/backends/__pycache__/kitty.cpython-313.pyc create mode 100644 src/kitty_workbench/backends/__pycache__/plain.cpython-313.pyc create mode 100644 src/kitty_workbench/backends/__pycache__/tmux.cpython-313.pyc create mode 100644 src/kitty_workbench/backends/kitty.py create mode 100644 src/kitty_workbench/backends/plain.py create mode 100644 src/kitty_workbench/backends/tmux.py create mode 100644 tests/test_backends.py 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 0000000000000000000000000000000000000000..0b1de8b33a34edb4b69aee97bd5be95b472f6ada GIT binary patch literal 2199 zcmbVN&2tn*6z}=k*~zDZP#9%tqE_58?Bh))FZ4c}}Ti>5Fs&aIXuEcn_jd(jcjVntLm?zG&+ zs;E+Oh)ff|GDUoM#hJHqd&2WQzdA*}w8co*s*eno0bhBEc=v zHR|T(2Yx?^AuMarvk2&Di2zgc5@qO|QmQ^`uBE-G879G?6E)j0Z{j~Vb}1F>?TCkK%}j(E+F(0a@k_HN zV=<2JpncQQ0XshC7?B*Y^*hkYf79SqNleJJ*W2+?Udo=4zB9#NVN`u$YM2;$uf zy1+)HG7Zz@IGxcMJ2OjXviNe8zyz{(hNnE5Y2nI9}>h=-@2mT>0#1Rls7gfg4 zD`RSev0mC4Bsd>q?5jbL6g!iQt;QmgNz5ajGA6K@iI-3uL@@73J!_Ov9dL|yuiV6sIng3QOc-StsoMGG%=@ar4% zMIyglzybUy-^6CVXmdizd1%^YJ9_i~c(7mK!D+!0!42?W?Dhb@EBIh=6L!qqJ9T$S zd98)z<F-?cn7?x!!K0AS4SK}ofjL@50YpgZA;tJ2Q-l3kA#+*H=>FB zd1D0yf#JtI%@#mhB@bQi`sTIG>sPK_`F{PLJAK!k-mboXfAY1PSMF2~KlDa#P2HHf zRl8C9v2xEla@RYuT|IGsa_Z(ccdD=dxud94sw^hVV|-*7>!e{`OREET5ula?auZDu z$_m<{z*j~WkgCXyKJ*4rK}1&qGW}PHsOr0URlX;V!!Bl9_7;dImSGt8$>-Z-`T_BN aBX4YzLr?8-WA3}~DS^*lHP5JNj{g9vQw$#f literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..67b23e2701db79b488e2cc6a02562112f6c26be8 GIT binary patch literal 1643 zcma)6O-v+36t4asW(HVbcfloMOLjN5*aN5_!=MK2X2V>xsHJ#T!(Mc#_ax?75 zD!+kB@WNR;c>-V$tw^3)%Y84-&T@x*L4JM1H+`$A*X^cO1Fmnw;p{B8H zU%Xl1<$?e5X8WvJtpGQXS6ZS{QE2ytod!7x*S1G$wKNM=0v9$^-nX4vjVC;fA9=bM z3WF}sX^gX+cXKXG9)Hd~OO#pO>e4KX850K0i7*nvh0S;bio#-?CSA`ICX3Tm;e=VI z6Oc$)%C4|!%<_~o6MS)DiHG&ItP|Hm@F|Mxt%THNK5Ls9U2nzUWu4Jbv9|CzuirPU z5X^M9M5TxccLNfOiHlX5xge1lU=M-ppx;I&pH6)>UC%U-=5c9)JC3Dl#5ot&~`OUGZxo26B1>c+m%|a6Yy|tA&K69d#J5>dJ)NYFtSwo#iUB#o1vr= zv||k2#IrC<#pETAE(=HGT{#Oo=xF%5`ZASHE_pV+2K)9vnlQcs?e_`24rgHil>G)h zOsKr=ne=t3IIMmYE6}xfT%~?Xs+&t0i6M{jKyOO*0a3!31f3XTQO3BFMR_W5730tH zAni+rF z8wc_P9aTnm9-Qiy_4a91vtsQZQ?tg4J5me0bTE#xuyArUS0jrl6alH85H{cHCZtVe zMZ|=ht_!0yb2ds;rnYcEX7$`DQMy+|{sDal1hRU}1@fn%Y1%Jn@fc12ibjsn#4);a QYF4!I-S_@Ruqg}w0zi(KLI3~& literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..de18a6038c6d41859ab3483730083b864067a57b GIT binary patch literal 2932 zcmbUjO>i4m`R%V{NtWU+P8_S5ERF++L>8qD2`+Uf#inUYoS=<@8#S}DTCL@^S8v7J zRc&iZCnv_?Vy9^)hGC{Tnwy6c2M%Spl$#PAY@VGSfJ<&L4#P}Od~a84Im|F&vWNG5 z-+SNx_rCA#et&-g!S|=FKijU1(BBvleNu>U`xQX$BMlLxaT-6(UFHd|GF+Gzh!8-L zhyj#{6hN8C0LAIp9*E%g|dY+QAh#-1`oIgTa`j9uUGZ5MkpKuaYI zC?K(SLQQ69FZ#1eJtH@<8f4AQhCJ@fA}&?g(R28eiZ`TpqR}c`TMQ5l2ZZ z9*OWCj5tS8`ITt5__YY@!4Y(>OJJ!aX~Os{LdN%B?3E^Jk|vipEyhx$#S0SPeHs$c z%lfG$6m+S95SCw&cv%3~F3cSS|0Bx}hag6xYU0NxbK>rZ<16t51yuo`k$jl)v^ zZ!J7tMWZN*R8&3|RY2?4(#7Ku)q|x2uhQssK8n}ka+w(#zdlvG^4`qFAGi3tMde9@ zK=)CR^(E{Wt(;>m zjV(g+x{gJqWyVa{jRXDp)|)?cn@&}Q23O~x?Ny^U1|nr+>NUqQJWC}_tkwDvyKmga49o+i9zr9oWN@;oYCW06t8^9dCi zc9q7VtNl5b)In;+^Q}5f6`R<%>(&$@E}`OrYvWvuik{`n(U|Gh!GkK5eA|a;;!)&O zWERK?2rJyw&3e^i^Fa+iI#rm5j1)}AXjZL4#m0q2+xPQJE?KNt*qkqT#0<7p^xj@~ zRCZQHR;MzyO{AlCScII>^b9zU~woev1N?|X1dOLgOarVsS?3Uj? z`=j62VCiJvy*v5$|8cTDxp!=MQ!*?K39+i|=P*+S;wkfcIi9FM(-=#>ZdW}f zqN?N@Y%^gxu4jdHIrdy#dI#)!cx=YqMmzBo$pL0EQ_;t)1!g}7kQos`cTNaPCkE<;4}HsnL__hq)l1uT zqXyL@uJ4+zL$l9S@cF}&An09&1)3`}u-3ZMTKo9U#~XOF(#gEGD()mRZN=&&tsP}} zFMVWf;m*PuzJoVzZNAe<7gmKYlj%=iU-#}^*zh~bDPY>k3J}LRnhe82Wn%eFf&&5qJ=zlQIKy(m6GnvWeKfA?*r;2& zP7}IbcdJc@;VE7J8I08KNk-S_Y~uNjjVp=ivD6Az2yCT8D{z^RdO#K4wBwx8b`HVjzx!ki6PU`=+7?X~MGK@W!qA^{s zx@PF)Tob|tCkZQ7Sl3q>c9bncuu$1wY1p_%SnJRsHc}7G&#`EJ64rE~KP<^TA8Gst zVIn5T6*z!KK^|+%e+V4M{RO@C1dZ&XzFm~rMg6;I;0YRfg3dk@U*(RjpZ^yE5UBbO DS5=}n literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..0ceaf0312fb6e74cd98ff1b2c078b5f649abdf8b GIT binary patch literal 2033 zcmah~&2JM&6rcTGe_)eP5*k#xGO5~?NGym_l@`&0NZTY}z-vR*fL5FJ#w=OSy0fz+ zaRth$BE1B)pytSt-jLcOe*nFKa%eh05A?*1DpZwv>YMcj464dl`{uowH*e#dOyg)L$PzhPu5VG`h`nc4MO|nhN%*110)mrhQG3&VV9&Q+qm#-Ub z2YW`;4tyFJmwd;d+##_?7JS?Y7e=4(Af~`_4#pvMs2}2}0=81epn{?u?Z!K-gCmpY z!jLj$k$R-XRKEt@0j9QmOz9HzK{BqL^*VfpX>N#V7?Xc8iRY-(Zo7QHttNIAVe@@Z zZlH6B2!N1i34n+QXy}$Sgf7ZNu`-sNXsVU9ltj}kmFP{)%&{C#J3ZwSP6d;Bo^Fay zvWE&1Y+*AA136+7F(?VSs0m~BMCmocqRF|s2@2jncsewU zOu1reqz~*)z8Cn8IC25L27IUB`?QnY`!#3F_8XULrpOAe)21=;>@cS7m_kb4A~j`} zjpErh30*IWm_%aC)QC2~hba*yej7aG$P4B;^sSbI8%&`-4Lqh2FNy;i@dONL>{$6i z*z(G5;KU8DJnQ4~yie)KLP+LkJ?vg6N5oBrmh2Z}>D`LT9N^LR63fAIaJKo;*@ymI;1V(EUd zv|b#(rw?z!D&+QZ#IIq(4mOZwqVc&D^cp&ttb2emZn-@RNI|>+AX%Vt|XAo)=VvIDJX>cR_hw z_SNTE^QGO!@fc6>#BjrB_{&JzM<5;8t110IrvDjL?ujba#rM*f&US=mGO2;;;uiLn z^5LDW-I}VYRam|@Q9WUws-FC;dgKH{aGm`{FJQSdwF&#oMAe!+S@CZJyiCR+_DqF| zVZ<~KU-C(anbh1Q}BMEAW4^~7Xg02RZ3RDZ#hjM zi-a_E!+;I-687@k8?Y{#1OcQK`&K(Eoz*KVSJv>Y+4bW7tMW#!c>Uw`Txny+(8I#^ z)r%_^SMds7JAdowdZBz(`aM^;K6Wko{)08TzGE*qZ4`GV{Qih4rb>n&ZYLA?5uJhi zb)M`7o0LHCmkWgeCx$7Lz1*>wROcDUZ=aF4^8(7`3Wr7{+~~Szm~PwHX?eEIvbNm{ z8*#w(ylsCKJ3%^AwCy>cL^SZRheMc_`2^wjlMHh)#-s1F?TG(DxVA%~)Xc^JrEL>l z>XJN?%TJ2j1h 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