feat: worktree session UI (dialog checkbox, confirm delete, list marker)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 20:41:11 -04:00
parent 0cf147ee25
commit 9efa270422
6 changed files with 449 additions and 21 deletions
+21
View File
@@ -32,6 +32,27 @@ hqt
| `r` | Rename session |
| `s` | Stop session |
## Worktree-isolated sessions
The New Session dialog (`n`) has an **Isolate in worktree** checkbox. When the
selected project is a git repository, checking it runs the session in a
dedicated [git worktree](https://git-scm.com/docs/git-worktree) instead of the
project root, so concurrent sessions don't step on each other's working tree.
- The worktree lives at `<project>/.worktrees/<branch>` and is automatically
added to the repo's `.git/info/exclude` so it never shows up as untracked.
- The branch is created off the current `HEAD`. If you leave the branch field
blank it defaults to a slugified form of the session nickname.
- For non-git projects the checkbox is disabled.
Deleting a worktree session (`d`) opens a confirmation dialog with an **Also
remove worktree** option. As a safety check, if the worktree has uncommitted
changes or unmerged commits a warning is shown and the removal box is left
unchecked — checking it then forces removal and discards that work.
Known limitation: submodules are not initialized in freshly created worktrees;
run `git submodule update --init` inside the worktree if your project needs them.
## Architecture
hqt uses a layered architecture: a Click CLI bootstraps into a Textual TUI, which drives service classes (ProjectService, SessionService) backed by SQLite via SQLAlchemy. Sessions are spawned as tmux windows through TmuxManager, with harness-specific configuration provided by pluggable HarnessConfigurator implementations discovered at runtime.
+67 -5
View File
@@ -1,5 +1,6 @@
import logging
from collections.abc import Coroutine
from pathlib import Path
from typing import Any
from sqlalchemy.orm import sessionmaker
@@ -10,6 +11,7 @@ from textual.theme import Theme
from textual.widgets import Footer
from hqt.config import get_settings
from hqt.git import worktree
from hqt.db.engine import ensure_db, get_engine, get_session_factory
from hqt.errors import ServiceError
from hqt.harnesses.registry import discover_harnesses, ensure_harnesses_in_db
@@ -17,6 +19,7 @@ from hqt.projects.service import ProjectService
from hqt.sessions.service import SessionService
from hqt.tmux.manager import TmuxManager
from hqt.tmux.runner import TmuxRunner
from hqt.tui.screens.confirm_delete import ConfirmDeleteScreen
from hqt.tui.screens.project_form import ProjectFormScreen
from hqt.tui.screens.new_session import NewSessionScreen
from hqt.tui.screens.rename_session import RenameSessionScreen
@@ -245,14 +248,25 @@ class HqtApp(App):
if not harness_names:
self.notify("No harnesses found", severity="error")
return
project = self._projects().get(project_id)
if project is None:
self.notify("Project not found", severity="error")
return
project_path = project.path
def on_dismiss(result: tuple[str, str, str | None] | None) -> None:
def on_dismiss(
result: tuple[str, str, str | None, str | None] | None,
) -> None:
if result:
harness_name, nickname, model = result
harness_name, nickname, model, worktree_branch = result
async def _do() -> None:
create_result = await self._sessions().create_session(
project_id, harness_name, nickname, model
project_id,
harness_name,
nickname,
model,
worktree_branch=worktree_branch,
)
if not create_result.spawn_ok:
first_line = next(
@@ -271,7 +285,13 @@ class HqtApp(App):
self._run_service_worker(_do())
self.push_screen(NewSessionScreen(harness_names), on_dismiss)
async def _open() -> None:
# is_git_repo is async; resolve it before pushing so the dialog can
# disable the worktree control for non-repo projects.
is_git_repo = await worktree.is_git_repo(Path(project_path))
self.push_screen(NewSessionScreen(harness_names, is_git_repo), on_dismiss)
self.run_worker(_open())
def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
# `Binding("enter", "attach_session", priority=True)` is app-global, so
@@ -297,13 +317,55 @@ class HqtApp(App):
def action_delete_session(self) -> None:
sid = self.query_one(SessionList).get_selected_session_id()
if sid:
if not sid:
return
sess = self._sessions().get_session(sid)
if sess is None:
self.notify("Session not found", severity="error")
return
# Plain (non-worktree) sessions delete immediately, no confirmation.
if sess.worktree_path is None:
async def _do() -> None:
await self._sessions().delete_session(sid)
await self._refresh_sessions()
self._run_service_worker(_do())
return
session_label = sess.nickname or sess.tmux_session_name
async def _confirm() -> None:
state = await self._sessions().worktree_state_for(sid)
parts: list[str] = []
if state and state.dirty:
parts.append("uncommitted changes")
if state and state.unique_commits > 0:
parts.append(f"{state.unique_commits} unmerged commit(s)")
warning = (
" and ".join(parts).capitalize() + " — work will be lost"
if parts
else None
)
def on_dismiss(result: tuple[bool, bool] | None) -> None:
if result is None:
return
_confirmed, remove = result
force = remove and warning is not None
async def _do() -> None:
await self._sessions().delete_session(
sid, remove_worktree=remove, force=force
)
await self._refresh_sessions()
self._run_service_worker(_do())
self.push_screen(ConfirmDeleteScreen(session_label, warning), on_dismiss)
self._run_service_worker(_confirm())
def action_rename_session(self) -> None:
sid = self.query_one(SessionList).get_selected_session_id()
+41
View File
@@ -0,0 +1,41 @@
from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical
from textual.screen import ModalScreen
from textual.widgets import Button, Checkbox, Label
class ConfirmDeleteScreen(ModalScreen[tuple[bool, bool] | None]):
"""Confirm deletion of a worktree session.
Dismisses with ``(True, remove_worktree)`` on Delete, or ``None`` on Cancel.
Mirrors RenameSessionScreen's structure so it reuses the shared
``#project-form-dialog`` styling. The "Also remove worktree" checkbox is
pre-checked only when there is no warning (clean worktree); when a warning is
present the caller treats a checked box as a forced removal.
"""
def __init__(self, session_label: str, warning: str | None = None) -> None:
self._session_label = session_label
self._warning = warning
super().__init__()
def compose(self) -> ComposeResult:
with Vertical(id="project-form-dialog"):
yield Label(f"Delete session {self._session_label}?")
if self._warning is not None:
yield Label(self._warning, id="delete-warning")
yield Checkbox(
"Also remove worktree",
value=(self._warning is None),
id="remove-worktree-checkbox",
)
with Horizontal(classes="dialog-actions"):
yield Button("Delete", variant="error", id="ok-btn")
yield Button("Cancel", id="cancel-btn")
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "ok-btn":
checkbox = self.query_one("#remove-worktree-checkbox", Checkbox)
self.dismiss((True, checkbox.value))
else:
self.dismiss(None)
+45 -4
View File
@@ -1,12 +1,15 @@
from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical
from textual.screen import ModalScreen
from textual.widgets import Button, Input, Label, Select
from textual.widgets import Button, Checkbox, Input, Label, Select
from hqt.git import worktree
class NewSessionScreen(ModalScreen[tuple[str, str, str | None] | None]):
def __init__(self, harness_names: list[str]) -> None:
class NewSessionScreen(ModalScreen[tuple[str, str, str | None, str | None] | None]):
def __init__(self, harness_names: list[str], is_git_repo: bool) -> None:
self.harness_names = harness_names
self._is_git_repo = is_git_repo
super().__init__()
def compose(self) -> ComposeResult:
@@ -26,10 +29,37 @@ class NewSessionScreen(ModalScreen[tuple[str, str, str | None] | None]):
yield Input(placeholder="session nickname", id="nickname-input")
yield Label("Model (optional):")
yield Input(placeholder="model name", id="model-input")
checkbox_label = (
"Isolate in worktree"
if self._is_git_repo
else "Isolate in worktree (not a git repo)"
)
yield Checkbox(
checkbox_label,
id="worktree-checkbox",
disabled=not self._is_git_repo,
)
# Hidden until the worktree checkbox is checked.
branch_input = Input(placeholder="branch name", id="branch-input")
branch_input.display = False
yield branch_input
with Horizontal(classes="dialog-actions"):
yield Button("OK", variant="primary", id="ok-btn")
yield Button("Cancel", id="cancel-btn")
def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
if event.checkbox.id != "worktree-checkbox":
return
branch_input = self.query_one("#branch-input", Input)
if event.value:
branch_input.display = True
# Prefill from the nickname slug, but never clobber user-typed text.
if not branch_input.value:
nickname = self.query_one("#nickname-input", Input).value
branch_input.value = worktree.slugify(nickname)
else:
branch_input.display = False
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "ok-btn":
self._submit()
@@ -45,7 +75,18 @@ class NewSessionScreen(ModalScreen[tuple[str, str, str | None] | None]):
harness = self.query_one("#harness-select", Select).value
nickname = self.query_one("#nickname-input", Input).value
model = self.query_one("#model-input", Input).value or None
worktree_branch: str | None = None
if self.query_one("#worktree-checkbox", Checkbox).value:
branch_input = self.query_one("#branch-input", Input)
worktree_branch = branch_input.value.strip() or worktree.slugify(nickname)
if not worktree_branch:
# Nothing to derive a branch from — keep the dialog open and let
# the user supply a branch name.
branch_input.focus()
return
if harness and harness != Select.BLANK:
self.dismiss((str(harness), nickname, model))
self.dismiss((str(harness), nickname, model, worktree_branch))
else:
self.dismiss(None)
+16 -3
View File
@@ -29,10 +29,19 @@ _STATUS_COLORS: dict[str, str] = {
def format_session_text(
nickname: str, harness_name: str, status_text: str, symbol: str
nickname: str,
harness_name: str,
status_text: str,
symbol: str,
worktree_branch: str | None = None,
) -> str:
color = _STATUS_COLORS.get(status_text, "#c6d0f5") # default: Text
label = escape(f"{nickname} [{harness_name}]")
name = nickname
# Mark worktree sessions with their branch. The nickname defaults to the
# branch at creation, so only append the marker when it adds information.
if worktree_branch and nickname != worktree_branch:
name = f"{nickname}{worktree_branch}"
label = escape(f"{name} [{harness_name}]")
return f"[{color}]{symbol}[/] {label} {status_text}"
@@ -52,7 +61,11 @@ class SessionList(Widget, can_focus=False):
status_text = getattr(info, "status", "dead" if not info.alive else "idle")
symbol = status_symbol(status_text, alive=info.alive)
text = format_session_text(
s.nickname or s.tmux_session_name, s.harness.name, status_text, symbol
s.nickname or s.tmux_session_name,
s.harness.name,
status_text,
symbol,
getattr(s, "worktree_branch", None),
)
new_items.append((f"sess-{s.id}", text, s.id))
+259 -9
View File
@@ -291,7 +291,9 @@ async def test_new_session_on_dismiss_sequential_create_then_refresh():
call_order: list[str] = []
async def fake_create(project_id, harness_name, nickname, model):
async def fake_create(
project_id, harness_name, nickname, model, worktree_branch=None
):
# Brief yield so that, if two separate workers were used, the list
# worker would be able to start and record "list" before this
# completes — exposing the regression.
@@ -318,10 +320,11 @@ async def test_new_session_on_dismiss_sequential_create_then_refresh():
with patch("hqt.tui.app.discover_harnesses", return_value={"claude": object()}):
app.action_new_session()
await app.workers.wait_for_complete()
await pilot.pause()
# The NewSessionScreen is now on top; dismiss it with a valid result tuple.
app.screen.dismiss(("claude", "mynick", None))
app.screen.dismiss(("claude", "mynick", None, None))
await pilot.pause()
# Wait for the worker spawned by on_dismiss to finish deterministically.
@@ -390,9 +393,10 @@ async def test_new_session_on_dismiss_notifies_on_spawn_failure():
with patch("hqt.tui.app.discover_harnesses", return_value={"claude": object()}):
with patch.object(app, "notify", side_effect=capture_notify):
app.action_new_session()
await app.workers.wait_for_complete()
await pilot.pause()
app.screen.dismiss(("claude", "mynick", None))
app.screen.dismiss(("claude", "mynick", None, None))
await pilot.pause()
await app.workers.wait_for_complete()
await pilot.pause()
@@ -460,9 +464,10 @@ async def test_new_session_on_dismiss_no_notify_on_success():
with patch("hqt.tui.app.discover_harnesses", return_value={"claude": object()}):
with patch.object(app, "notify", side_effect=capture_notify):
app.action_new_session()
await app.workers.wait_for_complete()
await pilot.pause()
app.screen.dismiss(("claude", "mynick", None))
app.screen.dismiss(("claude", "mynick", None, None))
await pilot.pause()
await app.workers.wait_for_complete()
await pilot.pause()
@@ -493,7 +498,7 @@ async def test_attach_enter_binding_disabled_in_modal():
# On the main screen the attach binding is live.
assert app.check_action("attach_session", ()) is True
app.push_screen(NewSessionScreen(["claude", "codex"]))
app.push_screen(NewSessionScreen(["claude", "codex"], is_git_repo=True))
await pilot.pause()
# With a modal on top, the binding is disabled so Enter reaches the modal.
@@ -515,7 +520,7 @@ async def test_new_session_enter_selects_highlighted_harness():
from hqt.tui.screens.new_session import NewSessionScreen
from textual.widgets import Select
app.push_screen(NewSessionScreen(["claude", "codex", "kiro"]))
app.push_screen(NewSessionScreen(["claude", "codex", "kiro"], is_git_repo=True))
await pilot.pause()
sel = app.screen.query_one("#harness-select", Select)
sel.focus()
@@ -540,7 +545,7 @@ async def test_new_session_enter_without_navigation_selects_first_harness():
from hqt.tui.screens.new_session import NewSessionScreen
from textual.widgets import Select
app.push_screen(NewSessionScreen(["claude", "codex", "kiro"]))
app.push_screen(NewSessionScreen(["claude", "codex", "kiro"], is_git_repo=True))
await pilot.pause()
sel = app.screen.query_one("#harness-select", Select)
sel.focus()
@@ -565,7 +570,9 @@ async def test_new_session_enter_in_input_submits_dialog():
from textual.widgets import Input
results: list = []
app.push_screen(NewSessionScreen(["claude", "codex"]), results.append)
app.push_screen(
NewSessionScreen(["claude", "codex"], is_git_repo=True), results.append
)
await pilot.pause()
nick = app.screen.query_one("#nickname-input", Input)
@@ -575,7 +582,7 @@ async def test_new_session_enter_in_input_submits_dialog():
await pilot.press("enter")
await pilot.pause()
assert results == [("claude", "mywork", None)], f"got {results}"
assert results == [("claude", "mywork", None, None)], f"got {results}"
# ---------------------------------------------------------------------------
@@ -1221,3 +1228,246 @@ async def test_session_list_jk_navigation():
await pilot.press("k")
await pilot.pause()
assert sl.get_selected_session_id() == session_ids[0]
# ---------------------------------------------------------------------------
# Worktree session UI: NewSessionScreen worktree controls
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_new_session_worktree_checkbox_disabled_when_not_git_repo():
"""The worktree checkbox is disabled for non-git-repo projects."""
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.tui.screens.new_session import NewSessionScreen
from textual.widgets import Checkbox
app.push_screen(NewSessionScreen(["claude"], is_git_repo=False))
await pilot.pause()
cb = app.screen.query_one("#worktree-checkbox", Checkbox)
assert cb.disabled is True
assert "not a git repo" in str(cb.label)
@pytest.mark.asyncio
async def test_new_session_worktree_unchecked_returns_none_branch():
"""With the checkbox unchecked, worktree_branch is None."""
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.tui.screens.new_session import NewSessionScreen
from textual.widgets import Button, Input
results: list = []
app.push_screen(NewSessionScreen(["claude"], is_git_repo=True), results.append)
await pilot.pause()
app.screen.query_one("#nickname-input", Input).value = "mywork"
app.screen.query_one("#ok-btn", Button).press()
await pilot.pause()
assert results == [("claude", "mywork", None, None)], f"got {results}"
@pytest.mark.asyncio
async def test_new_session_worktree_checked_uses_typed_branch():
"""Checked checkbox + typed branch returns that branch."""
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.tui.screens.new_session import NewSessionScreen
from textual.widgets import Button, Checkbox, Input
results: list = []
app.push_screen(NewSessionScreen(["claude"], is_git_repo=True), results.append)
await pilot.pause()
app.screen.query_one("#nickname-input", Input).value = "My Work"
app.screen.query_one("#worktree-checkbox", Checkbox).value = True
await pilot.pause()
# Branch input is revealed and prefilled from the nickname slug.
branch = app.screen.query_one("#branch-input", Input)
assert branch.display is True
assert branch.value == "my-work"
# User overrides with their own branch name.
branch.value = "feature-x"
app.screen.query_one("#ok-btn", Button).press()
await pilot.pause()
assert results == [("claude", "My Work", None, "feature-x")], f"got {results}"
@pytest.mark.asyncio
async def test_new_session_worktree_checked_blank_branch_slugifies_nickname():
"""Checked checkbox + blank branch derives the branch from the nickname slug."""
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.tui.screens.new_session import NewSessionScreen
from textual.widgets import Button, Checkbox, Input
results: list = []
app.push_screen(NewSessionScreen(["claude"], is_git_repo=True), results.append)
await pilot.pause()
app.screen.query_one("#nickname-input", Input).value = "Cool Feature!"
cb = app.screen.query_one("#worktree-checkbox", Checkbox)
cb.value = True
await pilot.pause()
# Clear the prefilled branch so _submit must re-derive it from nickname.
app.screen.query_one("#branch-input", Input).value = ""
app.screen.query_one("#ok-btn", Button).press()
await pilot.pause()
assert results == [("claude", "Cool Feature!", None, "cool-feature")], (
f"got {results}"
)
@pytest.mark.asyncio
async def test_new_session_branch_input_hidden_until_checked():
"""The branch input starts hidden and toggles with the checkbox."""
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.tui.screens.new_session import NewSessionScreen
from textual.widgets import Checkbox, Input
app.push_screen(NewSessionScreen(["claude"], is_git_repo=True))
await pilot.pause()
branch = app.screen.query_one("#branch-input", Input)
assert branch.display is False
cb = app.screen.query_one("#worktree-checkbox", Checkbox)
cb.value = True
await pilot.pause()
assert branch.display is True
cb.value = False
await pilot.pause()
assert branch.display is False
# ---------------------------------------------------------------------------
# Worktree session UI: ConfirmDeleteScreen
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_confirm_delete_checkbox_checked_by_default_when_no_warning():
"""No warning (clean worktree) → 'remove worktree' pre-checked → (True, True)."""
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.tui.screens.confirm_delete import ConfirmDeleteScreen
from textual.widgets import Button, Checkbox
results: list = []
app.push_screen(ConfirmDeleteScreen("mysess"), results.append)
await pilot.pause()
assert app.screen.query_one("#remove-worktree-checkbox", Checkbox).value is True
app.screen.query_one("#ok-btn", Button).press()
await pilot.pause()
assert results == [(True, True)]
@pytest.mark.asyncio
async def test_confirm_delete_checkbox_unchecked_by_default_with_warning():
"""A warning (dirty/unmerged) → checkbox unchecked → (True, False) by default."""
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.tui.screens.confirm_delete import ConfirmDeleteScreen
from textual.widgets import Button, Checkbox, Label
results: list = []
app.push_screen(
ConfirmDeleteScreen("mysess", warning="Work will be lost"),
results.append,
)
await pilot.pause()
assert (
app.screen.query_one("#remove-worktree-checkbox", Checkbox).value is False
)
warning = app.screen.query_one("#delete-warning", Label)
assert "Work will be lost" in str(warning.render())
app.screen.query_one("#ok-btn", Button).press()
await pilot.pause()
assert results == [(True, False)]
@pytest.mark.asyncio
async def test_confirm_delete_cancel_returns_none():
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.tui.screens.confirm_delete import ConfirmDeleteScreen
from textual.widgets import Button
results: list = []
app.push_screen(ConfirmDeleteScreen("mysess"), results.append)
await pilot.pause()
app.screen.query_one("#cancel-btn", Button).press()
await pilot.pause()
assert results == [None]
# ---------------------------------------------------------------------------
# Worktree session UI: list marker + non-worktree delete unchanged
# ---------------------------------------------------------------------------
def test_format_session_text_appends_worktree_branch_marker():
from hqt.tui.widgets.session_list import format_session_text
text = format_session_text("mywork", "claude", "idle", "", "feature-x")
assert "⎇ feature-x" in text
def test_format_session_text_no_marker_when_nickname_equals_branch():
"""Avoid duplication: when nickname already equals the branch, no marker."""
from hqt.tui.widgets.session_list import format_session_text
text = format_session_text("feature-x", "claude", "idle", "", "feature-x")
assert "" not in text
def test_format_session_text_no_marker_for_plain_session():
from hqt.tui.widgets.session_list import format_session_text
text = format_session_text("plain", "claude", "idle", "", None)
assert "" not in text
@pytest.mark.asyncio
async def test_delete_non_worktree_session_does_not_push_modal():
"""Plain (non-worktree) sessions delete immediately, with no confirm modal."""
from hqt.db.models import Harness, Project, Session
from hqt.tui.widgets.session_list import SessionList
from hqt.tui.screens.confirm_delete import ConfirmDeleteScreen
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
seed_db = app._db_factory()
proj = Project(name="del-proj", path="/tmp/del-proj")
seed_db.add(proj)
seed_db.flush()
harness = seed_db.query(Harness).first()
sess = Session(
project_id=proj.id,
harness_id=harness.id,
tmux_session_name="hqt-del",
archived=False,
)
seed_db.add(sess)
seed_db.commit()
proj_id = proj.id
sess_id = sess.id
seed_db.close()
app._selected_project_id = proj_id
await app._refresh_sessions()
await pilot.pause()
sl = app.query_one(SessionList)
sl.query_one("#session-list").focus()
await pilot.press("down")
await pilot.pause()
assert sl.get_selected_session_id() == sess_id
await app.run_action("delete_session")
await pilot.pause()
await app.workers.wait_for_complete()
await pilot.pause()
# No confirm modal was pushed; the session is gone from the DB.
assert not isinstance(app.screen, ConfirmDeleteScreen)
verify_db = app._db_factory()
assert verify_db.get(Session, sess_id) is None
verify_db.close()