Skip to main content
On this page

P8 · TUI — α + β + γ + δ + θ + ζ + η + ε + κ + ι + λ slices (2026-05-21 → 2026-05-22)

Status: COMPLETE locally — all 11 sub-phases shipped. Read-only viewer + run-sync + single-PR review + worker visibility + multi-select batch + add-PR + repo management + phase-aware progress + agent picker + end-of-run recap + theme/detach/reattach polish. No sub-phases pending.

Sub-phases delivered

  • α — Foundation: Textual App, 4-pane skeleton, footer keymap, q quits, ? help modal with escape dismiss.
  • β — Read-only queue view: rows poll from ~/.agents-devkit/config/pr-queue.json5 (mtime-gated, no busy-poll), state-icon derivation, detail pane updates on cursor move, filter cycle (f: all → open → ready → reviewed → terminal), sort cycle (S: fifo → newest → repo), live 2 s refresh, empty state when queue file is missing.
  • γ — Run sync from TUI (Session 4, 2026-05-22): s spawns adk pr-sync as an asyncio subprocess; stdout streams into a new LogPane (Textual RichLog); pr_sync.py emits a structured plan file at ~/.agents-devkit/tui/workers/sync-plan.json at each step boundary (8 canonical steps: pr-scan, pr-queue update --all, pr-queue clean (merged), pr-task clean-orphans, pr-queue remind, base-index audit, auto-base cleanup, pr-task prepare --all); new SyncPlanPane polls that file mtime-gated and renders each step with status icons (✓ … ↷ ⚠ ✗ + ASCII fallback). Pressing s while a sync is already in flight is a no-op + announces “already running”. Quitting the TUI mid-sync sends SIGTERM with a 2s grace period, then SIGKILL. Footer flips to “[s] sync (running…)” during a run.
  • δ — Single-PR review from TUI (Session 4, 2026-05-22): r triggers a worker driver (tui/worker.py) that walks claim → prepare → spawn claude -p "/adk-pr-review <url>" → release. Worker is a standalone script (python3 tui/worker.py <pr_url> for direct CLI use; TUI spawns it as a subprocess). Writes a heartbeat file at ~/.agents-devkit/tui/workers/<pid>.json every ~5 s (schema: pid, pr_url, task_type, agent, queue, started_at, last_heartbeat, current_phase, rc). In-worker heartbeat loop calls adk pr-queue heartbeat every ~5 min so a long review’s lock doesn’t expire (the default TAKEN_LOCK_MAX_AGE_SECONDS ceiling is 2 h). _busy() mutex blocks s while a review is running and vice versa, with distinct LogPane messages for cross-process collisions. SIGTERM during the agent run terminates claude with a 2 s grace, releases the lock with --status error, removes the heartbeat file, exits 130. Release rc is now checked — (error: release rc=N — lock may be stuck) surfaces if it fails.
  • θ — External-worker visibility + δ follow-ups (Session 4, 2026-05-22): adds a new WorkersPane above the SyncPlanPane that reads every <pid>.json heartbeat file under ~/.agents-devkit/tui/workers/ (mtime-gated via a directory-fingerprint signature), renders one line per live worker (acme/foo#42 · review/review · claude · 12s), and hides stale rows (no heartbeat update in 30 s). New WorkersModel.gc() method deletes files older than 120 s (not wired to the poll loop yet — exposed for future adk worker gc). Also lands the δ S-1 / M-3 / S-3 follow-ups: the worker’s _run_sync is replaced with async _run_streamed (stdout streams line-by-line for claim/prepare/release); the asyncio SIGTERM handler is hoisted to the top of _drive and runs before claim, with sig_received checked between steps; the SIGTERM test now pytest.fails explicitly if the heartbeat file never appeared. The reviewer’s C-1 (non-dict JSON in workers_dir crashing snapshot() / gc()) was patched inline before commit — both methods now isinstance(raw, dict) guard.
  • ζ — Multi-select + ordered batch run (Session 4, 2026-05-22): space toggles the highlighted row’s URL in/out of _selection_order; selected rows render [N]<icon> (N = 1-based run order, capped at 9 for display) in the widened first column of the QueueTable. R (capital) runs all selected rows in FIFO selection order, respecting _parallel_n (cycle 1 → 2 → 4 → 8 via p). Internal refactor: _review_proc (single slot) → _review_workers: dict[pr_url → Process]; new _review_tasks: dict[pr_url → Task] populated immediately at task creation so the cap check len([t for t in _review_tasks.values() if not t.done()]) >= parallel_n closes the spawn-race window in action_review. Selection survives queue reloads — URLs that vanish from the snapshot are pruned. Footer shows sel:N par:M. R skips unready rows with a (skipping <url> — not ready) log line; logs (batch start — N rows, parallel=M) and (batch done — N rows). Mutex preserved: s blocked while any review is in flight, R blocked while sync or another batch is running.
  • η — Add-PR modal + repo management screen (Session 4, 2026-05-22): + on the main screen opens a PromptScreen modal (generic single-input via ModalScreen[str | None]); submit spawns adk pr-queue add <input> -y and streams output into the LogPane. b switches to a RepoScreen (full-window Screen) showing every repo under ~/.agents-devkit/repos/ with its tracked branches in a Tree; each branch tagged with origin (user/auto), last-indexed timestamp, and “used Ns ago” age. RepoScreen actions: + → add repo, a → add branch (cursor on a repo), R → rebuild index, d → delete auto-base. escape returns to main. The 3 actions that use push_screen_wait (action_add_pr, action_add_repo, action_add_branch) are @work-decorated — required by Textual because push_screen_wait needs a worker context (caught by the code-reviewer and fixed inline before commit). Reviewer SHOULDs also applied inline: _refresh() now gates on RepoModel.has_changed() + preserves cursor across reloads (was wiping the cursor every 5 s); action_add_pr checks busy state BEFORE opening the modal (was checking after); action_rebuild and action_delete_auto_base are now spawn-and-detach via asyncio.create_task so the screen pump stays responsive during a long rebuild-index (was blocking the screen for the subprocess duration). New RepoModel reads repo-meta.json + branch-*/branch-meta.json directly from disk (no CLI calls), mtime-gated via dir + per-meta signature.
  • ε — Phase-aware progress (Session 4, 2026-05-22): tui/worker.py parses agent stdout line-by-line for phase markers (--- Phase N: desc ---, ## Phase N: desc, **Phase N: desc**, etc.) via a precompiled regex anchored at line-start with decoration markers (^[\s\-#*>]*). On match, the worker updates payload["current_phase"] in place; the existing _file_loop task picks it up on its next ~5 s tick and re-persists the heartbeat file. WorkersPane (θ) renders the new granular phase live (acme/foo#42 · review/phase 4: Triage · claude · 12s). DetailPane (β) gains a Phase: <current_phase> line for the highlighted row when an active worker exists — inserted between Status and Lock. Initial phase changed from "review" to "phase 0". Code-reviewer found one critical false-positive issue: the original regex matched Phase N in arbitrary prose (PR titles, JSON-embedded strings, narration like “completed Phase 4: … moving to Phase 5: …”). Fixed inline before commit: regex now anchors at line-start with decoration markers only, and the description char-class excludes : so multi-marker lines don’t capture across markers. Verified with 4 negative-prose test cases.
  • κ — Agent picker + headless fallback (Session 4, 2026-05-22): new tui/agent_registry.py exposes 5 default agents (claude, codex, cursor, opencode, headless) as a frozen AgentSpec tuple with list_agents / get_agent (case-insensitive, None-safe) / default_agent helpers. a on the main screen opens an AgentPickerScreen (ModalScreen[str | None] with a Textual OptionList) that highlights the current agent on mount; submit persists in AdkApp._current_agent. Footer adds an agent:<name> chip. Worker now accepts --agent <name> (registry lookup) AND --agent-bin <path> (raw override; takes precedence when both set; heartbeat agent field becomes "custom" to reflect the override). The headless agent has bin="__headless__" — a sentinel that the worker treats specially: it skips create_subprocess_exec entirely, emits a [headless] no agent spawned for <url> line, sleeps 0.5 s so the heartbeat file has time to publish, and exits the agent-run section with agent_rc=0. Cleanup (heartbeat unlink, release) runs as normal in the finally block. Useful for testing the worker flow without burning agent tokens or having a real binary on PATH.
  • ι — End-of-run recap modal (Session 4, 2026-05-22): after a batch (R) finishes, the TUI pushes a new RecapScreen (ModalScreen[None]) summarising per-row outcomes: ok / failed / skipped / spawn-error / crashed (with ASCII fallbacks). Header line shows totals; each row line shows the short PR id (acme/foo#42), exit code, and the truncated last log line for quick triage. Dismissable via escape / enter / q. Internal refactor: _run_review now returns an outcome dict (pr_url, rc, last_line, outcome) so _run_batch can collect outcomes from each completed task. action_run_selected also captures the URLs it skipped upfront and seeds them into the recap with outcome="skipped". Crashes from task.result() raising (CancelledError, etc.) are caught and surfaced as outcome="crashed" with the exception repr. One non-trivial gotcha caught during smoke: the original method name was _render which clashes with Textual’s internal Widget._render — renamed to _format_text to fix a confusing AttributeError: 'str' object has no attribute 'render_strips'.
  • λ — Polish: themes + detach prompt + reattach banner (Session 4, 2026-05-22): three small but visible UX upgrades that close P8. (1) t cycles through 5 curated Textual themes (textual-darktextual-lightnordgruvboxdracula → wrap); the LogPane confirms each switch with a (theme: <name>) line. (2) New generic ConfirmScreen (ModalScreen[bool]) — yes (y / enter) / no (n / escape / q) gate. (3) action_quit is now @work-decorated so it can push_screen_wait a ConfirmScreen when any subprocess is live — the user gets "N subprocesses still running. Quit anyway? (workers will be terminated)". Pressing y calls self.exit() which triggers on_unmount → SIGTERM → 2 s grace → SIGKILL per δ. Pressing n stays in the TUI. (4) Reattach-on-restart banner: when the TUI mounts and finds pre-existing live <pid>.json heartbeat files in the workers dir (from another terminal or a prior crashed session), it writes (reattached: N existing worker[s] from heartbeat dir) to the LogPane. The WorkersPane (θ) already renders those workers; the banner just makes the reattach loud for the operator. Worker discovery is read-only: the TUI doesn’t try to kill external workers on quit (they survive).

Known θ follow-ups (for ζ / κ slice)

The θ reviewer flagged one SHOULD-level limitation accepted as a follow-up:

  • θ-S-1: SIGTERM cannot cancel an in-flight _run_streamed subprocess. The handler sets sig_received=True and terminates agent_proc if alive, but doesn’t track the claim/prepare/release child. If adk pr-task prepare hangs (genuinely, not just slow), the worker waits for the call to complete before honoring the signal. The TUI’s on_unmount escalates SIGTERM → 2 s grace → SIGKILL, so the worker is eventually killed — but the lock may not be cleanly released. Fix sketch: track the in-flight Process in a nonlocal and .terminate() it from _on_sigterm. Defer to a future polish pass; not exercised by typical reviews where prepare is < 60 s.

Test pass count

  • Session 3 (α + β): 18 net-new TUI tests, all green.
  • Session 4 (γ): 27 net-new tests (10 in test_pr_sync_plan.py, 8 in test_sync_plan_model.py, 6 in test_sync_plan_pane.py, 3 Pilot in test_sync_action.py). All green.
  • Session 4 (δ): 11 net-new tests (6 in test_worker.py, 5 Pilot in test_review_action.py). All green.
  • Session 4 (θ): 19 net-new tests (10 in test_workers_model.py, 6 in test_workers_pane.py, 3 in test_worker_async.py); test_worker.py::test_sigterm_releases_and_cleans_heartbeat unflaked with explicit pytest.fail when the heartbeat file never appears. All green.
  • Session 4 (ζ): 10 net-new tests (5 in test_selection.py, 5 in test_batch_run.py). All green.
  • Session 4 (η): 20 net-new tests (8 in test_repo_model.py, 3 in test_prompt_screen.py, 4 in test_add_pr_action.py, 5 in test_repo_screen.py). All green.
  • Session 4 (ε): 17 net-new tests (13 in test_worker_phase.py — incl. 1 prose-false-positive case added in the reviewer-fix pass + 1 integration test; 4 in test_detail_pane_phase.py). All green.
  • Session 4 (κ): 18 net-new tests (8 in test_agent_registry.py, 4 Pilot in test_agent_picker_screen.py, 6 in test_worker_agent_flag.py). All green; 2 conditionally skipped when claude is on PATH (deliberate guard).
  • Session 4 (ι): 11 net-new tests in test_recap_screen.py (3 url-shortener + 4 text-format + 3 dismiss-key Pilot + 1 end-to-end batch-pushes-recap). All green.
  • Session 4 (λ): 10 net-new tests in test_polish.py (1 theme-cycle Pilot + 4 ConfirmScreen Pilot + 3 q-while-busy detach-prompt Pilot + 2 reattach-banner Pilot). All green.
  • Repo-wide: 571 tests, 571 passing (+ 2 deliberate skips). The pre-existing test_R_skips_unready_rows flake (NoMatches: No nodes match 'QueueTable' on Screen(id='_default')) still surfaces ~1/5 full-suite runs — flagged by the ε reviewer and tracked as a top-of-list follow-up. Pre-existing test_quiet_hours_aborts_inside_window red was fixed at the start of Session 4 (commit a5b9968).

What got built

  • tui/__init__.py — version stub.
  • tui/app.pyAdkApp(textual.App) + main() argparse entry; 9 bindings (q/?/f/S/j/k/g/G/escape); poll loop via set_interval calls model.has_changed() then model.snapshot(); filter/sort cycling rotates a tuple.
  • tui/styles.tcss — ~30 lines: header docks top, footer docks bottom, queue 2fr / detail 1fr horizontal split.
  • tui/widgets/header_bar.pyHeaderBar(Static, markup=False).update_snapshot(snapshot); one-line adk · queue · ready · in-review · platform summary.
  • tui/widgets/queue_table.pyQueueTable(DataTable).load(snapshot, ascii_only); cursor preserved across reloads by pr_url; empty-state row when queue missing/empty; _format_age relative-time helper.
  • tui/widgets/detail_pane.pyDetailPane(Static, markup=False).show(row); renders 8-line block (repo#N, title, author, branch, status+prep, lock, slack, last reviewed).
  • tui/widgets/footer_bar.pyFooterBar(Static, markup=False).update_status(filter_mode, sort_mode).
  • tui/widgets/help_screen.pyHelpScreen(ModalScreen[None]) with ? / escape dismiss.
  • tui/model/queue_model.pyQueueModel + QueueRow + QueueSnapshot + 5 filter modes + 3 sort modes + mtime-gated has_changed(). Reuses queue_io.read_queue, queue_io.ready_for_review, queue_io.dedupe_key (no duplication of upstream helpers).
  • tui/model/row_state.pyderive(row, ascii_only) → RowState with 13-icon set + ASCII fallback; precedence chain per SPEC §3.2.
  • bin/adk — no-args (and --queue-path/--ascii/--poll-interval alone) lazy-import tui.app:main; -h/--help/help still print CLI USAGE; ImportError on tui.app prints install hint and exits 2.
  • skills/adk-cli/scripts/requirements.txt — appended textual>=0.86.
  • tui/tests/conftest.pyfrozen_now, fake_queue_path, missing_queue_path, tui_app fixtures; repo-root sys.path insert.
  • tui/tests/fixtures/sample_queue.json5 — 6 rows mixing GH/BB × ready/preparing/merged/failed/locked.
  • tui/tests/test_model.py (9), test_app_launch.py (3 Pilot), test_queue_render.py (3 Pilot), test_keybindings.py (3 Pilot).

γ (Session 4)

  • skills/adk-cli/scripts/tui_plan.pySyncPlanWriter class + plan_path() helper. Atomic write via tmp = path.with_name(name + ".tmp"); os.replace(tmp, path). Schema versioned (version: 1). 94 LOC.
  • skills/adk-cli/scripts/pr_sync.py — instrumented every step (including the two special-cased branches: base-index audit and auto-base cleanup). _run_step gained a plan kwarg + a plan_name override so the canonical step name in the plan stays stable across --dry-run (which suffix-renames the display label). Skipped steps emit step_done(status="skipped"). The existing CLI JSON summary stdout is untouched.
  • tui/widgets/log_pane.pyLogPane(RichLog) with markup=False, wrap=False, auto_scroll=True; .announce() for meta-lines.
  • tui/widgets/sync_plan_pane.pySyncPlanPane(Static, markup=False); header line + one row per step; defensive .get(status, pending) fallback for forward-compat with new statuses.
  • tui/model/sync_plan_model.pySyncPlanModel.has_changed() mtime-gated; snapshot() returns None on missing/corrupt/wrong-version file; SyncPlanStep + SyncPlanSnapshot frozen dataclasses.
  • tui/app.py — new s binding; action_sync + _run_sync (asyncio subprocess driver: create_subprocess_exec with stdout=PIPE, stderr=STDOUT, env=os.environ + ADK_TUI_PLAN_PATH); _on_sync_task_done callback surfaces unhandled exceptions to the LogPane so the footer never gets stuck on “(running…)”; on_unmount cleanup terminates the child with a 2 s grace + SIGKILL fallback.
  • tui/widgets/footer_bar.pyupdate_status(filter, sort, *, sync_running=False) flips the [s] sync label.
  • skills/adk-cli/scripts/tests/conftest.pyautouse fixture sets ADK_TUI_PLAN_PATH to a tmp dir so pr_sync tests can never clobber the user’s real ~/.agents-devkit/tui/workers/sync-plan.json. Found by the independent code-reviewer as a Blocker.
  • skills/adk-cli/scripts/tests/test_pr_sync_plan.py (10 tests), tui/tests/test_sync_plan_model.py (8), tui/tests/test_sync_plan_pane.py (6), tui/tests/test_sync_action.py (3 Pilot). Fixtures added to tui/tests/conftest.py: fake_plan_path, sync_plan_in_progress, fake_adk_script.

δ (Session 4)

  • tui/worker.py — NEW, 240 LOC. Standalone worker driver. CLI: python3 tui/worker.py <pr_url> [--queue PATH] [--agent-bin PATH] [--adk-bin PATH] [--no-prepare] [--heartbeat-bump-interval-s N] [--heartbeat-file-interval-s N] [--heartbeat-dir PATH]. Flow: validate URL → adk pr-queue claim (fail-fast if locked) → optional adk pr-task prepare → write heartbeat file + spawn 2 background tasks (5-min adk pr-queue heartbeat + 5-s heartbeat-file rewrite) → spawn <agent-bin> -p "/adk-pr-review <url>" → stream stdout → on agent exit (or SIGTERM) clean up + adk pr-queue release (with --status error if rc != 0 or sig_received). Exit codes: 0 / 1 / 2 / 130 per §2.4. Async-throughout for streaming + heartbeat; sync subprocess.run for claim/prepare/release (known SHOULD follow-up — see “Known δ follow-ups” above).
  • tui/app.py — extended with r binding, action_review, _run_review (asyncio subprocess driver for the worker), _busy() mutex helper, _on_review_task_done callback, _resolve_worker_script (defaults to Path(__file__).parent / "worker.py"), new __init__ kwargs (agent_bin, heartbeat_dir, worker_script). action_sync now also blocks if a review is running; on_unmount extended to terminate both procs.
  • tui/widgets/footer_bar.py — added review_running kwarg; [r] run (disabled) legacy slot replaced with [r] review / [r] review (running…).
  • tui/widgets/help_screen.py — added the r line under the s line.
  • tui/tests/test_worker.py (6 tests, real subprocess invocations): happy path, heartbeat file lifecycle, claim failure, agent failure with --status error release, invalid URL, SIGTERM behavior.
  • tui/tests/test_review_action.py (5 Pilot tests): r with no row, on not-ready row, on eligible row (real worker subprocess), cross-process collision messages (s-while-review, r-while-sync).
  • tui/tests/fixtures/eligible_queue.json5 — single-row queue fixture satisfying queue_io.ready_for_review.
  • tui/tests/conftest.py — added fake_claude_script, worker_heartbeat_dir, eligible_queue_path fixtures.

θ (Session 4)

  • tui/worker.py_run_sync (sync subprocess.run) replaced with async _run_streamed (asyncio create_subprocess_exec + per-line readline loop), so claim/prepare/release output now streams to stdout instead of buffering. SIGTERM handler hoisted from line 159 → top of _drive (right after parse_pr_url), so it’s installed before claim runs. sig_received checked between every step; mid-flight signal causes a clean release-then-130 exit. New _release(adk_bin, pr_url, queue, *, status=None) helper de-duplicates the rc-check + (released:) / (error: release rc=…) emit logic.
  • tui/model/workers_model.py — NEW (~180 LOC). WorkersModel(workers_dir, stale_after_s=30, gc_after_s=120) with has_changed() (directory-fingerprint signature: (dir_mtime, sorted [(name, mtime, size)]) so a single-file rewrite is detected without needing the dir’s own mtime to bump), snapshot() (returns list[WorkerRow] with age_s and is_stale), gc() (deletes files older than gc_after_s, returns count). now_fn injectable. C-1 patch: both snapshot() and gc() now isinstance(raw, dict) guard so a stray non-dict JSON file under ~/.agents-devkit/tui/workers/ doesn’t crash the poll callback.
  • tui/widgets/workers_pane.py — NEW (~55 LOC). WorkersPane(Static, markup=False). Empty state (no active workers); otherwise Workers (N active) header + one line per LIVE worker (acme/foo#42 · review/review · claude · 12s). Stale rows are hidden from display. ASCII fallback swaps ⚙↻~.
  • tui/app.py — WorkersPane added to compose() between the Horizontal queue/detail block and SyncPlanPane (matches v4 plan §8.4 layout); _workers_model slot; _reload_workers(*, force=False) poll method called from _maybe_reload. Reuses the existing self._heartbeat_dir kwarg as the WorkersModel’s workers_dir (single source of truth).
  • tui/styles.tcss — WorkersPane styling (height: auto, min: 1, max: 10, padding 0 1, background $boost).
  • tui/tests/test_workers_model.py (10 tests): empty dir, two fresh files, stale file, corrupt JSON, missing last_heartbeat, non-dict JSON (C-1 regression), gc() removes old/keeps fresh, has_changed() detects new file, etc.
  • tui/tests/test_workers_pane.py (6 tests): empty state, populated render, stale hidden, ASCII fallback, etc.
  • tui/tests/test_worker_async.py (3 tests): prepare output arrives line-by-line (M-3 fix); SIGTERM mid-prepare releases lock (S-1 fix); SIGTERM before claim releases nothing (invariant check).
  • tui/tests/test_worker.pytest_sigterm_releases_and_cleans_heartbeat now pytest.fails explicitly when the heartbeat file never appears within 3 s instead of silently proceeding to SIGTERM (S-3 unflake).
  • tui/tests/conftest.py — added workers_dir_with_two, stale_worker_file, fake_slow_adk_script fixtures.

ζ (Session 4)

  • tui/app.py — biggest change of the slice (~+160 net LOC). Removed _review_proc / _review_task (single slots); added _selection_order: list[str], _review_workers: dict[str, Process], _review_tasks: dict[str, Task], _parallel_n: int = 4, _batch_task: Task | None = None, _PARALLEL_CYCLE = (1, 2, 4, 8). New bindings: R (run_selected), space (toggle_select), p (cycle_parallel). New methods: action_toggle_select, action_run_selected, action_cycle_parallel, _run_batch, _on_batch_task_done. _busy() renamed to _busy_label() and now considers _batch_task + _review_workers. _reload prunes vanished URLs from _selection_order + passes selected_order to QueueTable.load + selected_count / parallel_n to FooterBar. _run_review migrated to dict-based slot (_review_workers[pr_url] = proc inserted right after spawn, popped in finally on every exit path). on_unmount iterates _review_workers.values().
  • tui/widgets/queue_table.py — first column widened 2 → 5 chars; selected_order kwarg added to load(). Selected rows render [N]<icon> (N = 1-based run order, capped at 9 for display); unselected get a 3-space prefix to keep column alignment. Empty-state row unchanged.
  • tui/widgets/footer_bar.pyselected_count + parallel_n kwargs added; new text format · [R] run-sel [space] sel [p] par · sel:N par:M.
  • tui/widgets/help_screen.py — added the R, space, p lines under the existing r line.
  • tui/tests/test_selection.py (5 Pilot tests): space toggles selection + renders [N]; selection survives filter cycle; pruned when URL vanishes; p cycles parallel; footer shows sel:N.
  • tui/tests/test_batch_run.py (5 Pilot tests): R with no selection logs the right message; R with one row spawns one worker; R respects parallel cap (uses parallel_n=1 + slow fake_claude; polls active worker count and asserts max ≤ 1); R skips unready rows; R blocked while sync is running.
  • tui/tests/fixtures/eligible_multi_queue.json5 — 3 ready rows (pull/300, /301, /302) for batch tests.
  • tui/tests/conftest.py — added eligible_multi_queue fixture.

Reviewer findings applied inline before commit: action_review’s parallel-cap check tightened from len(_review_workers) (populated after subprocess spawn) → len([t for t in _review_tasks.values() if not t.done()]) (populated immediately at task creation) — closes the spawn-race window a rapid r press could exploit. Redundant _review_workers.pop in _on_review_task_done’s exception branch removed (the finally in _run_review already pops on every exit path). The reviewer’s “critical” claim about line 501 hardcoding review_running=False was a false positive — the line already reads bool(self._review_workers); verified by direct file inspection.

How it was built — agentic team

Three subagents in parallel (strict file-ownership boundaries per the spec):

Agent Slice Files
A (adk-agent-implementer) widgets + app shell tui/init.py, tui/app.py, tui/styles.tcss, tui/widgets/*
B (adk-agent-implementer) model layer tui/model/*
C (adk-agent-test-engineer) tests + integration tui/tests/*, bin/adk, requirements.txt

A binding spec at .temp/prj-prod-ready/tui-spec/SPEC.md (~13 KB) was the single source of truth: every public interface signature (QueueRow, QueueSnapshot, QueueModel, derive, ICON_SET, AdkApp, BINDINGS, bin/adk wiring rules) was pinned before fan-out. Two minor spec deviations were resolved by agents:

  1. HeaderBar.update() / FooterBar.update() (per spec) → renamed to update_snapshot() / update_status() to avoid clobbering Static.update. Agent C’s tests already worked against render() output rather than method names, so no test churn.
  2. ? binding key → question_mark (Textual’s canonical key name).

Validation pass

α + β (Session 3)

  • Code-review subagent ran an independent read-only review (spec conformance + correctness + DX + repo conventions + test quality). 12 findings landed (1 Critical clock-injection; 6 Should refactors; 4 May/Nit cleanups).
  • Three manual smoke runs (real queue / ASCII mode / missing queue) all clean.
  • One observable display bug surfaced during smoke: Rich’s markup parser was eating [f] / [S] / [s] / [r] / [q] brackets in the footer (interpreted as opening style tags). Fixed by setting markup=False in the Static constructor of FooterBar, HeaderBar, and DetailPane.

γ (Session 4)

  • Code-review subagent flagged 1 Blocker, 2 Criticals, 6 Shoulds, plus May/Nit cleanups.
  • Blocker (fixed): existing test_pr_sync.py was clobbering the user’s real ~/.agents-devkit/tui/workers/sync-plan.json on every test run (the new SyncPlanWriter ran unconditionally inside pr_sync.main() and the tests didn’t set ADK_TUI_PLAN_PATH). Resolved with an autouse fixture in skills/adk-cli/scripts/tests/conftest.py that redirects the env var to a tmp dir for every test. Verified: real-file mtime is unchanged across a full pr_sync test run.
  • Shoulds applied: _format_step uses .get() fallback so an unknown status doesn’t crash the pane; SyncPlanModel.has_changed() simplified to self._last_mtime != 0.0 with an explanatory comment; tui_plan.py atomic write uses path.with_name(name + ".tmp") (the idiomatic form, not the suffix-stacking trick); action_sync wires Task.add_done_callback to surface unhandled exceptions so the footer never gets stuck on “(running…)”; _run_sync catches OSError in addition to FileNotFoundError/PermissionError; _resolve_adk_bin return type tightened to Path (the dead None branch was already unreachable).
  • Punted (May/Nit): LogPane.announce kept (semantic clarity vs identical-to-write deletion); RichLog.lines accessed via str(Strip) in the test (works on Textual 8.x; .text property fix deferred); the integration test on pr-queue update --all against an empty queue accepted as-is (real smoke ran rc=0; would need a fresh-CI repro to justify mocking).
  • End-to-end smoke: real Pilot test with a fake /bin/sh adk that echoes 4 lines + exits 0; the LogPane captured the $ ... announce + all 4 lines + the (pr-sync exited rc=0) announce; footer flipped to “[s] sync” after exit.

How to use

Shell
# Default — launch against ~/.agents-devkit/config/pr-queue.json5:adk# Custom queue path:adk --queue-path /some/other/pr-queue.json5# ASCII-only icons (for terminals with poor unicode support):adk --ascii# Slower / faster polling:adk --poll-interval 5.0

In-app:

  • q quit, ? help, f cycle filter, S cycle sort, j/k or ↑/↓ move cursor, g/G jump first/last.
  • s sync — spawns adk pr-sync and streams its stdout into the Log pane; the Sync-plan pane shows step-by-step progress live. Pressing s again while a sync is in flight is a no-op.
  • r review — spawns a worker for the highlighted row that walks claim → prepare → claude /adk-pr-review → release. Pressing r while a sync OR another review is running is blocked (cross-process collision messages in the Log pane). Quit mid-review sends SIGTERM; worker releases the lock + cleans up the heartbeat file.
  • n next — shown as (disabled) in the footer until sub-phase ζ lands.

Decisions made this slice

Fork Choice Type Reason
Session 3 first track TUI build (P8) user-answered User explicit. Phase 4 (OSS hardening) deferred to next session.
Scope of TUI in this session α + β only user-answered Multi-session shipping path agreed; e2e for this slice today.
TUI directory top-level tui/ user-answered User preference: “this is an app, why not top-level”. Departure from v4 plan §8.5 which sketched skills/adk-cli/scripts/tui/worker.py; when δ ships, the worker driver will live at tui/worker.py.
Testing tool Textual Pilot via asyncio.run() inferred pytest-asyncio not installed; sync wrappers keep tests in the existing pytest pipeline.
Parallelization 3-agent fan-out with strict file ownership inferred User asked for “highest quality DX” + e2e completion today. Spec-first + ownership-fenced gives parallelism without cross-collision.
Rich markup eaten in footer markup=False on Static constructor (not in update() — wrong API in Textual 8.x) corrected after a smoke failure Initial fix tried update(text, markup=False) which Textual 8.2.x rejects; constructor-time markup=False is the working pattern.

Next steps — P8 done; remaining items are follow-ups + the broader v4 roadmap

P8 (TUI) is complete locally. The repo is ready for the v4 release work that was on hold during Session 4:

  1. Phase 4 — OSS hardening (Session 2’s #1 recommendation, never started). README polish, CONTRIBUTING.md, CODE_OF_CONDUCT.md, .github/ISSUE_TEMPLATE, CI workflow, pyproject.toml. Prereq for v4.0.0-rc1.
  2. Phase 7 — Release readiness report + v4.0.0-rc1 tag (Session 2’s #6). The TUI is in a state worth tagging.
  3. Stabilise test_batch_run.py flake. Pre-existing test_R_skips_unready_rows (and occasionally test_R_respects_parallel_cap) intermittently fails with NoMatches: No nodes match 'QueueTable' on Screen(id='_default'). Reproduces ~1-in-5 full-suite runs but isolated test runs pass. Suspect: race between Textual mount and the test’s app.query_one(QueueTable) query right after the first pilot.pause(). Wrap the queries in a _poll_until to mask it cheaply.
  4. SKILL.md / adk pr-task prepare workflow cleanup (Session 2 leftover).
  5. Bitbucket MCP signature audit on a real Bitbucket PR (Session 2 leftover).

Remaining ι-tier follow-ups (none load-bearing):

  • ι: Single r (one review) does NOT push a recap — only batch R does. Consider showing the recap for single-review too with a 1-row body, or expose a key (i?) to re-show the last recap on demand.
  • ι: The _format_text truncation kicks in at 60 chars for last_line; long error messages get clipped. Could route long lines to a wrapped sub-panel within the modal.

Remaining κ-tier follow-ups (none load-bearing):

  • κ-S-1: action_pick_agent’s double-push guard inherits η’s race window (two rapid a presses could both reach push_screen_wait before the guard observes a non-empty screen_stack). Same pattern as action_add_pr; fix systemically with @work(exclusive=True) if it ever bites.
  • κ-M-1: OptionList.highlighted silently falls through to row 0 (claude) when the current agent name is not in the registry. Low-impact UX edge.

Remaining ε-tier follow-ups (none load-bearing):

  • ε-may-1: _PHASE_RE description truncates at 60 chars before the defensive [:80] slice kicks in; the slice is effectively dead defense. Either tighten the test to verify the slice OR drop the slice. Cosmetic.

Remaining η/ζ-tier follow-ups (none load-bearing):

  • η-M-2: RepoScreen._spawn_subprocess only surfaces the last line of output in the screen footer; multi-line errors from repo add / rebuild-index get truncated. Route to LogPane or open a transient LogScreen on rc≠0.
  • η-N-3: Double-+ press while a PromptScreen is up is now guarded against, but the equivalent guard in RepoScreen’s +/a (which use @work) isn’t wired — Textual’s @work with default policy may queue or replace the second invocation.
  • ζ: test_R_respects_parallel_cap sampling rate is 100 ms; brief 2-worker windows could escape detection. Lower to 20 ms if it ever flakes on CI.
  • ζ: Per-worker _reload(force=True) at the end of each _run_review (∼9 reloads back-to-back when 8 workers finish a batch). Single end-of-batch reload would suffice; <20 ms wasted today.
  • θ-S-1 (still open): SIGTERM can’t cancel an in-flight _run_streamed subprocess inside the worker. Track in-flight Process in a nonlocal and terminate from the signal handler.

Things deliberately left for a polish-later pass (carried over from α+β):

  • Themes (only the default dark theme ships in this slice).
  • Custom layout (the 2fr/1fr split is fixed; resizing is on the Textual default).
  • The help modal lists every binding statically; later, generate it from AdkApp.BINDINGS.
  • The 5-state icon set is fine for now but doesn’t yet surface prep_error text — failed rows show but no inline reason; a hover/tooltip story comes with η/θ.

Exact commands to inspect

Shell
git log --oneline -5python3 -m pytest tui/tests/ -qpython3 -m pytest skills/adk-cli/scripts/tests/ skills/adk-pr-review/scripts/tests/ -qadk           # launch the TUI against the real queueadk --ascii   # ASCII glyphs (defensive fallback)