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>
This commit is contained in:
2026-06-10 17:06:57 -04:00
commit 90944948f5
61 changed files with 8834 additions and 0 deletions
+332
View File
@@ -0,0 +1,332 @@
import json
import os
import time
from pathlib import Path
from unittest.mock import patch
from hqt.harnesses.configurators.codex import CodexConfigurator
from hqt.harnesses.configurators.claude import ClaudeConfigurator
from hqt.harnesses.registry import discover_harnesses
def test_claude_spawn_config():
c = ClaudeConfigurator()
sid = c.generate_session_id(1)
cfg = c.build_spawn_config(Path("/tmp"), sid, model="opus")
assert cfg.command == ["claude", "--session-id", sid, "--model", "opus"]
def test_claude_resume_config():
c = ClaudeConfigurator()
sid = c.generate_session_id(1)
cfg = c.build_resume_config(Path("/tmp"), sid)
assert "--resume" in cfg.command
assert sid in cfg.command
def test_claude_resume_config_includes_model_when_set():
"""build_resume_config appends --model when model is provided."""
c = ClaudeConfigurator()
sid = c.generate_session_id(1)
cfg = c.build_resume_config(Path("/tmp"), sid, model="claude-opus-4-5")
assert "--resume" in cfg.command
assert sid in cfg.command
assert "--model" in cfg.command
assert "claude-opus-4-5" in cfg.command
def test_claude_resume_config_omits_model_when_none():
"""build_resume_config omits --model when model is None."""
c = ClaudeConfigurator()
sid = c.generate_session_id(1)
cfg = c.build_resume_config(Path("/tmp"), sid, model=None)
assert "--model" not in cfg.command
def test_codex_resume_config_includes_model_when_set():
"""build_resume_config appends -m <model> when model is provided."""
c = CodexConfigurator()
cfg = c.build_resume_config(Path("/tmp"), "abc-123", model="o3")
assert cfg.command == ["codex", "resume", "abc-123", "-m", "o3"]
def test_codex_resume_config_omits_model_when_none():
"""build_resume_config omits -m when model is None."""
c = CodexConfigurator()
cfg = c.build_resume_config(Path("/tmp"), "abc-123", model=None)
assert "-m" not in cfg.command
assert cfg.command == ["codex", "resume", "abc-123"]
def test_codex_spawn_with_model():
c = CodexConfigurator()
cfg = c.build_spawn_config(Path("/tmp"), "1", model="o3")
assert cfg.command == ["codex", "-m", "o3"]
def test_codex_resume_uses_uuid():
c = CodexConfigurator()
cfg = c.build_resume_config(Path("/tmp"), "abc-123")
assert cfg.command == ["codex", "resume", "abc-123"]
def test_codex_capture_session_id(tmp_path):
sessions_dir = tmp_path / ".codex" / "sessions" / "2026" / "06" / "09"
sessions_dir.mkdir(parents=True)
rollout = sessions_dir / "rollout-20260609-abcdef12-3456-7890-abcd-ef1234567890.jsonl"
meta = {"type": "session_meta", "payload": {"id": "abcdef12-3456-7890-abcd-ef1234567890", "cwd": "/projects/foo"}}
rollout.write_text(json.dumps(meta) + "\n")
since = time.time() - 60 # far enough back that freshly-written file qualifies
c = CodexConfigurator()
with patch("hqt.harnesses.configurators.codex.Path.home", return_value=tmp_path):
result = c.capture_session_id(Path("/projects/foo"), since)
assert result == "abcdef12-3456-7890-abcd-ef1234567890"
def test_codex_capture_session_id_wrong_cwd(tmp_path):
sessions_dir = tmp_path / ".codex" / "sessions" / "2026" / "06" / "09"
sessions_dir.mkdir(parents=True)
rollout = sessions_dir / "rollout-20260609-abcdef12.jsonl"
meta = {"type": "session_meta", "payload": {"id": "abcdef12", "cwd": "/other/path"}}
rollout.write_text(json.dumps(meta) + "\n")
since = time.time() - 60
c = CodexConfigurator()
with patch("hqt.harnesses.configurators.codex.Path.home", return_value=tmp_path):
result = c.capture_session_id(Path("/projects/foo"), since)
assert result is None
@patch("hqt.harnesses.registry.shutil.which")
def test_registry_discovers_harnesses(mock_which):
mock_which.side_effect = lambda x: "/usr/bin/" + x if x in ("claude", "codex") else None
result = discover_harnesses()
assert "claude" in result
assert "codex" in result
assert "kiro" not in result
# ---------------------------------------------------------------------------
# Task 4: time-fenced capture_session_id and captures_session_id flag
# ---------------------------------------------------------------------------
def test_codex_captures_session_id_flag():
"""CodexConfigurator must expose captures_session_id = True."""
assert CodexConfigurator.captures_session_id is True
def test_base_captures_session_id_flag_false():
"""HarnessConfigurator base class default must be False."""
from hqt.harnesses.base import HarnessConfigurator
assert HarnessConfigurator.captures_session_id is False
def test_claude_captures_session_id_flag_false():
"""ClaudeConfigurator must NOT set captures_session_id (inherits False)."""
assert ClaudeConfigurator.captures_session_id is False
def _write_rollout(path: Path, session_id: str, cwd: str) -> None:
meta = {"type": "session_meta", "payload": {"id": session_id, "cwd": cwd}}
path.write_text(json.dumps(meta) + "\n")
def test_codex_capture_ignores_file_before_since(tmp_path):
"""Rollout with mtime BEFORE since must not be returned even if cwd matches."""
sessions_dir = tmp_path / ".codex" / "sessions" / "dir"
sessions_dir.mkdir(parents=True)
rollout = sessions_dir / "rollout-old.jsonl"
_write_rollout(rollout, "old-id", "/projects/foo")
# Set mtime to something clearly in the past
past = time.time() - 60
os.utime(rollout, (past, past))
since = time.time() - 10 # since is after the file's mtime
c = CodexConfigurator()
with patch("hqt.harnesses.configurators.codex.Path.home", return_value=tmp_path):
result = c.capture_session_id(Path("/projects/foo"), since)
assert result is None
def test_codex_capture_accepts_file_after_since(tmp_path):
"""Rollout with mtime >= since and matching cwd is returned."""
sessions_dir = tmp_path / ".codex" / "sessions" / "dir"
sessions_dir.mkdir(parents=True)
rollout = sessions_dir / "rollout-new.jsonl"
_write_rollout(rollout, "new-id", "/projects/foo")
since = time.time() - 60 # since is well before file was written
# mtime of a freshly written file should be >= since
c = CodexConfigurator()
with patch("hqt.harnesses.configurators.codex.Path.home", return_value=tmp_path):
result = c.capture_session_id(Path("/projects/foo"), since)
assert result == "new-id"
def test_codex_capture_newest_post_since_cwd_match_wins(tmp_path):
"""Among multiple post-since rollouts, newest matching cwd wins over older matching."""
sessions_dir = tmp_path / ".codex" / "sessions" / "dir"
sessions_dir.mkdir(parents=True)
rollout_older = sessions_dir / "rollout-older.jsonl"
rollout_newer = sessions_dir / "rollout-newer.jsonl"
_write_rollout(rollout_older, "older-id", "/projects/foo")
_write_rollout(rollout_newer, "newer-id", "/projects/foo")
now = time.time()
since = now - 120
os.utime(rollout_older, (now - 30, now - 30))
os.utime(rollout_newer, (now - 10, now - 10))
c = CodexConfigurator()
with patch("hqt.harnesses.configurators.codex.Path.home", return_value=tmp_path):
result = c.capture_session_id(Path("/projects/foo"), since)
assert result == "newer-id"
def test_codex_capture_skips_non_matching_cwd_picks_older_matching(tmp_path):
"""Newest post-since file has wrong cwd; older post-since file with right cwd is returned."""
sessions_dir = tmp_path / ".codex" / "sessions" / "dir"
sessions_dir.mkdir(parents=True)
rollout_wrong = sessions_dir / "rollout-wrong.jsonl"
rollout_right = sessions_dir / "rollout-right.jsonl"
_write_rollout(rollout_wrong, "wrong-id", "/other/project")
_write_rollout(rollout_right, "right-id", "/projects/foo")
now = time.time()
since = now - 120
# wrong cwd file is newer
os.utime(rollout_wrong, (now - 5, now - 5))
# right cwd file is older but still after since
os.utime(rollout_right, (now - 20, now - 20))
c = CodexConfigurator()
with patch("hqt.harnesses.configurators.codex.Path.home", return_value=tmp_path):
result = c.capture_session_id(Path("/projects/foo"), since)
assert result == "right-id"
def test_codex_capture_missing_sessions_dir_returns_none(tmp_path):
"""Missing ~/.codex/sessions dir returns None without error."""
c = CodexConfigurator()
with patch("hqt.harnesses.configurators.codex.Path.home", return_value=tmp_path):
result = c.capture_session_id(Path("/projects/foo"), time.time())
assert result is None
# ---------------------------------------------------------------------------
# Task 7: parse_status for harness configurators
# ---------------------------------------------------------------------------
CLAUDE_WORKING_SCREEN = """\
> Implement feature X
● Bash(git status)
⎿ On branch main
● Write(src/foo.py)
⎿ Wrote 42 lines
Esc to interrupt
"""
CLAUDE_WAITING_SCREEN = """\
> Do you want to proceed with these changes?
1. Yes
2. No
"""
CLAUDE_PERMISSION_PROMPT = """\
> Do you want to allow this tool to run?
1. Yes, allow once
2. No
"""
CLAUDE_IDLE_SCREEN = """\
>
✓ Done
"""
CODEX_WORKING_SCREEN = """\
Working...
Esc to interrupt
"""
CODEX_IDLE_SCREEN = """\
>
"""
def test_base_parse_status_returns_none():
"""Base HarnessConfigurator.parse_status always returns None."""
from hqt.harnesses.configurators.kiro import KiroConfigurator
k = KiroConfigurator()
assert k.parse_status("anything") is None
assert k.parse_status("") is None
def test_claude_parse_status_working():
"""ClaudeConfigurator: 'esc to interrupt' (case-insensitive) → 'working'."""
c = ClaudeConfigurator()
assert c.parse_status(CLAUDE_WORKING_SCREEN) == "working"
def test_claude_parse_status_working_case_insensitive():
"""ClaudeConfigurator: 'ESC TO INTERRUPT' upper-case → 'working'."""
c = ClaudeConfigurator()
assert c.parse_status("ESC TO INTERRUPT") == "working"
def test_claude_parse_status_waiting_do_you_want():
"""ClaudeConfigurator: line starting with 'Do you want''waiting'."""
c = ClaudeConfigurator()
assert c.parse_status(CLAUDE_WAITING_SCREEN) == "waiting"
def test_claude_parse_status_waiting_permission_prompt():
"""ClaudeConfigurator: ' 1.' style line → 'waiting'."""
c = ClaudeConfigurator()
assert c.parse_status(CLAUDE_PERMISSION_PROMPT) == "waiting"
def test_claude_parse_status_none_for_idle():
"""ClaudeConfigurator: idle/done screen → None (activity-based fallback)."""
c = ClaudeConfigurator()
assert c.parse_status(CLAUDE_IDLE_SCREEN) is None
def test_claude_parse_status_empty_returns_none():
"""ClaudeConfigurator: empty text → None."""
c = ClaudeConfigurator()
assert c.parse_status("") is None
def test_codex_parse_status_working():
"""CodexConfigurator: 'esc to interrupt''working'."""
c = CodexConfigurator()
assert c.parse_status(CODEX_WORKING_SCREEN) == "working"
def test_codex_parse_status_none_for_idle():
"""CodexConfigurator: idle screen → None."""
c = CodexConfigurator()
assert c.parse_status(CODEX_IDLE_SCREEN) is None
def test_codex_parse_status_none_for_empty():
"""CodexConfigurator: empty text → None."""
c = CodexConfigurator()
assert c.parse_status("") is None