feat: worktree session UI (dialog checkbox, confirm delete, list marker)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -32,6 +32,27 @@ hqt
|
|||||||
| `r` | Rename session |
|
| `r` | Rename session |
|
||||||
| `s` | Stop 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
|
## 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.
|
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
@@ -1,5 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
from collections.abc import Coroutine
|
from collections.abc import Coroutine
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
@@ -10,6 +11,7 @@ from textual.theme import Theme
|
|||||||
from textual.widgets import Footer
|
from textual.widgets import Footer
|
||||||
|
|
||||||
from hqt.config import get_settings
|
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.db.engine import ensure_db, get_engine, get_session_factory
|
||||||
from hqt.errors import ServiceError
|
from hqt.errors import ServiceError
|
||||||
from hqt.harnesses.registry import discover_harnesses, ensure_harnesses_in_db
|
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.sessions.service import SessionService
|
||||||
from hqt.tmux.manager import TmuxManager
|
from hqt.tmux.manager import TmuxManager
|
||||||
from hqt.tmux.runner import TmuxRunner
|
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.project_form import ProjectFormScreen
|
||||||
from hqt.tui.screens.new_session import NewSessionScreen
|
from hqt.tui.screens.new_session import NewSessionScreen
|
||||||
from hqt.tui.screens.rename_session import RenameSessionScreen
|
from hqt.tui.screens.rename_session import RenameSessionScreen
|
||||||
@@ -245,14 +248,25 @@ class HqtApp(App):
|
|||||||
if not harness_names:
|
if not harness_names:
|
||||||
self.notify("No harnesses found", severity="error")
|
self.notify("No harnesses found", severity="error")
|
||||||
return
|
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:
|
if result:
|
||||||
harness_name, nickname, model = result
|
harness_name, nickname, model, worktree_branch = result
|
||||||
|
|
||||||
async def _do() -> None:
|
async def _do() -> None:
|
||||||
create_result = await self._sessions().create_session(
|
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:
|
if not create_result.spawn_ok:
|
||||||
first_line = next(
|
first_line = next(
|
||||||
@@ -271,7 +285,13 @@ class HqtApp(App):
|
|||||||
|
|
||||||
self._run_service_worker(_do())
|
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:
|
def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
|
||||||
# `Binding("enter", "attach_session", priority=True)` is app-global, so
|
# `Binding("enter", "attach_session", priority=True)` is app-global, so
|
||||||
@@ -297,13 +317,55 @@ class HqtApp(App):
|
|||||||
|
|
||||||
def action_delete_session(self) -> None:
|
def action_delete_session(self) -> None:
|
||||||
sid = self.query_one(SessionList).get_selected_session_id()
|
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:
|
async def _do() -> None:
|
||||||
await self._sessions().delete_session(sid)
|
await self._sessions().delete_session(sid)
|
||||||
await self._refresh_sessions()
|
await self._refresh_sessions()
|
||||||
|
|
||||||
self._run_service_worker(_do())
|
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:
|
def action_rename_session(self) -> None:
|
||||||
sid = self.query_one(SessionList).get_selected_session_id()
|
sid = self.query_one(SessionList).get_selected_session_id()
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
from textual.app import ComposeResult
|
from textual.app import ComposeResult
|
||||||
from textual.containers import Horizontal, Vertical
|
from textual.containers import Horizontal, Vertical
|
||||||
from textual.screen import ModalScreen
|
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]):
|
class NewSessionScreen(ModalScreen[tuple[str, str, str | None, str | None] | None]):
|
||||||
def __init__(self, harness_names: list[str]) -> None:
|
def __init__(self, harness_names: list[str], is_git_repo: bool) -> None:
|
||||||
self.harness_names = harness_names
|
self.harness_names = harness_names
|
||||||
|
self._is_git_repo = is_git_repo
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
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 Input(placeholder="session nickname", id="nickname-input")
|
||||||
yield Label("Model (optional):")
|
yield Label("Model (optional):")
|
||||||
yield Input(placeholder="model name", id="model-input")
|
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"):
|
with Horizontal(classes="dialog-actions"):
|
||||||
yield Button("OK", variant="primary", id="ok-btn")
|
yield Button("OK", variant="primary", id="ok-btn")
|
||||||
yield Button("Cancel", id="cancel-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:
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
if event.button.id == "ok-btn":
|
if event.button.id == "ok-btn":
|
||||||
self._submit()
|
self._submit()
|
||||||
@@ -45,7 +75,18 @@ class NewSessionScreen(ModalScreen[tuple[str, str, str | None] | None]):
|
|||||||
harness = self.query_one("#harness-select", Select).value
|
harness = self.query_one("#harness-select", Select).value
|
||||||
nickname = self.query_one("#nickname-input", Input).value
|
nickname = self.query_one("#nickname-input", Input).value
|
||||||
model = self.query_one("#model-input", Input).value or None
|
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:
|
if harness and harness != Select.BLANK:
|
||||||
self.dismiss((str(harness), nickname, model))
|
self.dismiss((str(harness), nickname, model, worktree_branch))
|
||||||
else:
|
else:
|
||||||
self.dismiss(None)
|
self.dismiss(None)
|
||||||
|
|||||||
@@ -29,10 +29,19 @@ _STATUS_COLORS: dict[str, str] = {
|
|||||||
|
|
||||||
|
|
||||||
def format_session_text(
|
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:
|
) -> str:
|
||||||
color = _STATUS_COLORS.get(status_text, "#c6d0f5") # default: Text
|
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}"
|
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")
|
status_text = getattr(info, "status", "dead" if not info.alive else "idle")
|
||||||
symbol = status_symbol(status_text, alive=info.alive)
|
symbol = status_symbol(status_text, alive=info.alive)
|
||||||
text = format_session_text(
|
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))
|
new_items.append((f"sess-{s.id}", text, s.id))
|
||||||
|
|
||||||
|
|||||||
+259
-9
@@ -291,7 +291,9 @@ async def test_new_session_on_dismiss_sequential_create_then_refresh():
|
|||||||
|
|
||||||
call_order: list[str] = []
|
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
|
# Brief yield so that, if two separate workers were used, the list
|
||||||
# worker would be able to start and record "list" before this
|
# worker would be able to start and record "list" before this
|
||||||
# completes — exposing the regression.
|
# 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()}):
|
with patch("hqt.tui.app.discover_harnesses", return_value={"claude": object()}):
|
||||||
app.action_new_session()
|
app.action_new_session()
|
||||||
|
await app.workers.wait_for_complete()
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
|
|
||||||
# The NewSessionScreen is now on top; dismiss it with a valid result tuple.
|
# 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()
|
await pilot.pause()
|
||||||
|
|
||||||
# Wait for the worker spawned by on_dismiss to finish deterministically.
|
# 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("hqt.tui.app.discover_harnesses", return_value={"claude": object()}):
|
||||||
with patch.object(app, "notify", side_effect=capture_notify):
|
with patch.object(app, "notify", side_effect=capture_notify):
|
||||||
app.action_new_session()
|
app.action_new_session()
|
||||||
|
await app.workers.wait_for_complete()
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
|
|
||||||
app.screen.dismiss(("claude", "mynick", None))
|
app.screen.dismiss(("claude", "mynick", None, None))
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
await app.workers.wait_for_complete()
|
await app.workers.wait_for_complete()
|
||||||
await pilot.pause()
|
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("hqt.tui.app.discover_harnesses", return_value={"claude": object()}):
|
||||||
with patch.object(app, "notify", side_effect=capture_notify):
|
with patch.object(app, "notify", side_effect=capture_notify):
|
||||||
app.action_new_session()
|
app.action_new_session()
|
||||||
|
await app.workers.wait_for_complete()
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
|
|
||||||
app.screen.dismiss(("claude", "mynick", None))
|
app.screen.dismiss(("claude", "mynick", None, None))
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
await app.workers.wait_for_complete()
|
await app.workers.wait_for_complete()
|
||||||
await pilot.pause()
|
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.
|
# On the main screen the attach binding is live.
|
||||||
assert app.check_action("attach_session", ()) is True
|
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()
|
await pilot.pause()
|
||||||
|
|
||||||
# With a modal on top, the binding is disabled so Enter reaches the modal.
|
# 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 hqt.tui.screens.new_session import NewSessionScreen
|
||||||
from textual.widgets import Select
|
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()
|
await pilot.pause()
|
||||||
sel = app.screen.query_one("#harness-select", Select)
|
sel = app.screen.query_one("#harness-select", Select)
|
||||||
sel.focus()
|
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 hqt.tui.screens.new_session import NewSessionScreen
|
||||||
from textual.widgets import Select
|
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()
|
await pilot.pause()
|
||||||
sel = app.screen.query_one("#harness-select", Select)
|
sel = app.screen.query_one("#harness-select", Select)
|
||||||
sel.focus()
|
sel.focus()
|
||||||
@@ -565,7 +570,9 @@ async def test_new_session_enter_in_input_submits_dialog():
|
|||||||
from textual.widgets import Input
|
from textual.widgets import Input
|
||||||
|
|
||||||
results: list = []
|
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()
|
await pilot.pause()
|
||||||
|
|
||||||
nick = app.screen.query_one("#nickname-input", Input)
|
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.press("enter")
|
||||||
await pilot.pause()
|
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.press("k")
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
assert sl.get_selected_session_id() == session_ids[0]
|
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user