Files
hqt/docs/superpowers/plans/2026-06-10-session-attach-rename.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

20 KiB

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.pyRenameSessionScreen 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.pytest_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):

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

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

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

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

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

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

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

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

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

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

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