90944948f5
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>
809 lines
25 KiB
Markdown
809 lines
25 KiB
Markdown
# 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"
|
||
```
|