From 7c543beffe8c2a4091c84b7c2ebc2600f66ad6e7 Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Wed, 10 Jun 2026 21:12:23 -0400 Subject: [PATCH 01/13] Add tool registry for tool windows Co-Authored-By: Claude Opus 4.8 --- src/hqt/tools.py | 28 ++++++++++++++++++++++++++++ tests/test_tools.py | 24 ++++++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 src/hqt/tools.py create mode 100644 tests/test_tools.py diff --git a/src/hqt/tools.py b/src/hqt/tools.py new file mode 100644 index 0000000..109ecae --- /dev/null +++ b/src/hqt/tools.py @@ -0,0 +1,28 @@ +"""Tools that can be opened in their own tmux window for a session's project. + +A tool window is NOT an hqt session: hqt spawns and styles it, then forgets it. +It lives purely as a tmux window until the tool exits. (``clone`` is handled +separately in the service — it creates a real session, not an aux window.) +""" + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Tool: + """How to open a tool in its own window. + + ``label`` is the base ``@hqt_label`` text (the project name is appended per + spawn). ``command`` is the argv to run; an empty list means "use tmux's + default shell" (a plain interactive shell). + """ + + label: str + command: list[str] + + +TOOLS: dict[str, Tool] = { + "nvim": Tool(label="nvim", command=["nvim"]), + "lazygit": Tool(label="lazygit", command=["lazygit"]), + "shell": Tool(label="shell", command=[]), +} diff --git a/tests/test_tools.py b/tests/test_tools.py new file mode 100644 index 0000000..51fcea8 --- /dev/null +++ b/tests/test_tools.py @@ -0,0 +1,24 @@ +from hqt.tools import TOOLS, Tool + + +def test_registry_has_the_three_tools(): + assert set(TOOLS) == {"nvim", "lazygit", "shell"} + + +def test_each_entry_is_a_tool(): + assert all(isinstance(t, Tool) for t in TOOLS.values()) + + +def test_nvim_and_lazygit_have_commands(): + assert TOOLS["nvim"].command == ["nvim"] + assert TOOLS["lazygit"].command == ["lazygit"] + + +def test_shell_has_empty_command_meaning_default_shell(): + assert TOOLS["shell"].command == [] + + +def test_labels_are_the_bare_tool_names(): + assert TOOLS["nvim"].label == "nvim" + assert TOOLS["lazygit"].label == "lazygit" + assert TOOLS["shell"].label == "shell" From 9896521fa94777944a463eebdb325b2febf69669 Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Wed, 10 Jun 2026 21:16:37 -0400 Subject: [PATCH 02/13] Add TmuxRunner.new_aux_window for styled tool windows Co-Authored-By: Claude Opus 4.8 --- src/hqt/tmux/runner.py | 63 ++++++++++++++++++++++++++++++++++++++++++ tests/test_tmux.py | 60 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+) 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 From 7835511f0a212394f8477aabc04b6aa6f9551444 Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Wed, 10 Jun 2026 21:21:33 -0400 Subject: [PATCH 03/13] Test new_aux_window cleanup when set-option fails Co-Authored-By: Claude Opus 4.8 --- tests/test_tmux.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/test_tmux.py b/tests/test_tmux.py index 9d08fc8..52b735b 100644 --- a/tests/test_tmux.py +++ b/tests/test_tmux.py @@ -1069,3 +1069,17 @@ async def test_new_aux_window_returns_none_when_new_window_fails(runner): ] wid = await runner.new_aux_window("nvim", "/p", ["nvim"], "nvim · p") assert wid is None + + +@pytest.mark.asyncio +async def test_new_aux_window_kills_window_when_set_option_fails(runner): + runner._exec.side_effect = [ + (0, "0\n", ""), # _next_window_index + (0, "@7\n", ""), # new-window -> window_id @7 + (1, "", "nope"), # set-option fails + (0, "", ""), # kill-window cleanup + ] + wid = await runner.new_aux_window("nvim", "/p", ["nvim"], "nvim · p") + assert wid is None + # the half-built window must be cleaned up by id. + assert runner._exec.call_args_list[-1].args == ("kill-window", "-t", "@7") From 115d0ffc019dd27ac7b35fef3633e34912aa0e37 Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Wed, 10 Jun 2026 21:22:53 -0400 Subject: [PATCH 04/13] Add TmuxManager.open_aux_window delegate Co-Authored-By: Claude Opus 4.8 --- src/hqt/tmux/manager.py | 9 +++++++++ tests/test_tmux.py | 11 +++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/hqt/tmux/manager.py b/src/hqt/tmux/manager.py index 5d7dd3a..94b031c 100644 --- a/src/hqt/tmux/manager.py +++ b/src/hqt/tmux/manager.py @@ -116,3 +116,12 @@ class TmuxManager: async def set_window_label(self, window_name: str, label: str) -> None: """Set the status-bar label for a window (status symbol + name).""" await self.runner.set_window_label(window_name, label) + + async def open_aux_window( + self, name: str, cwd: str, command: list[str], label: str + ) -> str | None: + """Open a styled tool window (non-session) and switch to it. + + Returns the new window_id, or None on failure. + """ + return await self.runner.new_aux_window(name, cwd, command, label) diff --git a/tests/test_tmux.py b/tests/test_tmux.py index 52b735b..c9047b8 100644 --- a/tests/test_tmux.py +++ b/tests/test_tmux.py @@ -1083,3 +1083,14 @@ async def test_new_aux_window_kills_window_when_set_option_fails(runner): assert wid is None # the half-built window must be cleaned up by id. assert runner._exec.call_args_list[-1].args == ("kill-window", "-t", "@7") + + +@pytest.mark.asyncio +async def test_manager_open_aux_window_delegates(runner): + from unittest.mock import AsyncMock + + runner.new_aux_window = AsyncMock(return_value="@4") + mgr = TmuxManager(runner) + wid = await mgr.open_aux_window("nvim", "/p", ["nvim"], "nvim · p") + assert wid == "@4" + runner.new_aux_window.assert_awaited_once_with("nvim", "/p", ["nvim"], "nvim · p") From e588c62fd43a5c3c2b7f2e6d4269d684732b23ef Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Wed, 10 Jun 2026 21:25:39 -0400 Subject: [PATCH 05/13] Add SessionService tool-window + window-resolution methods Co-Authored-By: Claude Opus 4.8 --- src/hqt/sessions/service.py | 53 +++++++++++++++++++++++ tests/test_sessions.py | 84 +++++++++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+) diff --git a/src/hqt/sessions/service.py b/src/hqt/sessions/service.py index 7d18a9c..aad2274 100644 --- a/src/hqt/sessions/service.py +++ b/src/hqt/sessions/service.py @@ -1,6 +1,7 @@ import asyncio import contextlib import logging +import shutil import time from collections.abc import Mapping from dataclasses import dataclass @@ -16,6 +17,7 @@ from hqt.git import worktree from hqt.git.worktree import WorktreeState from hqt.harnesses.base import HarnessConfigurator, SpawnConfig from hqt.status import window_label +from hqt.tools import TOOLS from hqt.tmux.manager import SpawnRequest, TmuxManager from hqt.tmux.runner import WindowInfo @@ -386,6 +388,57 @@ class SessionService: ) return True + def session_id_for_window(self, window_name: str) -> int | None: + """Resolve a tmux window name to its active hqt session id, or None. + + None means the window is not an hqt session window (a tool window, the + TUI home window, or an unrelated tmux window). + """ + with self.factory() as db: + sess = ( + db.query(Session) + .filter_by(tmux_session_name=window_name, archived=False) + .first() + ) + return sess.id if sess else None + + async def open_tool_window(self, session_id: int, tool: str) -> str | None: + """Open a tool (nvim/lazygit/shell) in a new styled window for a session. + + Spawns at the next free index in the session's project directory and + switches to it. The window is NOT a tracked session. Raises ServiceError + for an unknown tool, a missing binary, or a missing session/project. + Returns the new window_id, or None if the tmux spawn fails. + """ + spec = TOOLS.get(tool) + if spec is None: + raise ServiceError(f"Unknown tool '{tool}'") + if spec.command and shutil.which(spec.command[0]) is None: + raise ServiceError(f"{spec.command[0]} not found on PATH") + with self.factory() as db: + sess = db.get(Session, session_id) + if sess is None: + raise ServiceError("Session not found") + project = db.get(Project, sess.project_id) + if project is None: + raise ServiceError("Project no longer exists") + cwd = project.path + label = f"{spec.label} · {project.name}" + return await self.tmux.open_aux_window(spec.label, cwd, spec.command, label) + + async def open_tool_window_for_window( + self, window_name: str, tool: str + ) -> str | None: + """Open a tool window for the session identified by its tmux window name. + + Used by the `hqt tool` CLI (the tmux binding passes #{window_name}). + Raises ServiceError if the name is not an active hqt session window. + """ + session_id = self.session_id_for_window(window_name) + if session_id is None: + raise ServiceError(f"{window_name!r} is not an hqt session window") + return await self.open_tool_window(session_id, tool) + async def stop_session(self, session_id: int) -> None: with self.factory() as db: sess = db.get(Session, session_id) diff --git a/tests/test_sessions.py b/tests/test_sessions.py index 361b0f7..437c825 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -1518,3 +1518,87 @@ async def test_create_session_no_lock_for_non_capturing_harness(service, db, tmu # The `service` fixture's harness has captures_session_id = False, so the # spawn must NOT be serialized under the capture lock. assert observed["locked_during_spawn"] is False + + +# --------------------------------------------------------------------------- +# Task 4: SessionService tool-window methods +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_open_tool_window_spawns_styled_window(service, db, tmux, monkeypatch): + await service.create_session(project_id=1, harness_name="claude-code") + monkeypatch.setattr("hqt.sessions.service.shutil.which", lambda b: "/usr/bin/" + b) + tmux.open_aux_window = AsyncMock(return_value="@5") + + wid = await service.open_tool_window(1, "lazygit") + + assert wid == "@5" + tmux.open_aux_window.assert_awaited_once_with( + "lazygit", "/tmp/myproj", ["lazygit"], "lazygit · myproj" + ) + + +@pytest.mark.asyncio +async def test_open_tool_window_shell_skips_which_check(service, db, tmux, monkeypatch): + await service.create_session(project_id=1, harness_name="claude-code") + monkeypatch.setattr("hqt.sessions.service.shutil.which", lambda b: None) + tmux.open_aux_window = AsyncMock(return_value="@3") + + wid = await service.open_tool_window(1, "shell") + + assert wid == "@3" + tmux.open_aux_window.assert_awaited_once_with( + "shell", "/tmp/myproj", [], "shell · myproj" + ) + + +@pytest.mark.asyncio +async def test_open_tool_window_unknown_tool_raises(service): + with pytest.raises(ServiceError): + await service.open_tool_window(1, "emacs") + + +@pytest.mark.asyncio +async def test_open_tool_window_missing_binary_raises(service, db, monkeypatch): + await service.create_session(project_id=1, harness_name="claude-code") + monkeypatch.setattr("hqt.sessions.service.shutil.which", lambda b: None) + with pytest.raises(ServiceError): + await service.open_tool_window(1, "lazygit") + + +@pytest.mark.asyncio +async def test_open_tool_window_unknown_session_raises(service, monkeypatch): + monkeypatch.setattr("hqt.sessions.service.shutil.which", lambda b: "/usr/bin/" + b) + with pytest.raises(ServiceError): + await service.open_tool_window(999, "nvim") + + +def test_session_id_for_window_resolves_and_misses(service, db): + import asyncio + + asyncio.run(service.create_session(project_id=1, harness_name="claude-code")) + assert service.session_id_for_window("hqt-1") == 1 + assert service.session_id_for_window("not-an-hqt-window") is None + + +@pytest.mark.asyncio +async def test_open_tool_window_for_window_resolves_by_name( + service, db, tmux, monkeypatch +): + await service.create_session(project_id=1, harness_name="claude-code") + monkeypatch.setattr("hqt.sessions.service.shutil.which", lambda b: "/usr/bin/" + b) + tmux.open_aux_window = AsyncMock(return_value="@2") + + wid = await service.open_tool_window_for_window("hqt-1", "nvim") + + assert wid == "@2" + tmux.open_aux_window.assert_awaited_once_with( + "nvim", "/tmp/myproj", ["nvim"], "nvim · myproj" + ) + + +@pytest.mark.asyncio +async def test_open_tool_window_for_window_unknown_window_raises(service): + with pytest.raises(ServiceError): + await service.open_tool_window_for_window("not-an-hqt-window", "nvim") From 3da3fc6c4c7c7eea704072a6cba8f0901849deeb Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Wed, 10 Jun 2026 21:30:06 -0400 Subject: [PATCH 06/13] Polish tool-window methods: error format, logging, async test Co-Authored-By: Claude Opus 4.8 --- src/hqt/sessions/service.py | 3 ++- tests/test_sessions.py | 7 +++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/hqt/sessions/service.py b/src/hqt/sessions/service.py index aad2274..44fdf62 100644 --- a/src/hqt/sessions/service.py +++ b/src/hqt/sessions/service.py @@ -410,6 +410,7 @@ class SessionService: for an unknown tool, a missing binary, or a missing session/project. Returns the new window_id, or None if the tmux spawn fails. """ + log.info("Opening tool window: session=%s tool=%s", session_id, tool) spec = TOOLS.get(tool) if spec is None: raise ServiceError(f"Unknown tool '{tool}'") @@ -421,7 +422,7 @@ class SessionService: raise ServiceError("Session not found") project = db.get(Project, sess.project_id) if project is None: - raise ServiceError("Project no longer exists") + raise ServiceError(f"Project {sess.project_id} no longer exists") cwd = project.path label = f"{spec.label} · {project.name}" return await self.tmux.open_aux_window(spec.label, cwd, spec.command, label) diff --git a/tests/test_sessions.py b/tests/test_sessions.py index 437c825..12d5563 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -1574,10 +1574,9 @@ async def test_open_tool_window_unknown_session_raises(service, monkeypatch): await service.open_tool_window(999, "nvim") -def test_session_id_for_window_resolves_and_misses(service, db): - import asyncio - - asyncio.run(service.create_session(project_id=1, harness_name="claude-code")) +@pytest.mark.asyncio +async def test_session_id_for_window_resolves_and_misses(service, db): + await service.create_session(project_id=1, harness_name="claude-code") assert service.session_id_for_window("hqt-1") == 1 assert service.session_id_for_window("not-an-hqt-window") is None From 5265c8d4b0ab17d1921836593a82d96f03dfac54 Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Wed, 10 Jun 2026 21:31:42 -0400 Subject: [PATCH 07/13] Add SessionService.clone_session_for_window Opens a fresh harness session with the same project, harness, and model as the source window, creating a new sibling hqt- conversation. Co-Authored-By: Claude Opus 4.8 --- src/hqt/sessions/service.py | 27 +++++++++++++++++++++++++++ tests/test_sessions.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/src/hqt/sessions/service.py b/src/hqt/sessions/service.py index 44fdf62..0294371 100644 --- a/src/hqt/sessions/service.py +++ b/src/hqt/sessions/service.py @@ -440,6 +440,33 @@ class SessionService: raise ServiceError(f"{window_name!r} is not an hqt session window") return await self.open_tool_window(session_id, tool) + async def clone_session_for_window( + self, window_name: str + ) -> "CreateSessionResult": + """Open a fresh harness session cloning the one in `window_name`. + + Same project, harness, and model as the source session, but a brand-new + conversation (a new hqt- window at the next index). Raises + ServiceError if `window_name` is not an active hqt session window — so + invoking clone from a tool window (nvim/shell) or the TUI is a clean + no-op. + """ + with self.factory() as db: + sess = ( + db.query(Session) + .options(selectinload(Session.harness)) + .filter_by(tmux_session_name=window_name, archived=False) + .first() + ) + if sess is None: + raise ServiceError(f"{window_name!r} is not an hqt session window") + project_id = sess.project_id + harness_name = sess.harness.name + model = sess.model + return await self.create_session( + project_id, harness_name, nickname=None, model=model + ) + async def stop_session(self, session_id: int) -> None: with self.factory() as db: sess = db.get(Session, session_id) diff --git a/tests/test_sessions.py b/tests/test_sessions.py index 12d5563..ee2bf0b 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -1601,3 +1601,36 @@ async def test_open_tool_window_for_window_resolves_by_name( async def test_open_tool_window_for_window_unknown_window_raises(service): with pytest.raises(ServiceError): await service.open_tool_window_for_window("not-an-hqt-window", "nvim") + + +# --------------------------------------------------------------------------- +# Task 5: clone_session_for_window +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_clone_session_for_window_reuses_project_harness_model( + service, db, monkeypatch +): + await service.create_session( + project_id=1, harness_name="claude-code", nickname="orig", model="opus" + ) + captured = {} + + async def fake_create(project_id, harness_name, nickname=None, model=None): + captured["args"] = (project_id, harness_name, nickname, model) + return "SENTINEL" + + monkeypatch.setattr(service, "create_session", fake_create) + + result = await service.clone_session_for_window("hqt-1") + + assert result == "SENTINEL" + # Same project + harness + model; a fresh sibling, so no nickname. + assert captured["args"] == (1, "claude-code", None, "opus") + + +@pytest.mark.asyncio +async def test_clone_session_for_window_unknown_window_raises(service): + with pytest.raises(ServiceError): + await service.clone_session_for_window("not-an-hqt-window") From 43fdfd61f19fe75a7f29531e494915deec23cfc6 Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Wed, 10 Jun 2026 21:33:37 -0400 Subject: [PATCH 08/13] Mirror create_session signature in clone test stub Co-Authored-By: Claude Opus 4.8 --- tests/test_sessions.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_sessions.py b/tests/test_sessions.py index ee2bf0b..b387f3d 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -1617,7 +1617,9 @@ async def test_clone_session_for_window_reuses_project_harness_model( ) captured = {} - async def fake_create(project_id, harness_name, nickname=None, model=None): + async def fake_create( + project_id, harness_name, nickname=None, model=None, worktree_branch=None + ): captured["args"] = (project_id, harness_name, nickname, model) return "SENTINEL" From 8a304ba19e5234ccf56e0a6c3e8e619da5ca6e1f Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Wed, 10 Jun 2026 21:35:57 -0400 Subject: [PATCH 09/13] Add hqt tool CLI subcommand (tool + clone dispatch) Co-Authored-By: Claude Opus 4.8 --- src/hqt/cli.py | 41 ++++++++++++++++++++++++++++++ tests/test_cli.py | 65 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 tests/test_cli.py diff --git a/src/hqt/cli.py b/src/hqt/cli.py index 7069f7f..55cbebb 100644 --- a/src/hqt/cli.py +++ b/src/hqt/cli.py @@ -121,6 +121,47 @@ def doctor(): click.echo("No harnesses found on PATH") +def _build_session_service(): + """Wire a SessionService the same way HqtApp.on_mount does (for CLI use).""" + from hqt.config import get_settings + from hqt.db.engine import ensure_db, get_engine, get_session_factory + from hqt.harnesses.registry import discover_harnesses + from hqt.sessions.service import SessionService + from hqt.tmux.manager import TmuxManager + from hqt.tmux.runner import TmuxRunner + + settings = get_settings() + ensure_db(settings) + factory = get_session_factory(get_engine(settings)) + runner = TmuxRunner(settings.tmux_path, settings.tui_session_name) + return SessionService(factory, TmuxManager(runner), discover_harnesses()) + + +@main.command(name="tool") +@click.argument("tool") +@click.argument("window") +def tool_cmd(tool, window): + """Run TOOL for the session in tmux WINDOW. + + TOOL is nvim/lazygit/shell (opens a styled tool window) or "clone" (a fresh + harness with the same project + model). WINDOW is the tmux window name (the + hqt- key, e.g. from #{window_name}). + """ + import asyncio + + from hqt.errors import ServiceError + + svc = _build_session_service() + try: + if tool == "clone": + asyncio.run(svc.clone_session_for_window(window)) + else: + asyncio.run(svc.open_tool_window_for_window(window, tool)) + except ServiceError as err: + click.echo(str(err), err=True) + raise SystemExit(1) + + @main.command(name="list") def list_cmd(): """List projects and sessions.""" diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..652116b --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,65 @@ +from click.testing import CliRunner + +from hqt import cli +from hqt.config import Settings + + +def test_tool_cmd_opens_tool_window(monkeypatch, tmp_path): + monkeypatch.setattr( + "hqt.config.get_settings", lambda: Settings(db_path=tmp_path / "t.db") + ) + calls = {} + + async def fake_for_window(self, window, tool): + calls["tool"] = (window, tool) + return "@9" + + monkeypatch.setattr( + "hqt.sessions.service.SessionService.open_tool_window_for_window", + fake_for_window, + ) + + result = CliRunner().invoke(cli.main, ["tool", "lazygit", "hqt-5"]) + + assert result.exit_code == 0, result.output + assert calls["tool"] == ("hqt-5", "lazygit") + + +def test_tool_cmd_clone_dispatches_to_clone(monkeypatch, tmp_path): + monkeypatch.setattr( + "hqt.config.get_settings", lambda: Settings(db_path=tmp_path / "t.db") + ) + calls = {} + + async def fake_clone(self, window): + calls["clone"] = window + return "RESULT" + + monkeypatch.setattr( + "hqt.sessions.service.SessionService.clone_session_for_window", fake_clone + ) + + result = CliRunner().invoke(cli.main, ["tool", "clone", "hqt-5"]) + + assert result.exit_code == 0, result.output + assert calls["clone"] == "hqt-5" + + +def test_tool_cmd_reports_service_error(monkeypatch, tmp_path): + from hqt.errors import ServiceError + + monkeypatch.setattr( + "hqt.config.get_settings", lambda: Settings(db_path=tmp_path / "t.db") + ) + + async def boom(self, window, tool): + raise ServiceError("'hqt-5' is not an hqt session window") + + monkeypatch.setattr( + "hqt.sessions.service.SessionService.open_tool_window_for_window", boom + ) + + result = CliRunner().invoke(cli.main, ["tool", "nvim", "hqt-5"]) + + assert result.exit_code == 1 + assert "not an hqt session window" in result.output From a798c2bcf4cc888dd2a6cf7b7bdadcccb259061c Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Wed, 10 Jun 2026 21:40:00 -0400 Subject: [PATCH 10/13] Seed harness rows in CLI service wiring (mirror on_mount) Co-Authored-By: Claude Opus 4.8 --- src/hqt/cli.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/hqt/cli.py b/src/hqt/cli.py index 55cbebb..cd056b7 100644 --- a/src/hqt/cli.py +++ b/src/hqt/cli.py @@ -122,10 +122,15 @@ def doctor(): def _build_session_service(): - """Wire a SessionService the same way HqtApp.on_mount does (for CLI use).""" + """Wire a SessionService the same way HqtApp.on_mount does (for CLI use). + + Mirrors on_mount's DB + tmux wiring, including seeding harness rows, but + deliberately omits apply_theme — a one-off CLI call must not re-theme the + session's status bar. + """ from hqt.config import get_settings from hqt.db.engine import ensure_db, get_engine, get_session_factory - from hqt.harnesses.registry import discover_harnesses + from hqt.harnesses.registry import discover_harnesses, ensure_harnesses_in_db from hqt.sessions.service import SessionService from hqt.tmux.manager import TmuxManager from hqt.tmux.runner import TmuxRunner @@ -133,6 +138,8 @@ def _build_session_service(): settings = get_settings() ensure_db(settings) factory = get_session_factory(get_engine(settings)) + with factory() as db: + ensure_harnesses_in_db(db) runner = TmuxRunner(settings.tmux_path, settings.tui_session_name) return SessionService(factory, TmuxManager(runner), discover_harnesses()) From e35828d81968f783f07ac58048a59052ddcfeb72 Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Thu, 11 Jun 2026 08:13:31 -0400 Subject: [PATCH 11/13] Add hqt palette CLI subcommand (fzf tool launcher) Co-Authored-By: Claude Opus 4.8 --- src/hqt/cli.py | 62 +++++++++++++++++++++++++++++++++++++++++++++++ tests/test_cli.py | 44 +++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/src/hqt/cli.py b/src/hqt/cli.py index cd056b7..9e99188 100644 --- a/src/hqt/cli.py +++ b/src/hqt/cli.py @@ -169,6 +169,68 @@ def tool_cmd(tool, window): raise SystemExit(1) +PALETTE_ENTRIES = ["nvim", "lazygit", "shell", "clone"] + +# fzf colors matching the Catppuccin Frappé status bar / the Alt+o switcher. +_PALETTE_FZF_COLORS = ( + "bg:#292c3c,bg+:#414559,fg:#c6d0f5,fg+:#c6d0f5,hl:#ef9f76,hl+:#ef9f76," + "pointer:#ef9f76,prompt:#8caaee,info:#838ba7,border:#838ba7" +) + + +def _palette_popup_command(window: str) -> str: + """Shell pipeline for the fzf popup; `window` is baked in as a literal. + + display-popup does NOT format-expand its command, so the window name must be + concrete here (run-shell already expanded #{window_name} before `hqt palette` + ran). The selected entry runs `hqt tool `. + """ + import shlex + + entries = "\\n".join(PALETTE_ENTRIES) + "\\n" + return ( + f"printf '{entries}' | " + f"fzf --reverse --no-info --prompt='tool ' --pointer='▌' " + f"--color='{_PALETTE_FZF_COLORS}' | " + f"xargs -r -I{{}} hqt tool {{}} {shlex.quote(window)}" + ) + + +@main.command(name="palette") +@click.argument("window") +def palette_cmd(window): + """Pop an fzf tool palette for the session in tmux WINDOW (bound to M-p).""" + from hqt.config import get_settings + + settings = get_settings() + svc = _build_session_service() + if svc.session_id_for_window(window) is None: + subprocess.run( + [ + settings.tmux_path, + "display-message", + "hqt: open the tool palette from a harness window", + ] + ) + return + subprocess.run( + [ + settings.tmux_path, + "display-popup", + "-E", + "-w", + "40%", + "-h", + "30%", + "-T", + " open tool ", + "-S", + "fg=#838ba7", + _palette_popup_command(window), + ] + ) + + @main.command(name="list") def list_cmd(): """List projects and sessions.""" diff --git a/tests/test_cli.py b/tests/test_cli.py index 652116b..7472b75 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -63,3 +63,47 @@ def test_tool_cmd_reports_service_error(monkeypatch, tmp_path): assert result.exit_code == 1 assert "not an hqt session window" in result.output + + +def test_palette_cmd_shows_popup_for_session_window(monkeypatch, tmp_path): + monkeypatch.setattr( + "hqt.config.get_settings", lambda: Settings(db_path=tmp_path / "t.db") + ) + monkeypatch.setattr( + "hqt.sessions.service.SessionService.session_id_for_window", + lambda self, window: 5, + ) + runs = [] + monkeypatch.setattr(cli.subprocess, "run", lambda argv, **kw: runs.append(argv)) + + result = CliRunner().invoke(cli.main, ["palette", "hqt-5"]) + + assert result.exit_code == 0, result.output + assert len(runs) == 1 + argv = runs[0] + assert "display-popup" in argv + popup_cmd = argv[-1] + assert "fzf" in popup_cmd + assert "nvim" in popup_cmd and "clone" in popup_cmd + # the window is baked into the command for `hqt tool {} ` + assert "hqt-5" in popup_cmd + + +def test_palette_cmd_hints_for_non_session_window(monkeypatch, tmp_path): + monkeypatch.setattr( + "hqt.config.get_settings", lambda: Settings(db_path=tmp_path / "t.db") + ) + monkeypatch.setattr( + "hqt.sessions.service.SessionService.session_id_for_window", + lambda self, window: None, + ) + runs = [] + monkeypatch.setattr(cli.subprocess, "run", lambda argv, **kw: runs.append(argv)) + + result = CliRunner().invoke(cli.main, ["palette", "nvim"]) + + assert result.exit_code == 0, result.output + assert len(runs) == 1 + argv = runs[0] + assert "display-message" in argv + assert "display-popup" not in argv From 8ebb07e578914d8037fa5055ff8a34506ab5e285 Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Thu, 11 Jun 2026 08:18:06 -0400 Subject: [PATCH 12/13] Hoist shlex to module-level import in cli Co-Authored-By: Claude Opus 4.8 --- src/hqt/cli.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/hqt/cli.py b/src/hqt/cli.py index 9e99188..3d3a5c9 100644 --- a/src/hqt/cli.py +++ b/src/hqt/cli.py @@ -1,5 +1,6 @@ import logging import os +import shlex import subprocess import sys import traceback @@ -185,8 +186,6 @@ def _palette_popup_command(window: str) -> str: concrete here (run-shell already expanded #{window_name} before `hqt palette` ran). The selected entry runs `hqt tool `. """ - import shlex - entries = "\\n".join(PALETTE_ENTRIES) + "\\n" return ( f"printf '{entries}' | " From 488d76e88d94eb4d8cd6a1b10701905456563227 Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Thu, 11 Jun 2026 08:28:07 -0400 Subject: [PATCH 13/13] Disable allow-rename on tool windows (match new_window) nvim/lazygit emit OSC title sequences; without allow-rename off a tool could overwrite the window name and scramble the styled @hqt_label. Mirrors the window-option setup in new_window. Surfaced by final integration review. Co-Authored-By: Claude Opus 4.8 --- src/hqt/tmux/runner.py | 13 +++++++++++-- tests/test_tmux.py | 4 +++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/hqt/tmux/runner.py b/src/hqt/tmux/runner.py index 5311352..b58cb88 100644 --- a/src/hqt/tmux/runner.py +++ b/src/hqt/tmux/runner.py @@ -351,8 +351,10 @@ class TmuxRunner: 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). + # Style by window_id: automatic-rename + allow-rename off (nvim/lazygit + # emit OSC title sequences that would otherwise scramble the label), the + # @hqt_label, then the Frappé per-window theme — one atomic invocation + # (";" argv separators). Mirrors new_window's window-option setup. rc, _, err = await self._exec( "set-option", "-w", @@ -365,6 +367,13 @@ class TmuxRunner: "-w", "-t", window_id, + "allow-rename", + "off", + ";", + "set-option", + "-w", + "-t", + window_id, "@hqt_label", label, ";", diff --git a/tests/test_tmux.py b/tests/test_tmux.py index c9047b8..52ffb08 100644 --- a/tests/test_tmux.py +++ b/tests/test_tmux.py @@ -1021,7 +1021,7 @@ 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, "", ""), # set-option (automatic-rename + allow-rename + @hqt_label + theme) (0, "", ""), # select-window ] wid = await runner.new_aux_window("lazygit", "/proj", ["lazygit"], "lazygit · proj") @@ -1037,6 +1037,8 @@ async def test_new_aux_window_spawns_styles_and_selects(runner): assert calls[2].args[:6] == ( "set-option", "-w", "-t", "@7", "automatic-rename", "off", ) + # allow-rename off too, so nvim/lazygit OSC titles can't scramble the label. + assert "allow-rename" in calls[2].args assert "@hqt_label" in calls[2].args assert "lazygit · proj" in calls[2].args assert "remain-on-exit" not in calls[2].args