Initial commit: hqt — HQ Terminal TUI
TUI for orchestrating AI coding harness sessions (Claude Code, Codex, Kiro, etc.) via tmux. Click CLI bootstraps a Textual TUI over ProjectService/SessionService backed by SQLite, spawning harness sessions as tmux windows through TmuxManager. Includes recent fixes: - Visible Tab focus highlight on dialog OK/Cancel buttons - Auto-select first project on launch - Auto-select first session + per-project session-selection memory - tmux new-window targets an explicit free index, fixing "index N in use" failures (broken spawn/attach in attached sessions) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,808 @@
|
||||
# Project Editing + Frappé Theme Completion Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Let users edit a project's name and path from the TUI, and make the Catppuccin Frappé theme actually look like Frappé (full theme definition, styled dialogs/headers, colored status symbols).
|
||||
|
||||
**Architecture:** `ProjectService` gains an `update` method (unique-path violations surface as `ValueError`). The existing `AddProjectScreen` is generalized into `ProjectFormScreen` with optional pre-filled values, used by both the `a` (add) and new `e` (edit) bindings. The half-specified `FRAPPE_THEME` is replaced with a full definition mirroring Textual's built-in `catppuccin-mocha` structure, plus a `styles.tcss` pass and Rich-markup status colors.
|
||||
|
||||
**Tech Stack:** Python 3.12, Textual 8.x, SQLAlchemy 2.x, pytest + pytest-asyncio, `uv` for everything (`uv run pytest ...`).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-09-project-edit-frappe-theme-design.md`
|
||||
|
||||
**Known bug fixed in Task 7:** session labels currently pass `[claude]` to `Label`, which parses it as a Rich markup tag and silently swallows the harness name. Verified by experiment: `Label('x [claude] y')` renders as `'x y'`. The fix escapes the brackets.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `ProjectService.update`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/hqt/projects/service.py`
|
||||
- Test: `tests/test_services.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Add to the `TestProjectService` class in `tests/test_services.py`:
|
||||
|
||||
```python
|
||||
def test_update_name_and_path(self, db):
|
||||
svc = ProjectService(db)
|
||||
p = svc.create("old", "/old/path")
|
||||
updated = svc.update(p.id, "new", "/new/path")
|
||||
assert updated.name == "new"
|
||||
assert updated.path == "/new/path"
|
||||
assert svc.get(p.id).path == "/new/path"
|
||||
|
||||
def test_update_duplicate_path_raises(self, db):
|
||||
svc = ProjectService(db)
|
||||
svc.create("a", "/a")
|
||||
p2 = svc.create("b", "/b")
|
||||
with pytest.raises(ValueError, match="already uses path"):
|
||||
svc.update(p2.id, "b", "/a")
|
||||
# DB session must remain usable after rollback, values unchanged
|
||||
assert svc.get(p2.id).path == "/b"
|
||||
|
||||
def test_update_unknown_id_raises(self, db):
|
||||
svc = ProjectService(db)
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
svc.update(9999, "x", "/x")
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/test_services.py -v -k update`
|
||||
Expected: 3 FAILED with `AttributeError: 'ProjectService' object has no attribute 'update'`
|
||||
|
||||
- [ ] **Step 3: Implement `update`**
|
||||
|
||||
In `src/hqt/projects/service.py`, add the import at the top:
|
||||
|
||||
```python
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
```
|
||||
|
||||
Add this method to `ProjectService` (after `get`, before `archive`):
|
||||
|
||||
```python
|
||||
def update(self, project_id: int, name: str, path: str) -> Project:
|
||||
project = self.get(project_id)
|
||||
if project is None:
|
||||
raise ValueError(f"Project {project_id} not found")
|
||||
project.name = name
|
||||
project.path = path
|
||||
try:
|
||||
self.db.commit()
|
||||
except IntegrityError as err:
|
||||
self.db.rollback()
|
||||
raise ValueError(f"Another project already uses path {path}") from err
|
||||
self.db.refresh(project)
|
||||
return project
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest tests/test_services.py -v`
|
||||
Expected: all PASS (including the pre-existing tests)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/hqt/projects/service.py tests/test_services.py
|
||||
git commit -m "feat: ProjectService.update with duplicate-path ValueError"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: `ProjectFormScreen` (rename `AddProjectScreen`, add pre-fill)
|
||||
|
||||
**Files:**
|
||||
- Rename: `src/hqt/tui/screens/add_project.py` → `src/hqt/tui/screens/project_form.py`
|
||||
- Modify: `src/hqt/tui/app.py` (import at line 17, `action_add_project` at lines 107–114)
|
||||
- Test: `tests/test_tui.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Add to `tests/test_tui.py`:
|
||||
|
||||
```python
|
||||
# ---------------------------------------------------------------------------
|
||||
# ProjectFormScreen: shared add/edit form
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_project_form_prefills_initial_values():
|
||||
app = HqtApp()
|
||||
async with app.run_test(size=(120, 40)) as pilot:
|
||||
from hqt.tui.screens.project_form import ProjectFormScreen
|
||||
from textual.widgets import Input
|
||||
|
||||
app.push_screen(
|
||||
ProjectFormScreen(
|
||||
title="Edit Project",
|
||||
initial_name="myproj",
|
||||
initial_path="/tmp/myproj",
|
||||
)
|
||||
)
|
||||
await pilot.pause()
|
||||
assert app.screen.query_one("#name-input", Input).value == "myproj"
|
||||
assert app.screen.query_one("#path-input", Input).value == "/tmp/myproj"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_project_form_add_mode_defaults_name_to_basename():
|
||||
app = HqtApp()
|
||||
async with app.run_test(size=(120, 40)) as pilot:
|
||||
from hqt.tui.screens.project_form import ProjectFormScreen
|
||||
from textual.widgets import Button, Input
|
||||
|
||||
results = []
|
||||
app.push_screen(ProjectFormScreen(), results.append)
|
||||
await pilot.pause()
|
||||
app.screen.query_one("#path-input", Input).value = "/tmp/somerepo"
|
||||
app.screen.query_one("#ok-btn", Button).press()
|
||||
await pilot.pause()
|
||||
assert results == [("somerepo", "/tmp/somerepo")]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/test_tui.py -v -k project_form`
|
||||
Expected: 2 FAILED with `ModuleNotFoundError: No module named 'hqt.tui.screens.project_form'`
|
||||
|
||||
- [ ] **Step 3: Rename the file and generalize the class**
|
||||
|
||||
```bash
|
||||
git mv src/hqt/tui/screens/add_project.py src/hqt/tui/screens/project_form.py
|
||||
```
|
||||
|
||||
Replace the entire contents of `src/hqt/tui/screens/project_form.py` with:
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Vertical
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import Button, Input, Label
|
||||
|
||||
|
||||
class ProjectFormScreen(ModalScreen[tuple[str, str] | None]):
|
||||
def __init__(
|
||||
self,
|
||||
title: str = "Add Project",
|
||||
initial_name: str = "",
|
||||
initial_path: str = "",
|
||||
) -> None:
|
||||
self._form_title = title
|
||||
self._initial_name = initial_name
|
||||
self._initial_path = initial_path
|
||||
super().__init__()
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(id="project-form-dialog"):
|
||||
yield Label(self._form_title)
|
||||
yield Label("Path:")
|
||||
yield Input(
|
||||
value=self._initial_path,
|
||||
placeholder="/path/to/project",
|
||||
id="path-input",
|
||||
)
|
||||
yield Label("Name (optional):")
|
||||
yield Input(
|
||||
value=self._initial_name,
|
||||
placeholder="project name",
|
||||
id="name-input",
|
||||
)
|
||||
yield Button("OK", variant="primary", id="ok-btn")
|
||||
yield Button("Cancel", id="cancel-btn")
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
if event.button.id == "ok-btn":
|
||||
path = self.query_one("#path-input", Input).value.strip()
|
||||
name = self.query_one("#name-input", Input).value.strip()
|
||||
if path:
|
||||
if not name:
|
||||
name = Path(path).name
|
||||
self.dismiss((name, path))
|
||||
else:
|
||||
self.dismiss(None)
|
||||
else:
|
||||
self.dismiss(None)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update `app.py` to use the renamed screen**
|
||||
|
||||
In `src/hqt/tui/app.py`, replace the import:
|
||||
|
||||
```python
|
||||
from hqt.tui.screens.add_project import AddProjectScreen
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```python
|
||||
from hqt.tui.screens.project_form import ProjectFormScreen
|
||||
```
|
||||
|
||||
and in `action_add_project`, replace:
|
||||
|
||||
```python
|
||||
self.push_screen(AddProjectScreen(), on_dismiss)
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```python
|
||||
self.push_screen(ProjectFormScreen(), on_dismiss)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the full TUI test file**
|
||||
|
||||
Run: `uv run pytest tests/test_tui.py -v`
|
||||
Expected: all PASS
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add -A src/hqt/tui tests/test_tui.py
|
||||
git commit -m "refactor: generalize AddProjectScreen into ProjectFormScreen with pre-fill"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: `ProjectList.get_selected_project_id`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/hqt/tui/widgets/project_list.py`
|
||||
- Test: `tests/test_tui.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Add to `tests/test_tui.py`:
|
||||
|
||||
```python
|
||||
@pytest.mark.asyncio
|
||||
async def test_project_list_get_selected_project_id():
|
||||
app = HqtApp()
|
||||
async with app.run_test(size=(120, 40)) as pilot:
|
||||
from hqt.tui.widgets.project_list import ProjectList
|
||||
|
||||
p = app._project_service.create("selproj", "/tmp/selproj")
|
||||
app._load_projects()
|
||||
await pilot.pause()
|
||||
|
||||
pl = app.query_one(ProjectList)
|
||||
lv = pl.query_one("#project-list")
|
||||
lv.focus()
|
||||
await pilot.press("down")
|
||||
await pilot.pause()
|
||||
|
||||
assert pl.get_selected_project_id() == p.id
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `uv run pytest tests/test_tui.py::test_project_list_get_selected_project_id -v`
|
||||
Expected: FAIL with `AttributeError: 'ProjectList' object has no attribute 'get_selected_project_id'`
|
||||
|
||||
- [ ] **Step 3: Implement the method**
|
||||
|
||||
Add to `ProjectList` in `src/hqt/tui/widgets/project_list.py` (same pattern as `SessionList.get_selected_session_id`):
|
||||
|
||||
```python
|
||||
def get_selected_project_id(self) -> int | None:
|
||||
lv = self.query_one("#project-list", ListView)
|
||||
if lv.highlighted_child and hasattr(lv.highlighted_child, "data"):
|
||||
return lv.highlighted_child.data
|
||||
return None
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `uv run pytest tests/test_tui.py::test_project_list_get_selected_project_id -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/hqt/tui/widgets/project_list.py tests/test_tui.py
|
||||
git commit -m "feat: ProjectList.get_selected_project_id"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: `e` binding + `action_edit_project`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/hqt/tui/app.py` (BINDINGS at lines 42–51, new action after `action_add_project`)
|
||||
- Test: `tests/test_tui.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Add to `tests/test_tui.py`:
|
||||
|
||||
```python
|
||||
# ---------------------------------------------------------------------------
|
||||
# Edit project: binding + action
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_project_binding_exists():
|
||||
bindings = {b.key: b for b in HqtApp.BINDINGS}
|
||||
assert "e" in bindings
|
||||
assert bindings["e"].action == "edit_project"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_project_no_selection_warns():
|
||||
app = HqtApp()
|
||||
async with app.run_test(size=(120, 40)) as pilot:
|
||||
from unittest.mock import patch
|
||||
|
||||
notify_calls = []
|
||||
with patch.object(
|
||||
app, "notify", side_effect=lambda msg, **kw: notify_calls.append((msg, kw))
|
||||
):
|
||||
await app.run_action("edit_project")
|
||||
await pilot.pause()
|
||||
|
||||
assert any(
|
||||
kw.get("severity") == "warning" for _, kw in notify_calls
|
||||
), f"Expected warning notification, got: {notify_calls}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_project_opens_prefilled_form_and_updates():
|
||||
app = HqtApp()
|
||||
async with app.run_test(size=(120, 40)) as pilot:
|
||||
from textual.widgets import Input
|
||||
from hqt.tui.screens.project_form import ProjectFormScreen
|
||||
from hqt.tui.widgets.project_list import ProjectList
|
||||
|
||||
p = app._project_service.create("oldname", "/tmp/oldpath")
|
||||
app._load_projects()
|
||||
await pilot.pause()
|
||||
|
||||
pl = app.query_one(ProjectList)
|
||||
pl.query_one("#project-list").focus()
|
||||
await pilot.press("down")
|
||||
await pilot.pause()
|
||||
assert pl.get_selected_project_id() == p.id
|
||||
|
||||
await app.run_action("edit_project")
|
||||
await pilot.pause()
|
||||
|
||||
assert isinstance(app.screen, ProjectFormScreen)
|
||||
assert app.screen.query_one("#name-input", Input).value == "oldname"
|
||||
assert app.screen.query_one("#path-input", Input).value == "/tmp/oldpath"
|
||||
|
||||
app.screen.dismiss(("newname", "/tmp/newpath"))
|
||||
await pilot.pause()
|
||||
|
||||
refreshed = app._project_service.get(p.id)
|
||||
assert refreshed.name == "newname"
|
||||
assert refreshed.path == "/tmp/newpath"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_project_duplicate_path_notifies_error():
|
||||
app = HqtApp()
|
||||
async with app.run_test(size=(120, 40)) as pilot:
|
||||
from unittest.mock import patch
|
||||
from hqt.tui.widgets.project_list import ProjectList
|
||||
|
||||
app._project_service.create("first", "/tmp/first")
|
||||
p2 = app._project_service.create("second", "/tmp/second")
|
||||
app._load_projects()
|
||||
await pilot.pause()
|
||||
|
||||
pl = app.query_one(ProjectList)
|
||||
pl.query_one("#project-list").focus()
|
||||
# Two items: press down twice to land on the second project
|
||||
await pilot.press("down")
|
||||
await pilot.press("down")
|
||||
await pilot.pause()
|
||||
assert pl.get_selected_project_id() == p2.id
|
||||
|
||||
notify_calls = []
|
||||
with patch.object(
|
||||
app, "notify", side_effect=lambda msg, **kw: notify_calls.append((msg, kw))
|
||||
):
|
||||
await app.run_action("edit_project")
|
||||
await pilot.pause()
|
||||
app.screen.dismiss(("second", "/tmp/first"))
|
||||
await pilot.pause()
|
||||
|
||||
assert any(
|
||||
kw.get("severity") == "error" and "already uses path" in msg
|
||||
for msg, kw in notify_calls
|
||||
), f"Expected duplicate-path error notification, got: {notify_calls}"
|
||||
# DB unchanged
|
||||
assert app._project_service.get(p2.id).path == "/tmp/second"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/test_tui.py -v -k edit_project`
|
||||
Expected: 4 FAILED (`"e" not in bindings`; the action-based tests fail because the action doesn't exist)
|
||||
|
||||
- [ ] **Step 3: Implement binding and action**
|
||||
|
||||
In `src/hqt/tui/app.py`, add to `BINDINGS` (after the `"a"` binding):
|
||||
|
||||
```python
|
||||
Binding("e", "edit_project", "Edit Project"),
|
||||
```
|
||||
|
||||
Add this method after `action_add_project`:
|
||||
|
||||
```python
|
||||
def action_edit_project(self) -> None:
|
||||
project_id = self.query_one(ProjectList).get_selected_project_id()
|
||||
if project_id is None:
|
||||
self.notify("Select a project first", severity="warning")
|
||||
return
|
||||
project = self._project_service.get(project_id)
|
||||
if project is None:
|
||||
self.notify("Project not found", severity="error")
|
||||
return
|
||||
|
||||
def on_dismiss(result: tuple[str, str] | None) -> None:
|
||||
if result:
|
||||
name, path = result
|
||||
try:
|
||||
self._project_service.update(project_id, name, path)
|
||||
except ValueError as err:
|
||||
self.notify(str(err), severity="error")
|
||||
return
|
||||
self._load_projects()
|
||||
|
||||
self.push_screen(
|
||||
ProjectFormScreen(
|
||||
title="Edit Project",
|
||||
initial_name=project.name,
|
||||
initial_path=project.path,
|
||||
),
|
||||
on_dismiss,
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest tests/test_tui.py -v`
|
||||
Expected: all PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/hqt/tui/app.py tests/test_tui.py
|
||||
git commit -m "feat: edit project name/path via 'e' binding"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Complete the Frappé theme definition
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/hqt/tui/app.py:23-36` (`FRAPPE_THEME`)
|
||||
- Test: `tests/test_tui.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Add to `tests/test_tui.py`:
|
||||
|
||||
```python
|
||||
# ---------------------------------------------------------------------------
|
||||
# Catppuccin Frappé theme: full definition
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_frappe_theme_fully_applied():
|
||||
app = HqtApp()
|
||||
async with app.run_test() as pilot:
|
||||
assert app.current_theme.name == "catppuccin-frappe"
|
||||
variables = app.get_css_variables()
|
||||
# Spot-check: explicit values, not Textual auto-derivations
|
||||
assert variables["primary"] == "#8caaee" # Blue
|
||||
assert variables["background"] == "#292c3c" # Mantle
|
||||
assert variables["surface"] == "#414559" # Surface0
|
||||
assert variables["border"] == "#babbf1" # Lavender
|
||||
assert variables["footer-background"] == "#51576d" # Surface1
|
||||
assert variables["input-cursor-background"] == "#f2d5cf" # Rosewater
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `uv run pytest tests/test_tui.py::test_frappe_theme_fully_applied -v`
|
||||
Expected: FAIL on `variables["background"] == "#292c3c"` (currently `#303446`)
|
||||
|
||||
- [ ] **Step 3: Replace `FRAPPE_THEME`**
|
||||
|
||||
In `src/hqt/tui/app.py`, replace the entire `FRAPPE_THEME = Theme(...)` block with:
|
||||
|
||||
```python
|
||||
# Catppuccin Frappé, mirroring the structure of Textual's built-in
|
||||
# catppuccin-mocha theme so no colors are auto-derived off-palette.
|
||||
FRAPPE_THEME = Theme(
|
||||
name="catppuccin-frappe",
|
||||
primary="#8caaee", # Blue
|
||||
secondary="#ca9ee6", # Mauve
|
||||
accent="#ef9f76", # Peach
|
||||
success="#a6d189", # Green
|
||||
warning="#e5c890", # Yellow
|
||||
error="#e78284", # Red
|
||||
foreground="#c6d0f5", # Text
|
||||
background="#292c3c", # Mantle
|
||||
surface="#414559", # Surface0
|
||||
panel="#51576d", # Surface1
|
||||
dark=True,
|
||||
variables={
|
||||
"input-cursor-foreground": "#232634", # Crust
|
||||
"input-cursor-background": "#f2d5cf", # Rosewater
|
||||
"input-selection-background": "#949cbb 30%", # Overlay2 30%
|
||||
"border": "#babbf1", # Lavender
|
||||
"border-blurred": "#626880", # Surface2
|
||||
"footer-background": "#51576d", # Surface1
|
||||
"block-cursor-foreground": "#303446", # Base
|
||||
"block-cursor-text-style": "none",
|
||||
"button-color-foreground": "#292c3c", # Mantle
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest tests/test_tui.py -v`
|
||||
Expected: all PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/hqt/tui/app.py tests/test_tui.py
|
||||
git commit -m "feat: fully specify Catppuccin Frappé theme variables"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Style dialogs and panel headers in `styles.tcss`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/hqt/tui/styles.tcss`
|
||||
- Test: `tests/test_tui.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Add to `tests/test_tui.py`:
|
||||
|
||||
```python
|
||||
@pytest.mark.asyncio
|
||||
async def test_project_form_dialog_styled():
|
||||
app = HqtApp()
|
||||
async with app.run_test(size=(120, 40)) as pilot:
|
||||
from hqt.tui.screens.project_form import ProjectFormScreen
|
||||
|
||||
app.push_screen(ProjectFormScreen())
|
||||
await pilot.pause()
|
||||
dialog = app.screen.query_one("#project-form-dialog")
|
||||
assert dialog.styles.width.value == 60
|
||||
assert dialog.styles.border_top[0] == "solid"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `uv run pytest tests/test_tui.py::test_project_form_dialog_styled -v`
|
||||
Expected: FAIL (width is not 60; no border set)
|
||||
|
||||
- [ ] **Step 3: Extend the stylesheet**
|
||||
|
||||
Replace the contents of `src/hqt/tui/styles.tcss` with:
|
||||
|
||||
```css
|
||||
ProjectList {
|
||||
width: 1fr;
|
||||
min-width: 20;
|
||||
max-width: 30;
|
||||
dock: left;
|
||||
border-right: solid $primary;
|
||||
}
|
||||
|
||||
SessionList {
|
||||
width: 3fr;
|
||||
}
|
||||
|
||||
#project-header, #session-header {
|
||||
width: 100%;
|
||||
text-style: bold;
|
||||
background: $surface;
|
||||
}
|
||||
|
||||
ProjectFormScreen, NewSessionScreen {
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
#project-form-dialog, #new-session-dialog {
|
||||
width: 60;
|
||||
height: auto;
|
||||
padding: 1 2;
|
||||
background: $surface;
|
||||
border: solid $border;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest tests/test_tui.py -v`
|
||||
Expected: all PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/hqt/tui/styles.tcss tests/test_tui.py
|
||||
git commit -m "feat: style modal dialogs and panel headers with Frappé palette"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Colored status symbols + harness-name markup bug fix
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/hqt/tui/widgets/session_list.py`
|
||||
- Test: `tests/test_tui.py`
|
||||
|
||||
Context: labels are Rich markup. The current f-string interpolates `[{s.harness.name}]`, which Rich parses as a markup tag and **silently drops** — the harness name is invisible in the running app today. The new formatting helper escapes user-derived text and adds a Frappé color tag around the status symbol.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Add to `tests/test_tui.py`:
|
||||
|
||||
```python
|
||||
# ---------------------------------------------------------------------------
|
||||
# Status symbol colors + harness-name markup escape
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_format_session_text_colors_and_escapes():
|
||||
from hqt.tui.widgets.session_list import format_session_text
|
||||
|
||||
text = format_session_text("mywork", "claude", "working", "◐")
|
||||
assert text.startswith("[#a6d189]◐[/]") # Green symbol
|
||||
assert "\\[claude]" in text # escaped, so brackets render
|
||||
assert "working" in text
|
||||
|
||||
|
||||
def test_format_session_text_status_colors():
|
||||
from hqt.tui.widgets.session_list import format_session_text
|
||||
|
||||
assert format_session_text("x", "h", "waiting", "◉").startswith("[#e5c890]") # Yellow
|
||||
assert format_session_text("x", "h", "idle", "●").startswith("[#81c8be]") # Teal
|
||||
assert format_session_text("x", "h", "active", "●").startswith("[#81c8be]") # Teal
|
||||
assert format_session_text("x", "h", "dead", "○").startswith("[#737994]") # Overlay0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_list_renders_harness_name_brackets():
|
||||
"""Regression: '[claude]' must be visible, not swallowed as a markup tag."""
|
||||
app = HqtApp()
|
||||
async with app.run_test(size=(120, 40)) as pilot:
|
||||
from textual.widgets import Label
|
||||
from hqt.tui.widgets.session_list import SessionList
|
||||
|
||||
sl = app.query_one(SessionList)
|
||||
infos = [_make_session_info(1, "mywork", "claude", "working", True)]
|
||||
await sl.refresh_sessions(infos)
|
||||
await pilot.pause()
|
||||
|
||||
lv = sl.query_one("#session-list")
|
||||
texts = [str(child.query_one(Label).render()) for child in lv.children]
|
||||
assert any("[claude]" in t for t in texts), f"harness name missing: {texts}"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/test_tui.py -v -k "format_session_text or renders_harness"`
|
||||
Expected: 2 FAILED with `ImportError: cannot import name 'format_session_text'`, 1 FAILED on the brackets assertion
|
||||
|
||||
- [ ] **Step 3: Implement the formatting helper and use it**
|
||||
|
||||
In `src/hqt/tui/widgets/session_list.py`, add at the top:
|
||||
|
||||
```python
|
||||
from rich.markup import escape
|
||||
```
|
||||
|
||||
Add at module level (above the `SessionList` class, next to where `_STATUS_SYMBOLS` will remain inside the class):
|
||||
|
||||
```python
|
||||
_STATUS_COLORS: dict[str, str] = {
|
||||
"working": "#a6d189", # Green
|
||||
"waiting": "#e5c890", # Yellow
|
||||
"active": "#81c8be", # Teal
|
||||
"idle": "#81c8be", # Teal
|
||||
"dead": "#737994", # Overlay0
|
||||
}
|
||||
|
||||
|
||||
def format_session_text(
|
||||
nickname: str, harness_name: str, status_text: str, symbol: str
|
||||
) -> str:
|
||||
color = _STATUS_COLORS.get(status_text, "#c6d0f5") # default: Text
|
||||
label = escape(f"{nickname} [{harness_name}]")
|
||||
return f"[{color}]{symbol}[/] {label} {status_text}"
|
||||
```
|
||||
|
||||
In `SessionList.refresh_sessions`, replace:
|
||||
|
||||
```python
|
||||
text = f"{symbol} {s.nickname or s.tmux_session_name} [{s.harness.name}] {status_text}"
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```python
|
||||
text = format_session_text(
|
||||
s.nickname or s.tmux_session_name, s.harness.name, status_text, symbol
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the full TUI test file**
|
||||
|
||||
Run: `uv run pytest tests/test_tui.py -v`
|
||||
Expected: all PASS — including the pre-existing `test_session_list_label_includes_status`, which reads plain rendered text and is unaffected by markup
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/hqt/tui/widgets/session_list.py tests/test_tui.py
|
||||
git commit -m "feat: Frappé-colored status symbols; fix harness name swallowed by markup"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Full suite + visual verification
|
||||
|
||||
**Files:**
|
||||
- None modified (verification only)
|
||||
|
||||
- [ ] **Step 1: Run the entire test suite**
|
||||
|
||||
Run: `uv run pytest`
|
||||
Expected: all tests PASS, no warnings about unknown CSS
|
||||
|
||||
- [ ] **Step 2: Render a headless screenshot and inspect it**
|
||||
|
||||
```bash
|
||||
uv run python - <<'EOF'
|
||||
import asyncio, tempfile
|
||||
from pathlib import Path
|
||||
import hqt.config as config
|
||||
|
||||
tmp = Path(tempfile.mkdtemp())
|
||||
config._settings = config.Settings(db_path=tmp/'hqt.db', config_dir=tmp, skills_dir=tmp/'skills')
|
||||
|
||||
from hqt.tui.app import HqtApp
|
||||
|
||||
async def main():
|
||||
app = HqtApp()
|
||||
async with app.run_test(size=(100, 30)) as pilot:
|
||||
app._project_service.create("demo-project", "/tmp/demo")
|
||||
app._load_projects()
|
||||
await pilot.pause()
|
||||
Path('/tmp/hqt_themed.svg').write_text(app.export_screenshot())
|
||||
|
||||
asyncio.run(main())
|
||||
EOF
|
||||
rsvg-convert -o /tmp/hqt_themed.png /tmp/hqt_themed.svg
|
||||
```
|
||||
|
||||
Then view `/tmp/hqt_themed.png` (Read tool or image viewer). Check: Mantle background (`#292c3c`), Surface0 panels, Lavender borders, Blue selection highlight, bold panel headers. Repeat with `app.push_screen(ProjectFormScreen())` before the screenshot to verify the dialog styling.
|
||||
|
||||
- [ ] **Step 3: Final commit if any fixes were needed**
|
||||
|
||||
If the screenshot revealed fixes, commit them:
|
||||
|
||||
```bash
|
||||
git add -A && git commit -m "fix: theme polish from visual verification"
|
||||
```
|
||||
@@ -0,0 +1,550 @@
|
||||
# Session Attach Simplification + Rename Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Remove the redundant `Shift+R` Resume binding (Enter/attach already auto-resumes) and add an `r` Rename action that edits a session's display nickname.
|
||||
|
||||
**Architecture:** Resume is pure deletion — `attach_session()` already respawns dead/gone windows before attaching. Rename is a synchronous DB write to `Session.nickname`; the existing 3-second poll (`sync_window_labels`) propagates the new label to tmux. A new modal `RenameSessionScreen` mirrors the existing `ProjectFormScreen` pattern.
|
||||
|
||||
**Tech Stack:** Python, Textual (TUI), SQLAlchemy, pytest + pytest-asyncio.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- **Modify** `src/hqt/sessions/service.py` — remove `resume_session()`; add `rename_session()` and `get_session()`.
|
||||
- **Modify** `src/hqt/tui/app.py` — remove the Resume binding + `action_resume_session()`; add the Rename binding + `action_rename_session()`.
|
||||
- **Create** `src/hqt/tui/screens/rename_session.py` — `RenameSessionScreen` modal.
|
||||
- **Modify** `tests/test_tui.py` — delete the resume keypress test; add rename-modal + rename-action tests.
|
||||
- **Modify** `tests/test_sessions.py` — delete four `resume_session` tests; add `rename_session`/`get_session` tests (reusing existing fixtures).
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Remove Resume from the service
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/hqt/sessions/service.py:143-150` (the `resume_session` method)
|
||||
- Modify: `tests/test_sessions.py` (delete four `resume_session` tests)
|
||||
|
||||
- [ ] **Step 1: Inventory every reference**
|
||||
|
||||
Run: `grep -rn "resume_session" src/ tests/`
|
||||
Expected references: the method in `src/hqt/sessions/service.py`; `action_resume_session` in `src/hqt/tui/app.py`; `test_resume_keypress_triggers_resume` in `tests/test_tui.py`; and four tests in `tests/test_sessions.py` — `test_resume_session_passes_env`, `test_resume_session_resume_ok_attaches`, `test_resume_session_resume_fails_fallback_spawn`, `test_resume_session_both_rungs_fail_returns_false`. No other production callers.
|
||||
|
||||
Coverage note: `_respawn_with_fallback`'s rung-1/rung-2 behavior is independently covered by `test_attach_session_dead_resume_ok_attaches_once`, `test_attach_session_dead_resume_fails_fallback_spawn`, and `test_attach_session_both_rungs_fail_returns_false`, so deleting the resume tests loses no fallback-ladder coverage.
|
||||
|
||||
- [ ] **Step 2: Delete the four `resume_session` tests in `tests/test_sessions.py`**
|
||||
|
||||
Remove these four async test functions in full (including decorators and docstrings): `test_resume_session_passes_env`, `test_resume_session_resume_ok_attaches`, `test_resume_session_resume_fails_fallback_spawn`, `test_resume_session_both_rungs_fail_returns_false`. Leave all `test_attach_session_*` tests intact.
|
||||
|
||||
- [ ] **Step 3: Confirm the resume tests are gone but attach tests remain**
|
||||
|
||||
Run: `grep -n "resume_session\|test_attach_session" tests/test_sessions.py`
|
||||
Expected: no `resume_session` matches; the three `test_attach_session_*` tests still listed.
|
||||
|
||||
- [ ] **Step 4: Delete the `resume_session` method**
|
||||
|
||||
In `src/hqt/sessions/service.py`, remove this method entirely (currently lines 143-150):
|
||||
|
||||
```python
|
||||
async def resume_session(self, session_id: int) -> bool:
|
||||
"""Force-restart the harness in this session's window."""
|
||||
sess = self.db.get(Session, session_id)
|
||||
window_name = sess.tmux_session_name
|
||||
ok = await self._respawn_with_fallback(sess, window_name)
|
||||
if not ok:
|
||||
return False
|
||||
return await self.tmux.attach(window_name)
|
||||
```
|
||||
|
||||
Leave `_respawn_with_fallback()` and `attach_session()` untouched — attach depends on the fallback ladder.
|
||||
|
||||
- [ ] **Step 5: Verify nothing in the service references the removed method**
|
||||
|
||||
Run: `grep -n "resume_session" src/hqt/sessions/service.py`
|
||||
Expected: no output.
|
||||
|
||||
- [ ] **Step 6: Run the service tests**
|
||||
|
||||
Run: `python -m pytest tests/test_sessions.py -q`
|
||||
Expected: PASS — resume tests removed, attach/fallback tests still green.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/hqt/sessions/service.py tests/test_sessions.py
|
||||
git commit -m "refactor: drop redundant resume_session (attach auto-resumes)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Remove Resume from the app + its test
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/hqt/tui/app.py:62-68` (BINDINGS) and `src/hqt/tui/app.py:235-243` (`action_resume_session`)
|
||||
- Modify: `tests/test_tui.py:822-871` (delete `test_resume_keypress_triggers_resume`)
|
||||
|
||||
- [ ] **Step 1: Delete the resume test first**
|
||||
|
||||
In `tests/test_tui.py`, remove the section header comment block and the entire `test_resume_keypress_triggers_resume` test (currently lines 822-871), including its `@pytest.mark.parametrize` decorator.
|
||||
|
||||
- [ ] **Step 2: Run the suite to confirm only the deleted test is gone**
|
||||
|
||||
Run: `python -m pytest tests/test_tui.py -q`
|
||||
Expected: PASS — the resume test no longer collected; the action still exists so nothing else breaks yet.
|
||||
|
||||
- [ ] **Step 3: Remove the Resume binding**
|
||||
|
||||
In `src/hqt/tui/app.py`, delete these lines from `BINDINGS` (currently lines 63-66):
|
||||
|
||||
```python
|
||||
# A normal terminal sends the character "R" for Shift+R; only the Kitty
|
||||
# keyboard protocol emits "shift+r". Bind both so Resume fires either way
|
||||
# (binding "shift+r" alone never matches on a standard terminal).
|
||||
Binding("R,shift+r", "resume_session", "Resume"),
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Remove `action_resume_session`**
|
||||
|
||||
In `src/hqt/tui/app.py`, delete this method (currently lines 235-243):
|
||||
|
||||
```python
|
||||
def action_resume_session(self) -> None:
|
||||
sid = self.query_one(SessionList).get_selected_session_id()
|
||||
if sid:
|
||||
async def _do() -> None:
|
||||
ok = await self._session_service.resume_session(sid)
|
||||
if not ok:
|
||||
self.notify("Failed to resume session", severity="error")
|
||||
await self._refresh_sessions()
|
||||
self.run_worker(_do())
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Confirm no lingering references**
|
||||
|
||||
Run: `grep -rn "resume_session\|Resume" src/hqt/tui/app.py`
|
||||
Expected: no output.
|
||||
|
||||
- [ ] **Step 6: Run the full suite**
|
||||
|
||||
Run: `python -m pytest tests/test_tui.py -q`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/hqt/tui/app.py tests/test_tui.py
|
||||
git commit -m "refactor: remove Shift+R Resume binding and action"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Add `rename_session` + `get_session` to the service
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/hqt/sessions/service.py` (add two methods to `SessionService`)
|
||||
- Test: `tests/test_sessions.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
`tests/test_sessions.py` already exists with shared `db`, `tmux`, `harnesses`, and `service` fixtures (the `db` fixture seeds one `Harness(name="claude-code")` and one `Project` with id 1). Reuse them — do NOT add new setup helpers. Append a small builder and the tests:
|
||||
|
||||
```python
|
||||
def _seed_session(db, nickname=None):
|
||||
"""Create a session for project 1 / harness 'claude-code' (seeded by the db fixture)."""
|
||||
from hqt.db.models import Harness, Session
|
||||
|
||||
harness = db.query(Harness).filter_by(name="claude-code").first()
|
||||
sess = Session(
|
||||
project_id=1,
|
||||
harness_id=harness.id,
|
||||
nickname=nickname,
|
||||
tmux_session_name="hqt-rename",
|
||||
archived=False,
|
||||
)
|
||||
db.add(sess)
|
||||
db.commit()
|
||||
return sess
|
||||
|
||||
|
||||
def test_rename_session_sets_nickname(service, db):
|
||||
from hqt.db.models import Session
|
||||
|
||||
sess = _seed_session(db, nickname="old")
|
||||
service.rename_session(sess.id, "new-name")
|
||||
assert db.get(Session, sess.id).nickname == "new-name"
|
||||
|
||||
|
||||
def test_rename_session_empty_clears_to_none(service, db):
|
||||
from hqt.db.models import Session
|
||||
|
||||
sess = _seed_session(db, nickname="old")
|
||||
service.rename_session(sess.id, "")
|
||||
assert db.get(Session, sess.id).nickname is None
|
||||
|
||||
|
||||
def test_rename_session_whitespace_clears_to_none(service, db):
|
||||
from hqt.db.models import Session
|
||||
|
||||
sess = _seed_session(db, nickname="old")
|
||||
service.rename_session(sess.id, " ")
|
||||
assert db.get(Session, sess.id).nickname is None
|
||||
|
||||
|
||||
def test_get_session_returns_row(service, db):
|
||||
sess = _seed_session(db, nickname="x")
|
||||
assert service.get_session(sess.id).id == sess.id
|
||||
|
||||
|
||||
def test_get_session_missing_returns_none(service):
|
||||
assert service.get_session(99999) is None
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests to verify they fail**
|
||||
|
||||
Run: `python -m pytest tests/test_sessions.py -k "rename_session or get_session" -q`
|
||||
Expected: FAIL with `AttributeError: 'SessionService' object has no attribute 'rename_session'` (and `get_session`).
|
||||
|
||||
- [ ] **Step 3: Implement both methods**
|
||||
|
||||
In `src/hqt/sessions/service.py`, add these methods to `SessionService` (place them next to `delete_session`):
|
||||
|
||||
```python
|
||||
def get_session(self, session_id: int) -> Session | None:
|
||||
"""Return the session row, or None if it does not exist."""
|
||||
return self.db.get(Session, session_id)
|
||||
|
||||
def rename_session(self, session_id: int, nickname: str | None) -> None:
|
||||
"""Update a session's display nickname.
|
||||
|
||||
An empty/blank nickname clears it to None, so the label falls back to
|
||||
the tmux window name. The tmux window label is refreshed by the next
|
||||
poll (sync_window_labels), so no immediate tmux call is needed here.
|
||||
"""
|
||||
sess = self.db.get(Session, session_id)
|
||||
sess.nickname = (nickname or "").strip() or None
|
||||
self.db.commit()
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the tests to verify they pass**
|
||||
|
||||
Run: `python -m pytest tests/test_sessions.py -k "rename_session or get_session" -q`
|
||||
Expected: PASS (5 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/hqt/sessions/service.py tests/test_sessions.py
|
||||
git commit -m "feat: add rename_session and get_session to SessionService"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Create the RenameSessionScreen modal
|
||||
|
||||
**Files:**
|
||||
- Create: `src/hqt/tui/screens/rename_session.py`
|
||||
- Test: `tests/test_tui.py` (append)
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Append to `tests/test_tui.py`:
|
||||
|
||||
```python
|
||||
# ---------------------------------------------------------------------------
|
||||
# RenameSessionScreen: prefill + submit
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_screen_prefills_current_nickname():
|
||||
app = HqtApp()
|
||||
async with app.run_test(size=(120, 40)) as pilot:
|
||||
from hqt.tui.screens.rename_session import RenameSessionScreen
|
||||
from textual.widgets import Input
|
||||
|
||||
app.push_screen(RenameSessionScreen(initial_nickname="mywork"))
|
||||
await pilot.pause()
|
||||
assert app.screen.query_one("#nickname-input", Input).value == "mywork"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_screen_ok_returns_stripped_value():
|
||||
app = HqtApp()
|
||||
async with app.run_test(size=(120, 40)) as pilot:
|
||||
from hqt.tui.screens.rename_session import RenameSessionScreen
|
||||
from textual.widgets import Button, Input
|
||||
|
||||
results = []
|
||||
app.push_screen(RenameSessionScreen(initial_nickname=""), results.append)
|
||||
await pilot.pause()
|
||||
app.screen.query_one("#nickname-input", Input).value = " renamed "
|
||||
app.screen.query_one("#ok-btn", Button).press()
|
||||
await pilot.pause()
|
||||
assert results == ["renamed"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_screen_cancel_returns_none():
|
||||
app = HqtApp()
|
||||
async with app.run_test(size=(120, 40)) as pilot:
|
||||
from hqt.tui.screens.rename_session import RenameSessionScreen
|
||||
from textual.widgets import Button
|
||||
|
||||
results = []
|
||||
app.push_screen(RenameSessionScreen(initial_nickname="x"), results.append)
|
||||
await pilot.pause()
|
||||
app.screen.query_one("#cancel-btn", Button).press()
|
||||
await pilot.pause()
|
||||
assert results == [None]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests to verify they fail**
|
||||
|
||||
Run: `python -m pytest tests/test_tui.py -k rename_screen -q`
|
||||
Expected: FAIL with `ModuleNotFoundError: No module named 'hqt.tui.screens.rename_session'`.
|
||||
|
||||
- [ ] **Step 3: Implement the modal**
|
||||
|
||||
Create `src/hqt/tui/screens/rename_session.py`:
|
||||
|
||||
```python
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import Button, Input, Label
|
||||
|
||||
|
||||
class RenameSessionScreen(ModalScreen[str | None]):
|
||||
"""Rename a session's display nickname.
|
||||
|
||||
Dismisses with the stripped nickname on OK, or None on Cancel. Mirrors
|
||||
ProjectFormScreen's structure so it picks up the same dialog styling
|
||||
(#project-form-dialog rules are reused via the shared id).
|
||||
"""
|
||||
|
||||
def __init__(self, initial_nickname: str = "") -> None:
|
||||
self._initial_nickname = initial_nickname
|
||||
super().__init__()
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(id="project-form-dialog"):
|
||||
yield Label("Rename Session")
|
||||
yield Label("Name:")
|
||||
yield Input(
|
||||
value=self._initial_nickname,
|
||||
placeholder="session name",
|
||||
id="nickname-input",
|
||||
)
|
||||
with Horizontal(classes="dialog-actions"):
|
||||
yield Button("OK", variant="primary", id="ok-btn")
|
||||
yield Button("Cancel", id="cancel-btn")
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
if event.button.id == "ok-btn":
|
||||
self.dismiss(self.query_one("#nickname-input", Input).value.strip())
|
||||
else:
|
||||
self.dismiss(None)
|
||||
|
||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||
# Enter in the text field confirms, matching the New Session dialog.
|
||||
self.dismiss(self.query_one("#nickname-input", Input).value.strip())
|
||||
```
|
||||
|
||||
Note: reusing `id="project-form-dialog"` is intentional — it inherits the existing dialog styling in `styles.tcss` (width 60, border, `$surface` fill) verified by `test_project_form_dialog_styled`. No new CSS needed.
|
||||
|
||||
- [ ] **Step 4: Run the tests to verify they pass**
|
||||
|
||||
Run: `python -m pytest tests/test_tui.py -k rename_screen -q`
|
||||
Expected: PASS (3 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/hqt/tui/screens/rename_session.py tests/test_tui.py
|
||||
git commit -m "feat: add RenameSessionScreen modal"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Wire the `r` Rename binding + action into the app
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/hqt/tui/app.py` (BINDINGS, import, new action)
|
||||
- Test: `tests/test_tui.py` (append)
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Append to `tests/test_tui.py`:
|
||||
|
||||
```python
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rename session: binding + action
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_session_binding_exists():
|
||||
bindings = {b.key: b for b in HqtApp.BINDINGS}
|
||||
assert "r" in bindings
|
||||
assert bindings["r"].action == "rename_session"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_session_no_selection_warns():
|
||||
app = HqtApp()
|
||||
async with app.run_test(size=(120, 40)) as pilot:
|
||||
from unittest.mock import patch
|
||||
|
||||
notify_calls = []
|
||||
with patch.object(
|
||||
app, "notify", side_effect=lambda msg, **kw: notify_calls.append((msg, kw))
|
||||
):
|
||||
await app.run_action("rename_session")
|
||||
await pilot.pause()
|
||||
|
||||
assert any(
|
||||
kw.get("severity") == "warning" for _, kw in notify_calls
|
||||
), f"Expected warning notification, got: {notify_calls}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_session_opens_prefilled_and_updates():
|
||||
app = HqtApp()
|
||||
async with app.run_test(size=(120, 40)) as pilot:
|
||||
from hqt.db.models import Project, Harness, Session
|
||||
from hqt.tui.widgets.session_list import SessionList
|
||||
from hqt.tui.screens.rename_session import RenameSessionScreen
|
||||
from textual.widgets import Input
|
||||
|
||||
proj = Project(name="ren-proj", path="/tmp/ren-proj")
|
||||
app._db_session.add(proj)
|
||||
app._db_session.flush()
|
||||
harness = app._db_session.query(Harness).first()
|
||||
sess = Session(
|
||||
project_id=proj.id,
|
||||
harness_id=harness.id,
|
||||
nickname="before",
|
||||
tmux_session_name="hqt-ren",
|
||||
archived=False,
|
||||
)
|
||||
app._db_session.add(sess)
|
||||
app._db_session.commit()
|
||||
|
||||
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("rename_session")
|
||||
await pilot.pause()
|
||||
|
||||
assert isinstance(app.screen, RenameSessionScreen)
|
||||
assert app.screen.query_one("#nickname-input", Input).value == "before"
|
||||
|
||||
app.screen.dismiss("after")
|
||||
await pilot.pause()
|
||||
await app.workers.wait_for_complete()
|
||||
await pilot.pause()
|
||||
|
||||
assert app._db_session.get(Session, sess.id).nickname == "after"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests to verify they fail**
|
||||
|
||||
Run: `python -m pytest tests/test_tui.py -k rename_session -q`
|
||||
Expected: FAIL — `"r"` not in BINDINGS / no `rename_session` action.
|
||||
|
||||
- [ ] **Step 3: Add the import**
|
||||
|
||||
In `src/hqt/tui/app.py`, add next to the other screen imports (near line 19):
|
||||
|
||||
```python
|
||||
from hqt.tui.screens.rename_session import RenameSessionScreen
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the binding**
|
||||
|
||||
In `src/hqt/tui/app.py` `BINDINGS`, add this entry (e.g. after the `delete_session` binding):
|
||||
|
||||
```python
|
||||
Binding("r", "rename_session", "Rename"),
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add the action**
|
||||
|
||||
In `src/hqt/tui/app.py`, add this method (e.g. after `action_attach_session`):
|
||||
|
||||
```python
|
||||
def action_rename_session(self) -> None:
|
||||
sid = self.query_one(SessionList).get_selected_session_id()
|
||||
if sid is None:
|
||||
self.notify("Select a session first", severity="warning")
|
||||
return
|
||||
sess = self._session_service.get_session(sid)
|
||||
if sess is None:
|
||||
self.notify("Session not found", severity="error")
|
||||
return
|
||||
|
||||
def on_dismiss(result: str | None) -> None:
|
||||
if result is not None:
|
||||
self._session_service.rename_session(sid, result)
|
||||
self.run_worker(self._refresh_sessions())
|
||||
|
||||
self.push_screen(
|
||||
RenameSessionScreen(initial_nickname=sess.nickname or ""),
|
||||
on_dismiss,
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run the tests to verify they pass**
|
||||
|
||||
Run: `python -m pytest tests/test_tui.py -k rename_session -q`
|
||||
Expected: PASS (3 tests).
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/hqt/tui/app.py tests/test_tui.py
|
||||
git commit -m "feat: add 'r' rename-session binding and action"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Full verification + docs
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md` (if it documents keybindings — check first)
|
||||
|
||||
- [ ] **Step 1: Run the entire test suite**
|
||||
|
||||
Run: `python -m pytest -q`
|
||||
Expected: PASS, no failures, no reference to resume.
|
||||
|
||||
- [ ] **Step 2: Update keybinding docs if present**
|
||||
|
||||
Run: `grep -rn "Resume\|Shift+R\|shift+r" README.md`
|
||||
If matches exist, replace the Resume entry with a Rename (`r`) entry and note that attach (Enter) now auto-resumes. If no matches, skip.
|
||||
|
||||
- [ ] **Step 3: Commit any doc change**
|
||||
|
||||
```bash
|
||||
git add README.md
|
||||
git commit -m "docs: replace Resume keybinding with Rename ('r')"
|
||||
```
|
||||
|
||||
(Skip this commit if README needed no change.)
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Notes
|
||||
|
||||
- **Spec coverage:** Change 1 (remove Resume) → Tasks 1-2. Change 2 (rename: service, modal, wiring) → Tasks 3-5. Testing section → tests embedded in every task + Task 6. Out-of-scope items (no `tmux_session_name` change, no migration) are respected — only `nickname` is written.
|
||||
- **Empty-input → None:** handled in `rename_session` (`(nickname or "").strip() or None`) and covered by `test_rename_session_empty_clears_to_none`.
|
||||
- **Type consistency:** `get_session(session_id) -> Session | None`, `rename_session(session_id, nickname)`, and `RenameSessionScreen(initial_nickname=...)` are used identically across the service, app action, and tests.
|
||||
- **Label propagation:** no immediate tmux call; the existing `sync_window_labels` poll (app.py `set_interval(3, ...)`) reads the new nickname — consistent with the approved design.
|
||||
@@ -0,0 +1,158 @@
|
||||
# Project Editing + Catppuccin Frappé Theme Completion
|
||||
|
||||
**Date:** 2026-06-09
|
||||
**Status:** Approved
|
||||
|
||||
## Problem
|
||||
|
||||
1. Projects cannot be edited after creation. If a repository moves on disk, the
|
||||
only options are archiving the project or editing the database by hand.
|
||||
2. The TUI registers a Catppuccin Frappé theme (`FRAPPE_THEME` in
|
||||
`src/hqt/tui/app.py`) but only sets the basic semantic fields. Textual
|
||||
auto-derives every other color (footer keys, borders, selection highlights),
|
||||
which drifts off-palette, and the modal dialogs, panel headers, and status
|
||||
symbols are entirely unstyled. The result does not look like Frappé.
|
||||
|
||||
## Part 1: Project editing
|
||||
|
||||
### Service layer
|
||||
|
||||
Add to `ProjectService` (`src/hqt/projects/service.py`):
|
||||
|
||||
```python
|
||||
def update(self, project_id: int, name: str, path: str) -> Project
|
||||
```
|
||||
|
||||
- Updates both fields and commits.
|
||||
- Raises `ValueError("Project not found")` for an unknown id.
|
||||
- A duplicate path violates the unique constraint on `Project.path`: catch
|
||||
`IntegrityError`, roll back, raise `ValueError` with a readable message
|
||||
(e.g. `"Another project already uses path <path>"`).
|
||||
- No filesystem validation (consistent with `create`, which accepts any
|
||||
string).
|
||||
|
||||
### Form screen
|
||||
|
||||
Rename `AddProjectScreen` (`src/hqt/tui/screens/add_project.py`) to
|
||||
`ProjectFormScreen`:
|
||||
|
||||
- Constructor: `ProjectFormScreen(title="Add Project", initial_name="",
|
||||
initial_path="")`.
|
||||
- The dialog heading label shows `title`; inputs are pre-filled with the
|
||||
initial values.
|
||||
- Return type unchanged: `tuple[str, str] | None` of `(name, path)`.
|
||||
- Add-mode behavior unchanged: empty name defaults to `Path(path).name`;
|
||||
empty path dismisses with `None`.
|
||||
|
||||
### Project list
|
||||
|
||||
Add `ProjectList.get_selected_project_id() -> int | None`
|
||||
(`src/hqt/tui/widgets/project_list.py`), same pattern as
|
||||
`SessionList.get_selected_session_id()`: read `data` off the ListView's
|
||||
highlighted child.
|
||||
|
||||
### App wiring
|
||||
|
||||
In `HqtApp` (`src/hqt/tui/app.py`):
|
||||
|
||||
- New binding: `Binding("e", "edit_project", "Edit Project")`.
|
||||
- `action_edit_project`:
|
||||
- No project highlighted → `notify("Select a project first",
|
||||
severity="warning")` and return.
|
||||
- Otherwise push `ProjectFormScreen(title="Edit Project",
|
||||
initial_name=project.name, initial_path=project.path)`.
|
||||
- On dismiss with a result: call `ProjectService.update`, refresh the
|
||||
project list. Catch `ValueError` and show its message as an error
|
||||
notification.
|
||||
- `action_add_project` switches to the renamed `ProjectFormScreen` with
|
||||
defaults; behavior otherwise unchanged.
|
||||
|
||||
### Session semantics
|
||||
|
||||
Editing a path never touches tmux. Running sessions keep the working
|
||||
directory they were spawned with; only sessions created after the edit use
|
||||
the new path. This matches archive semantics (DB-only operation).
|
||||
|
||||
## Part 2: Frappé theme completion
|
||||
|
||||
### Theme definition
|
||||
|
||||
Replace `FRAPPE_THEME` with a fully specified `Theme`, mirroring the
|
||||
structure of Textual's built-in `catppuccin-mocha` theme with Frappé values:
|
||||
|
||||
| Field | Frappé color | Hex |
|
||||
|--------------|--------------|-----------|
|
||||
| `primary` | Blue | `#8caaee` |
|
||||
| `secondary` | Mauve | `#ca9ee6` |
|
||||
| `accent` | Peach | `#ef9f76` |
|
||||
| `success` | Green | `#a6d189` |
|
||||
| `warning` | Yellow | `#e5c890` |
|
||||
| `error` | Red | `#e78284` |
|
||||
| `foreground` | Text | `#c6d0f5` |
|
||||
| `background` | Mantle | `#292c3c` |
|
||||
| `surface` | Surface0 | `#414559` |
|
||||
| `panel` | Surface1 | `#51576d` |
|
||||
| `dark` | — | `True` |
|
||||
|
||||
`variables` dict (same keys the built-in Mocha theme sets):
|
||||
|
||||
| Variable | Frappé color | Value |
|
||||
|------------------------------|---------------|----------------|
|
||||
| `input-cursor-foreground` | Crust | `#232634` |
|
||||
| `input-cursor-background` | Rosewater | `#f2d5cf` |
|
||||
| `input-selection-background` | Overlay2 30% | `#949cbb 30%` |
|
||||
| `border` | Lavender | `#babbf1` |
|
||||
| `border-blurred` | Surface2 | `#626880` |
|
||||
| `footer-background` | Surface1 | `#51576d` |
|
||||
| `block-cursor-foreground` | Base | `#303446` |
|
||||
| `block-cursor-text-style` | — | `none` |
|
||||
| `button-color-foreground` | Mantle | `#292c3c` |
|
||||
|
||||
Note: `background` moves from Base to Mantle to match how the built-in
|
||||
Catppuccin themes layer surfaces — panels (Surface0/1) then sit visibly
|
||||
above the background.
|
||||
|
||||
### Widget styling (`src/hqt/tui/styles.tcss`)
|
||||
|
||||
- Keep the existing `ProjectList`/`SessionList` layout rules.
|
||||
- Modal dialogs: `ProjectFormScreen` renames its container id to
|
||||
`#project-form-dialog`; `NewSessionScreen` keeps `#new-session-dialog`.
|
||||
Both get: centered (`align: center middle` on the modal screen), fixed
|
||||
width (~60 cells), `$surface` background, Lavender border, padding.
|
||||
- Panel headers (`#project-header`, `#session-header`): bold text on
|
||||
`$surface` background.
|
||||
|
||||
### Status symbol colors (`src/hqt/tui/widgets/session_list.py`)
|
||||
|
||||
Color the status symbol via Rich markup with Frappé hex literals:
|
||||
|
||||
| Status | Frappé color | Hex |
|
||||
|-----------|--------------|-----------|
|
||||
| `working` | Green | `#a6d189` |
|
||||
| `waiting` | Yellow | `#e5c890` |
|
||||
| `active` | Teal | `#81c8be` |
|
||||
| `idle` | Teal | `#81c8be` |
|
||||
| `dead` | Overlay0 | `#737994` |
|
||||
|
||||
## Testing
|
||||
|
||||
- **Service** (`tests/test_services.py`): `update` renames and repaths;
|
||||
duplicate path raises `ValueError` and leaves the DB session usable
|
||||
(rollback); unknown id raises `ValueError`.
|
||||
- **TUI** (pilot tests, same style as `tests/test_tui.py`):
|
||||
- Pressing `e` with a project highlighted opens the form pre-filled with
|
||||
that project's name and path.
|
||||
- Submitting the edit form updates the DB and refreshes the list.
|
||||
- Pressing `e` with no project selected shows a warning notification.
|
||||
- The add-project flow still works through the renamed screen.
|
||||
- **Theme**: assert `app.current_theme.name == "catppuccin-frappe"` and
|
||||
spot-check resolved CSS variables (e.g. `background` is `#292c3c`,
|
||||
`border` variable is `#babbf1`) so a regression to auto-derived colors is
|
||||
caught.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Archiving/un-archiving projects from the TUI.
|
||||
- Filesystem validation of project paths.
|
||||
- Moving or migrating existing tmux sessions when a path changes.
|
||||
- Light-mode (Latte) theme variant.
|
||||
@@ -0,0 +1,87 @@
|
||||
# Simplify session attach + add rename
|
||||
|
||||
**Date:** 2026-06-10
|
||||
**Status:** Approved
|
||||
|
||||
## Goal
|
||||
|
||||
Streamline session handling with two changes:
|
||||
|
||||
1. Remove the redundant Resume (`Shift+R`) binding — attach already auto-resumes.
|
||||
2. Add a Rename action (`r`) to relabel the selected session.
|
||||
|
||||
## Background
|
||||
|
||||
Each session row carries two names:
|
||||
|
||||
- `tmux_session_name` (`hqt-{id}`) — the stable internal identifier used to
|
||||
target the tmux window and as the unique DB key. Never user-facing.
|
||||
- `nickname` — an optional display label shown in the session list and in the
|
||||
tmux window label (falls back to `tmux_session_name` when unset).
|
||||
|
||||
`attach_session()` (`src/hqt/sessions/service.py`) already handles every window
|
||||
state: alive → switch to it; dead pane or missing window → respawn via the
|
||||
fallback ladder (`_respawn_with_fallback`) then attach. The `resume_session()`
|
||||
method and its `R,shift+r` binding only duplicate that respawn-then-attach path,
|
||||
so Resume is dead weight.
|
||||
|
||||
## Change 1 — Remove the Resume binding (deletion only)
|
||||
|
||||
- **`src/hqt/tui/app.py`**: delete the `Binding("R,shift+r", "resume_session",
|
||||
"Resume")` entry and the `action_resume_session()` method.
|
||||
- **`src/hqt/sessions/service.py`**: delete the `resume_session()` method.
|
||||
|
||||
Keep `_respawn_with_fallback()` — `attach_session()` depends on it. Attach
|
||||
behavior is unchanged; Enter becomes the single way into a session, resuming
|
||||
transparently when the window is dead or gone.
|
||||
|
||||
## Change 2 — Add Rename (`r`)
|
||||
|
||||
### Service
|
||||
|
||||
New synchronous method on `SessionService` (pure DB write — no tmux call):
|
||||
|
||||
```python
|
||||
def rename_session(self, session_id: int, nickname: str | None) -> None:
|
||||
sess = self.db.get(Session, session_id)
|
||||
sess.nickname = nickname or None # empty -> None -> label falls back to tmux name
|
||||
self.db.commit()
|
||||
```
|
||||
|
||||
The next 3-second poll (`sync_window_labels()`) reads the updated `nickname` and
|
||||
refreshes the tmux window label automatically — no immediate label push needed.
|
||||
|
||||
A small `get_session(session_id) -> Session | None` accessor is added so the app
|
||||
layer can read the current nickname to prefill the dialog without reaching into
|
||||
`db` directly.
|
||||
|
||||
### Modal
|
||||
|
||||
New `RenameSessionScreen(ModalScreen[str | None])` in
|
||||
`src/hqt/tui/screens/rename_session.py`, mirroring `ProjectFormScreen`:
|
||||
|
||||
- Titled dialog with a single `Input` prefilled with the current nickname.
|
||||
- OK / Cancel buttons in a `.dialog-actions` row.
|
||||
- OK dismisses with the stripped input value; Cancel dismisses with `None`.
|
||||
|
||||
### App wiring (`src/hqt/tui/app.py`)
|
||||
|
||||
- Add `Binding("r", "rename_session", "Rename")`.
|
||||
- `action_rename_session()`:
|
||||
- Get the selected session id; warn ("Select a session first") if none.
|
||||
- Load the session via `get_session()` to read its current nickname.
|
||||
- Push `RenameSessionScreen` prefilled with that nickname.
|
||||
- On a non-`None` dismiss, call `rename_session()` then `_refresh_sessions()`.
|
||||
|
||||
## Testing
|
||||
|
||||
- `rename_session` sets `nickname`; an empty string clears it to `None`.
|
||||
- Remove or update any test referencing `resume_session` or the Resume binding.
|
||||
- Pilot test: press `r`, type a name, confirm, assert the session's nickname /
|
||||
displayed label updates.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- No change to `tmux_session_name`.
|
||||
- No new harness logic.
|
||||
- No DB migration (reuses the existing `nickname` column).
|
||||
Reference in New Issue
Block a user