Files
hqt/docs/superpowers/plans/2026-06-09-project-edit-frappe-theme.md
T
felixm 90944948f5 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>
2026-06-10 17:06:57 -04:00

25 KiB
Raw Blame History

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:

    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:

from sqlalchemy.exc import IntegrityError

Add this method to ProjectService (after get, before archive):

    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
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.pysrc/hqt/tui/screens/project_form.py

  • Modify: src/hqt/tui/app.py (import at line 17, action_add_project at lines 107114)

  • Test: tests/test_tui.py

  • Step 1: Write the failing tests

Add to tests/test_tui.py:

# ---------------------------------------------------------------------------
# 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
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:

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:

from hqt.tui.screens.add_project import AddProjectScreen

with:

from hqt.tui.screens.project_form import ProjectFormScreen

and in action_add_project, replace:

        self.push_screen(AddProjectScreen(), on_dismiss)

with:

        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
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:

@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):

    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
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 4251, new action after action_add_project)

  • Test: tests/test_tui.py

  • Step 1: Write the failing tests

Add to tests/test_tui.py:

# ---------------------------------------------------------------------------
# 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):

        Binding("e", "edit_project", "Edit Project"),

Add this method after action_add_project:

    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
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:

# ---------------------------------------------------------------------------
# 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:

# 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
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:

@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:

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
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:

# ---------------------------------------------------------------------------
# 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:

from rich.markup import escape

Add at module level (above the SessionList class, next to where _STATUS_SYMBOLS will remain inside the class):

_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:

            text = f"{symbol} {s.nickname or s.tmux_session_name} [{s.harness.name}] {status_text}"

with:

            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
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
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:

git add -A && git commit -m "fix: theme polish from visual verification"