diff --git a/docs/superpowers/plans/2026-06-11-new-session-dialog-ux.md b/docs/superpowers/plans/2026-06-11-new-session-dialog-ux.md new file mode 100644 index 0000000..9478995 --- /dev/null +++ b/docs/superpowers/plans/2026-06-11-new-session-dialog-ux.md @@ -0,0 +1,353 @@ +# 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 -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 ` + +## 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`: + +```python +@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): + +```python + 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): + +```python + # 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: + +```python + 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** + +```bash +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 " +``` + +--- + +### 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`: + +```python +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`: + +```css +/* 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** + +```bash +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 " +``` + +--- + +### 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`: + +```python +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: + +```python +from textual.app import ComposeResult +``` + +to: + +```python +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__`): + +```python + # 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** + +```bash +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 " +``` + +--- + +### 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.