Files
felixm fc95bf9830 feat: stop and rename a session from its tmux window
Add "stop" and "rename" to the Alt+p tool palette so a session can be
managed from inside its own harness window, mirroring the TUI's s/r keys:

- stop kills the harness window (stop_session_for_window).
- rename prompts for a nickname, pre-filled with the current one. The
  name is read from /dev/tty via readline (the fzf pipe leaves our stdin
  spent) and never passes through tmux's command parser, so there's
  nothing to quote-escape.

Extract require_session_id_for_window as the shared resolve-or-raise
guard behind the tool/stop/rename branches, dropping the duplicated
"not an hqt session window" check.

Also removes the now-shipped feature plans and specs under docs/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 11:07:33 -04:00

274 lines
8.6 KiB
Python

from unittest.mock import patch
from click.testing import CliRunner
from hqt import cli, sandbox
from hqt.config import Settings
def _which(found: set[str]):
return lambda name: f"/usr/bin/{name}" if name in found else None
def test_doctor_reports_bwrap_present():
"""Test that doctor reports bubblewrap when 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.shutil, "which", side_effect=_which({"tmux", "bwrap"})),
patch.object(sandbox, "is_available", return_value=True),
):
result = CliRunner().invoke(cli.main, ["doctor"])
assert result.exit_code == 0
assert "bubblewrap: ✓" in result.output
def test_doctor_reports_bwrap_missing():
"""Test that doctor reports bubblewrap as missing when not present."""
with (
patch.object(cli.shutil, "which", side_effect=_which({"tmux"})),
patch.object(sandbox, "is_available", return_value=False),
):
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_stop_dispatches_to_stop(monkeypatch, tmp_path):
monkeypatch.setattr(
"hqt.config.get_settings", lambda: Settings(db_path=tmp_path / "t.db")
)
calls = {}
async def fake_stop(self, window):
calls["stop"] = window
monkeypatch.setattr(
"hqt.sessions.service.SessionService.stop_session_for_window", fake_stop
)
result = CliRunner().invoke(cli.main, ["tool", "stop", "hqt-5"])
assert result.exit_code == 0, result.output
assert calls["stop"] == "hqt-5"
def test_tool_cmd_rename_applies_new_nickname(monkeypatch, tmp_path):
from types import SimpleNamespace
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,
)
monkeypatch.setattr(
"hqt.sessions.service.SessionService.get_session",
lambda self, sid: SimpleNamespace(nickname="old"),
)
# Prefill the prompt with the current nickname; user edits to "new".
seen = {}
def fake_read(prompt, prefill=""):
seen["prefill"] = prefill
return "new"
monkeypatch.setattr(cli, "_read_line_from_tty", fake_read)
calls = {}
monkeypatch.setattr(
"hqt.sessions.service.SessionService.rename_session",
lambda self, sid, nickname: calls.setdefault("args", (sid, nickname)),
)
result = CliRunner().invoke(cli.main, ["tool", "rename", "hqt-5"])
assert result.exit_code == 0, result.output
assert seen["prefill"] == "old"
assert calls["args"] == (5, "new")
def test_tool_cmd_rename_aborted_leaves_name(monkeypatch, tmp_path):
from types import SimpleNamespace
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,
)
monkeypatch.setattr(
"hqt.sessions.service.SessionService.get_session",
lambda self, sid: SimpleNamespace(nickname="old"),
)
monkeypatch.setattr(cli, "_read_line_from_tty", lambda prompt, prefill="": None)
calls = {}
monkeypatch.setattr(
"hqt.sessions.service.SessionService.rename_session",
lambda self, sid, nickname: calls.setdefault("args", (sid, nickname)),
)
result = CliRunner().invoke(cli.main, ["tool", "rename", "hqt-5"])
assert result.exit_code == 0, result.output
assert "args" not in calls # None return = aborted, no DB write
def test_tool_cmd_rename_unknown_window_raises(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,
)
result = CliRunner().invoke(cli.main, ["tool", "rename", "hqt-5"])
assert result.exit_code == 1
assert "not an hqt session window" in result.output
def test_tool_cmd_reset_windows_dispatches_to_manager(monkeypatch, tmp_path):
monkeypatch.setattr(
"hqt.config.get_settings", lambda: Settings(db_path=tmp_path / "t.db")
)
calls = {}
async def fake_reset(self, home_window):
calls["home"] = home_window
return True
monkeypatch.setattr("hqt.tmux.manager.TmuxManager.reset_window_indexes", fake_reset)
result = CliRunner().invoke(cli.main, ["tool", "reset-windows", "hqt-5"])
assert result.exit_code == 0, result.output
# Compacts against the configured home/TUI window, not the trigger window.
assert calls["home"] == Settings(db_path=tmp_path / "t.db").tui_window_name
def test_tool_cmd_reset_windows_failure_reports_error(monkeypatch, tmp_path):
monkeypatch.setattr(
"hqt.config.get_settings", lambda: Settings(db_path=tmp_path / "t.db")
)
async def fake_reset(self, home_window):
return False
monkeypatch.setattr("hqt.tmux.manager.TmuxManager.reset_window_indexes", fake_reset)
result = CliRunner().invoke(cli.main, ["tool", "reset-windows", "hqt-5"])
assert result.exit_code == 1
assert "reset window indexes" in result.output
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
assert "stop" in popup_cmd
assert "rename" in popup_cmd
assert "reset-windows" 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