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>
This commit is contained in:
@@ -75,6 +75,102 @@ def test_tool_cmd_clone_dispatches_to_clone(monkeypatch, tmp_path):
|
||||
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")
|
||||
@@ -150,6 +246,8 @@ def test_palette_cmd_shows_popup_for_session_window(monkeypatch, tmp_path):
|
||||
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
|
||||
|
||||
@@ -2073,6 +2073,15 @@ async def test_session_id_for_window_resolves_and_misses(service, db):
|
||||
assert service.session_id_for_window("not-an-hqt-window") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_require_session_id_for_window_resolves_or_raises(service, db):
|
||||
# The shared guard behind tool/stop/rename: id on a hit, ServiceError on a miss.
|
||||
await service.create_session(project_id=1, harness_name="claude-code")
|
||||
assert service.require_session_id_for_window("hqt-1") == 1
|
||||
with pytest.raises(ServiceError):
|
||||
service.require_session_id_for_window("not-an-hqt-window")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_tool_window_for_window_resolves_by_name(
|
||||
service, db, tmux, monkeypatch
|
||||
@@ -2128,3 +2137,16 @@ async def test_clone_session_for_window_reuses_project_harness_model(
|
||||
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")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_session_for_window_kills_by_name(service, db, tmux):
|
||||
await service.create_session(project_id=1, harness_name="claude-code")
|
||||
await service.stop_session_for_window("hqt-1")
|
||||
tmux.kill.assert_called_once_with("hqt-1")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_session_for_window_unknown_window_raises(service):
|
||||
with pytest.raises(ServiceError):
|
||||
await service.stop_session_for_window("not-an-hqt-window")
|
||||
|
||||
+41
-15
@@ -1218,10 +1218,14 @@ async def test_poll_info_empty_names(runner):
|
||||
@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, "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
|
||||
(
|
||||
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"
|
||||
@@ -1229,12 +1233,26 @@ async def test_new_aux_window_spawns_styles_and_selects(runner):
|
||||
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",
|
||||
"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",
|
||||
"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
|
||||
@@ -1248,7 +1266,7 @@ async def test_new_aux_window_spawns_styles_and_selects(runner):
|
||||
@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, "2\n", ""), # indices [2] -> next index 3
|
||||
(0, "@9\n", ""),
|
||||
(0, "", ""),
|
||||
(0, "", ""),
|
||||
@@ -1257,16 +1275,24 @@ async def test_new_aux_window_shell_omits_command_arg(runner):
|
||||
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}",
|
||||
"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
|
||||
(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
|
||||
@@ -1275,10 +1301,10 @@ async def test_new_aux_window_returns_none_when_new_window_fails(runner):
|
||||
@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
|
||||
(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
|
||||
|
||||
Reference in New Issue
Block a user