# 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" ```