diff --git a/src/hqt/tmux/runner.py b/src/hqt/tmux/runner.py index 25e0cbb..5311352 100644 --- a/src/hqt/tmux/runner.py +++ b/src/hqt/tmux/runner.py @@ -1,5 +1,6 @@ import asyncio import logging +import shlex from dataclasses import dataclass log = logging.getLogger(__name__) @@ -315,6 +316,68 @@ class TmuxRunner: return window_id + async def new_aux_window( + self, name: str, cwd: str, command: list[str], label: str + ) -> str | None: + """Create an auxiliary (non-session) tool window and switch to it. + + Appends at the next free index, then styles by window_id — not name — so + duplicate names (tool windows are spawned fresh every time) stay + unambiguous. Deliberately does NOT set remain-on-exit: the window closes + when the tool exits (a quit shell closes its window too). The window is + never tracked by hqt; it lives purely as a tmux window. + + Returns the window_id, or None on failure (the half-created window is + cleaned up). + """ + idx = await self._next_window_index() + args = [ + "new-window", + "-t", + f"{self.session_name}:{idx}", + "-n", + name, + "-c", + cwd, + "-P", + "-F", + "#{window_id}", + ] + if command: + args.append(shlex.join(command)) + rc, stdout, err = await self._exec(*args) + if rc != 0: + log.error("new-window (aux) failed: %s", err) + return None + window_id = stdout.strip() + + # Style by window_id: automatic-rename off, the @hqt_label, then the + # Frappé per-window theme — one atomic invocation (";" argv separators). + rc, _, err = await self._exec( + "set-option", + "-w", + "-t", + window_id, + "automatic-rename", + "off", + ";", + "set-option", + "-w", + "-t", + window_id, + "@hqt_label", + label, + ";", + *_window_theme_args(window_id), + ) + if rc != 0: + log.error("set-option (aux window) failed for %s: %s", name, err) + await self._exec("kill-window", "-t", window_id) + return None + + await self._exec("select-window", "-t", window_id) + return window_id + async def has_window(self, window_name: str) -> bool: """Check if a window with this name exists.""" rc, stdout, _ = await self._exec( diff --git a/tests/test_tmux.py b/tests/test_tmux.py index 2b48878..9d08fc8 100644 --- a/tests/test_tmux.py +++ b/tests/test_tmux.py @@ -1009,3 +1009,63 @@ async def test_poll_info_empty_names(runner): result = await mgr.poll_info([]) assert result == {} assert runner._exec.call_count == 1 + + +# --------------------------------------------------------------------------- +# Task 2: new_aux_window — styled tool windows (never tracked) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_new_aux_window_spawns_styles_and_selects(runner): + runner._exec.side_effect = [ + (0, "0\n", ""), # _next_window_index: indices [0] -> next index 1 + (0, "@7\n", ""), # new-window -P -F '#{window_id}' + (0, "", ""), # set-option (automatic-rename + @hqt_label + theme) + (0, "", ""), # select-window + ] + wid = await runner.new_aux_window("lazygit", "/proj", ["lazygit"], "lazygit · proj") + assert wid == "@7" + + calls = runner._exec.call_args_list + # new-window: next free index, name, cwd, print window_id, then the command. + assert calls[1].args == ( + "new-window", "-t", "hqt-main:1", "-n", "lazygit", + "-c", "/proj", "-P", "-F", "#{window_id}", "lazygit", + ) + # post-creation options target the window_id and NEVER set remain-on-exit. + assert calls[2].args[:6] == ( + "set-option", "-w", "-t", "@7", "automatic-rename", "off", + ) + assert "@hqt_label" in calls[2].args + assert "lazygit · proj" in calls[2].args + assert "remain-on-exit" not in calls[2].args + # switch to it. + assert calls[3].args == ("select-window", "-t", "@7") + + +@pytest.mark.asyncio +async def test_new_aux_window_shell_omits_command_arg(runner): + runner._exec.side_effect = [ + (0, "2\n", ""), # indices [2] -> next index 3 + (0, "@9\n", ""), + (0, "", ""), + (0, "", ""), + ] + wid = await runner.new_aux_window("shell", "/proj", [], "shell · proj") + assert wid == "@9" + calls = runner._exec.call_args_list + assert calls[1].args == ( + "new-window", "-t", "hqt-main:3", "-n", "shell", + "-c", "/proj", "-P", "-F", "#{window_id}", + ) + + +@pytest.mark.asyncio +async def test_new_aux_window_returns_none_when_new_window_fails(runner): + runner._exec.side_effect = [ + (0, "0\n", ""), # next index + (1, "", "boom"), # new-window fails + ] + wid = await runner.new_aux_window("nvim", "/p", ["nvim"], "nvim · p") + assert wid is None