Merge branch 'tool-windows'

Add tool windows: tool registry, styled aux tmux windows
(TmuxRunner.new_aux_window / TmuxManager.open_aux_window),
SessionService tool-window + clone-for-window methods, and
hqt tool / hqt palette CLI subcommands (fzf tool launcher).

# Conflicts:
#	src/hqt/cli.py
#	src/hqt/tmux/manager.py
#	tests/test_cli.py
#	tests/test_sessions.py
This commit is contained in:
2026-06-11 09:43:37 -04:00
9 changed files with 639 additions and 7 deletions
+111 -7
View File
@@ -2,9 +2,8 @@ from unittest.mock import patch
from click.testing import CliRunner
from hqt import cli as cli_module
from hqt import sandbox
from hqt.cli import main
from hqt import cli, sandbox
from hqt.config import Settings
def _which(found: set[str]):
@@ -16,10 +15,10 @@ def test_doctor_reports_bwrap_present():
# tmux is discovered via cli.shutil.which; bwrap availability comes from the
# shared sandbox.is_available() helper (the single source of truth).
with (
patch.object(cli_module.shutil, "which", side_effect=_which({"tmux", "bwrap"})),
patch.object(cli.shutil, "which", side_effect=_which({"tmux", "bwrap"})),
patch.object(sandbox, "is_available", return_value=True),
):
result = CliRunner().invoke(main, ["doctor"])
result = CliRunner().invoke(cli.main, ["doctor"])
assert result.exit_code == 0
assert "bubblewrap: ✓" in result.output
@@ -27,9 +26,114 @@ def test_doctor_reports_bwrap_present():
def test_doctor_reports_bwrap_missing():
"""Test that doctor reports bubblewrap as missing when not present."""
with (
patch.object(cli_module.shutil, "which", side_effect=_which({"tmux"})),
patch.object(cli.shutil, "which", side_effect=_which({"tmux"})),
patch.object(sandbox, "is_available", return_value=False),
):
result = CliRunner().invoke(main, ["doctor"])
result = CliRunner().invoke(cli.main, ["doctor"])
assert result.exit_code == 0
assert "bubblewrap: ✗" in result.output
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
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 {} <window>`
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
+118
View File
@@ -2010,3 +2010,121 @@ async def test_sandboxed_worktree_binds_repo_git_dir(
)
binds = mock_wrap.call_args.args[3]
assert Bind(Path("/tmp/myproj/.git"), writable=True) in binds
# ---------------------------------------------------------------------------
# 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")
@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
@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")
# ---------------------------------------------------------------------------
# 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, worktree_branch=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")
+87
View File
@@ -1208,3 +1208,90 @@ 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 + allow-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",
)
# 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
# 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
@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")
@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")
+24
View File
@@ -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"