feat: soft j/k field navigation in New Session dialog

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 10:43:00 -04:00
parent d72f04dd89
commit 78ea959788
2 changed files with 68 additions and 0 deletions
+17
View File
@@ -1,4 +1,5 @@
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical from textual.containers import Horizontal, Vertical
from textual.screen import ModalScreen from textual.screen import ModalScreen
from textual.widgets import Button, Checkbox, Input, Label, Select, Switch from textual.widgets import Button, Checkbox, Input, Label, Select, Switch
@@ -10,6 +11,14 @@ from hqt.sandbox import SandboxPolicy
class NewSessionScreen( class NewSessionScreen(
ModalScreen[tuple[str, str, str | None, str | None, SandboxPolicy | None] | None] ModalScreen[tuple[str, str, str | None, str | None, SandboxPolicy | None] | None]
): ):
# 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),
]
def __init__( def __init__(
self, self,
harness_names: list[str], harness_names: list[str],
@@ -21,6 +30,14 @@ class NewSessionScreen(
self.sandbox_available = sandbox_available self.sandbox_available = sandbox_available
super().__init__() super().__init__()
# focus_next/focus_previous are screen methods, not binding actions, so the
# j/k bindings need these thin action_* wrappers to dispatch to them.
def action_focus_next(self) -> None:
self.focus_next()
def action_focus_previous(self) -> None:
self.focus_previous()
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
with Vertical(id="new-session-dialog"): with Vertical(id="new-session-dialog"):
yield Label("New Session") yield Label("New Session")
+51
View File
@@ -1913,3 +1913,54 @@ def test_dialog_focus_highlight_uses_accent():
): ):
assert selector in css, f"missing focus rule: {selector}" assert selector in css, f"missing focus rule: {selector}"
assert "border: tall $accent;" in css assert "border: tall $accent;" in css
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