Files
hqt/docs/superpowers/plans/2026-06-11-new-session-dialog-ux.md
T
2026-06-11 10:29:35 -04:00

12 KiB

New Session Dialog UX Refinements 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: Three UX refinements to the New Session modal — hide sandbox sub-options until sandboxing is enabled, unify the focus highlight to the dialog's Peach accent, and add context-aware j/k field navigation.

Architecture: All changes are confined to the NewSessionScreen widget (src/hqt/tui/screens/new_session.py) and the shared stylesheet (src/hqt/tui/styles.tcss). The reveal logic reuses the screen's existing show/hide pattern (the worktree checkbox → branch input), navigation uses Textual's built-in focus_next/focus_previous screen actions, and the highlight is a scoped CSS rule. No service, DB, or sandbox-policy code is touched.

Tech Stack: Python 3.14, Textual (TUI framework), pytest + pytest-asyncio (app.run_test() pilot harness), uv for running commands.


Conventions

  • Run tests with: uv run --active pytest <path> -v
  • Run the linter with: uv run --active ruff check
  • Existing screen tests live in tests/test_tui.py and use the app.run_test() pilot. New tests go there too.
  • The screen under test is constructed as NewSessionScreen(harness_names, is_git_repo=..., sandbox_available=...) and pushed with app.push_screen(...).
  • Commit messages end with the trailer: Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

File Structure

  • src/hqt/tui/screens/new_session.py — modal screen. Gains: a #sandbox-options container around the fs/net rows, an on_switch_changed handler, and BINDINGS for j/k.
  • src/hqt/tui/styles.tcss — shared stylesheet. Gains: a scoped :focus rule for the dialog's controls.
  • tests/test_tui.py — screen tests. Gains: reveal, focus-highlight, and j/k navigation tests.

Task 1: Hide sandbox sub-options until the switch is on

Files:

  • Modify: src/hqt/tui/screens/new_session.py:66-74 (the fs/net rows) and add an on_switch_changed handler near on_checkbox_changed (around line 79)

  • Test: tests/test_tui.py

  • Step 1: Write the failing test

Append to tests/test_tui.py:

@pytest.mark.asyncio
async def test_sandbox_options_hidden_until_switch_on():
    """The fs/net rows are sub-options of the sandbox switch: hidden until it is
    turned on, shown when on, hidden again when turned back off."""
    app = HqtApp()
    async with app.run_test(size=(120, 40)) as pilot:
        from hqt.tui.screens.new_session import NewSessionScreen
        from textual.widgets import Switch

        app.push_screen(
            NewSessionScreen(["claude"], is_git_repo=True, sandbox_available=True)
        )
        await pilot.pause()
        options = app.screen.query_one("#sandbox-options")
        assert options.display is False

        switch = app.screen.query_one("#sandbox-switch", Switch)
        switch.value = True
        await pilot.pause()
        assert options.display is True

        switch.value = False
        await pilot.pause()
        assert options.display is False
  • Step 2: Run test to verify it fails

Run: uv run --active pytest tests/test_tui.py::test_sandbox_options_hidden_until_switch_on -v Expected: FAIL — NoMatches / no widget with id sandbox-options (the container does not exist yet).

  • Step 3: Wrap the fs/net rows in a hidden container

In src/hqt/tui/screens/new_session.py, replace the current fs/net block (lines 66-74):

            yield Label("Filesystem access:")
            yield Select(
                [("Read-write", "rw"), ("Read-only", "ro")],
                id="fs-select",
                value="rw",
                allow_blank=False,
            )
            yield Label("Network:")
            yield Switch(id="net-switch", value=True)

with a container that starts hidden (same display = False pattern already used for #branch-input above):

            # Sub-options of the sandbox switch: only meaningful when sandboxing
            # is on, so hidden until then (mirrors the worktree -> branch reveal).
            sandbox_options = Vertical(id="sandbox-options")
            sandbox_options.display = False
            with sandbox_options:
                yield Label("Filesystem access:")
                yield Select(
                    [("Read-write", "rw"), ("Read-only", "ro")],
                    id="fs-select",
                    value="rw",
                    allow_blank=False,
                )
                yield Label("Network:")
                yield Switch(id="net-switch", value=True)

Vertical is already imported at the top of the file (from textual.containers import Horizontal, Vertical).

  • Step 4: Add the reveal handler

In the same file, add this method directly after on_checkbox_changed (it ends around line 90). Guard on the switch id so the #net-switch toggle inside the container does not trigger it:

    def on_switch_changed(self, event: Switch.Changed) -> None:
        if event.switch.id != "sandbox-switch":
            return
        self.query_one("#sandbox-options").display = event.value
  • Step 5: Run test to verify it passes

Run: uv run --active pytest tests/test_tui.py::test_sandbox_options_hidden_until_switch_on -v Expected: PASS

  • Step 6: Commit
git add src/hqt/tui/screens/new_session.py tests/test_tui.py
git commit -m "feat: reveal sandbox sub-options only when sandboxing is on

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 2: Unify the focus highlight to the Peach accent

Files:

  • Modify: src/hqt/tui/styles.tcss (append a scoped :focus rule)
  • Test: tests/test_tui.py

Context: the dialog buttons already get a Peach $accent focus highlight, but Switch/Checkbox/Select/Input fall back to Textual's default Lavender ($border) focus border. This task makes every focusable control in the dialog share the Peach accent.

  • Step 1: Write the failing test

Append to tests/test_tui.py:

def test_dialog_focus_highlight_uses_accent():
    """Every focusable control in the New Session dialog shares the Peach accent
    focus border (consistent with the dialog buttons), not Textual's default
    Lavender. Guards against the scoped :focus rule being dropped."""
    from pathlib import Path
    import hqt.tui

    css = (Path(hqt.tui.__file__).parent / "styles.tcss").read_text()
    for selector in (
        "#new-session-dialog Switch:focus",
        "#new-session-dialog Checkbox:focus",
        "#new-session-dialog Select:focus",
        "#new-session-dialog Input:focus",
    ):
        assert selector in css, f"missing focus rule: {selector}"
    assert "border: tall $accent;" in css
  • Step 2: Run test to verify it fails

Run: uv run --active pytest tests/test_tui.py::test_dialog_focus_highlight_uses_accent -v Expected: FAIL — selectors not found in styles.tcss.

  • Step 3: Add the scoped focus rule

Append to the end of src/hqt/tui/styles.tcss:

/* Unify the focus highlight across the New Session dialog. The buttons get a
   Peach $accent focus highlight (above), but Switch/Checkbox/Select/Input
   otherwise fall back to Textual's default Lavender $border focus border, which
   reads as off-palette next to the buttons. Match `tall` so only the color
   changes, not the control height. */
#new-session-dialog Switch:focus,
#new-session-dialog Checkbox:focus,
#new-session-dialog Select:focus,
#new-session-dialog Input:focus {
    border: tall $accent;
}
  • Step 4: Run test to verify it passes

Run: uv run --active pytest tests/test_tui.py::test_dialog_focus_highlight_uses_accent -v Expected: PASS

  • Step 5: Sanity-check the dialog still mounts

Run: uv run --active pytest tests/test_tui.py -k new_session -v Expected: PASS (the CSS parses and the existing New Session tests are unaffected).

  • Step 6: Commit
git add src/hqt/tui/styles.tcss tests/test_tui.py
git commit -m "style: unify New Session dialog focus highlight to accent

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 3: Soft context-aware j/k field navigation

Files:

  • Modify: src/hqt/tui/screens/new_session.py (add BINDINGS and the Binding import)
  • Test: tests/test_tui.py

Context: j/k move focus through the dialog's focus chain. Because Input consumes printable keys before screen bindings fire, j/k type literally while editing text and only move focus when the focused control is a toggle/select/ button — the "soft" model. focus_next/focus_previous are built-in Textual screen actions; Tab/Shift+Tab/arrows are unaffected.

  • Step 1: Write the failing tests

Append to tests/test_tui.py:

def test_new_session_has_jk_focus_bindings():
    """j -> focus_next, k -> focus_previous, declared on the screen."""
    from hqt.tui.screens.new_session import NewSessionScreen

    actions = {b.key: b.action for b in NewSessionScreen.BINDINGS}
    assert actions.get("j") == "focus_next"
    assert actions.get("k") == "focus_previous"


@pytest.mark.asyncio
async def test_jk_moves_focus_between_controls():
    """On a non-text control, j moves focus to another control."""
    app = HqtApp()
    async with app.run_test(size=(120, 40)) as pilot:
        from hqt.tui.screens.new_session import NewSessionScreen
        from textual.widgets import Switch

        app.push_screen(
            NewSessionScreen(["claude"], is_git_repo=True, sandbox_available=True)
        )
        await pilot.pause()
        switch = app.screen.query_one("#sandbox-switch", Switch)
        switch.focus()
        await pilot.pause()
        assert app.focused is switch

        await pilot.press("j")
        await pilot.pause()
        assert app.focused is not switch


@pytest.mark.asyncio
async def test_jk_typed_into_input_is_literal():
    """Inside a text input, j/k type literally and do not move focus."""
    app = HqtApp()
    async with app.run_test(size=(120, 40)) as pilot:
        from hqt.tui.screens.new_session import NewSessionScreen
        from textual.widgets import Input

        app.push_screen(NewSessionScreen(["claude"], is_git_repo=True))
        await pilot.pause()
        nickname = app.screen.query_one("#nickname-input", Input)
        nickname.focus()
        await pilot.pause()

        await pilot.press("j", "k")
        await pilot.pause()
        assert nickname.value == "jk"
        assert app.focused is nickname
  • Step 2: Run tests to verify they fail

Run: uv run --active pytest tests/test_tui.py -k "jk or jk_focus" -v Expected: FAIL — NewSessionScreen has no BINDINGS attribute defining j/k (and pressing j on the switch would not move focus).

  • Step 3: Add the bindings

In src/hqt/tui/screens/new_session.py, add the Binding import. Change:

from textual.app import ComposeResult

to:

from textual.app import ComposeResult
from textual.binding import Binding

Then add a BINDINGS class attribute as the first line inside the NewSessionScreen class body (immediately after the class line, before __init__):

    # Soft vim-style field navigation: j/k walk the dialog's focus chain. Inputs
    # consume printable keys first, so j/k type literally while editing text and
    # only move focus when a toggle/select/button is focused.
    BINDINGS = [
        Binding("j", "focus_next", "Next field", show=False),
        Binding("k", "focus_previous", "Prev field", show=False),
    ]
  • Step 4: Run tests to verify they pass

Run: uv run --active pytest tests/test_tui.py -k "jk or jk_focus" -v Expected: PASS (all three tests)

  • Step 5: Commit
git add src/hqt/tui/screens/new_session.py tests/test_tui.py
git commit -m "feat: soft j/k field navigation in New Session dialog

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 4: Full-suite verification

Files: none (verification only)

  • Step 1: Run the whole test suite

Run: uv run --active pytest -q Expected: PASS — no regressions; the new tests included.

  • Step 2: Lint

Run: uv run --active ruff check Expected: All checks passed!

  • Step 3 (optional manual smoke): launch the TUI and open New Session

Run the app, press n, and confirm: the fs/net rows appear only after toggling Sandboxed on; focused controls show a Peach (not Lavender) highlight; j/k move between toggles but type literally in the nickname/model/branch fields.