Add vim list nav, fix window-title padding toggle, widen title spacing

TUI:
- VimListView: j/k now move down/up in the project and session lists
  (arrow keys still work); used by both list widgets.

tmux status bar:
- Widen the gap between window cells via a 2-space window-status-separator
  (WINDOW_STATUS_SEPARATOR).
- Fix the current-window cell toggling between padded " 1: name " and
  unpadded "1: name". The per-window status format is static, but
  set_window_label re-asserted it on every 3s poll — letting a competing
  writer (e.g. a stale TUI process running older code with a differently
  padded format) flip the cell each cycle. The format is now owned by a
  single write-once path (new_window at creation, apply_theme for existing
  windows); the poll updates only the dynamic @hqt_label.

Also lands pre-staged scaffolding: AGENTS.md quality gate, TODO.md backlog,
and ty dev dependency.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 17:43:47 -04:00
parent 5eea909029
commit 9379e1523c
10 changed files with 175 additions and 33 deletions
+12 -8
View File
@@ -784,21 +784,25 @@ async def test_poll_info_rc_nonzero_all_dead(runner):
@pytest.mark.asyncio
async def test_set_window_label_sets_option_and_format(runner):
"""set_window_label sets @hqt_label + a label-aware window-status-format,
targeting the window by NAME (identity preserved — no rename)."""
from hqt.tmux.runner import WINDOW_STATUS_FORMAT
async def test_set_window_label_sets_only_label_option(runner):
"""set_window_label updates ONLY the dynamic @hqt_label, targeting the
window by NAME (identity preserved — no rename).
It must NOT (re)write window-status-format / window-status-current-format:
the format is static and is established once by new_window (creation) and
apply_theme (existing windows). Re-asserting it on every 3s poll is the
vehicle that lets a stale/competing writer flip the cell between padded and
unpadded — see the toggle bug this guards against.
"""
await runner.set_window_label("hqt-1", "◐ myproj")
args = runner._exec.call_args.args
# Targets by name (exact-match prefix), never renames the window.
assert "hqt-main:=hqt-1" in args
assert "@hqt_label" in args
assert "◐ myproj" in args
# Both formats reference the label option so the bar shows it.
assert args.count(WINDOW_STATUS_FORMAT) == 2
assert "window-status-format" in args
assert "window-status-current-format" in args
# The poll does not touch the format — that is owned by new_window/apply_theme.
assert "window-status-format" not in args
assert "window-status-current-format" not in args
# All in a single atomic tmux invocation.
assert runner._exec.call_count == 1
+73
View File
@@ -1057,3 +1057,76 @@ async def test_rename_session_opens_prefilled_and_updates():
assert app._db_session.get(Session, sess.id).nickname == "after"
# ---------------------------------------------------------------------------
# Vim-style j/k navigation in the project and session lists
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_project_list_jk_navigation():
"""j/k move the highlight down/up in the project list like the arrow keys."""
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.tui.widgets.project_list import ProjectList
p1 = app._project_service.create("first", "/tmp/jk-first")
p2 = app._project_service.create("second", "/tmp/jk-second")
app._load_projects()
await app.workers.wait_for_complete()
await pilot.pause()
pl = app.query_one(ProjectList)
pl.query_one("#project-list").focus()
await pilot.pause()
# j walks down to the second project...
await pilot.press("j")
await pilot.pause()
assert pl.get_selected_project_id() == p2.id
# ...and k walks back up to the first.
await pilot.press("k")
await pilot.pause()
assert pl.get_selected_project_id() == p1.id
@pytest.mark.asyncio
async def test_session_list_jk_navigation():
"""j/k move the highlight down/up in the session list like the arrow keys."""
from hqt.db.models import Harness, Project, Session
from hqt.tui.widgets.session_list import SessionList
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
proj = Project(name="jk-proj", path="/tmp/jk-proj")
app._db_session.add(proj)
app._db_session.flush()
harness = app._db_session.query(Harness).first()
sessions = []
for n in range(2):
s = Session(
project_id=proj.id,
harness_id=harness.id,
nickname=f"sess-{n}",
tmux_session_name=f"hqt-jk-{n}",
archived=False,
)
app._db_session.add(s)
sessions.append(s)
app._db_session.commit()
app._selected_project_id = proj.id
# restore_selection mirrors a real project switch, which highlights the
# first session — giving j/k a defined starting point.
await app._refresh_sessions(restore_selection=True)
await pilot.pause()
sl = app.query_one(SessionList)
sl.query_one("#session-list").focus()
await pilot.pause()
await pilot.press("j")
await pilot.pause()
assert sl.get_selected_session_id() == sessions[1].id
await pilot.press("k")
await pilot.pause()
assert sl.get_selected_session_id() == sessions[0].id