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,qquits,?help modal withescapedismiss. - β — 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):
sspawnsadk pr-syncas an asyncio subprocess; stdout streams into a new LogPane (TextualRichLog); pr_sync.py emits a structured plan file at~/.agents-devkit/tui/workers/sync-plan.jsonat 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). Pressingswhile 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):
rtriggers a worker driver (tui/worker.py) that walks claim → prepare → spawnclaude -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>.jsonevery ~5 s (schema: pid, pr_url, task_type, agent, queue, started_at, last_heartbeat, current_phase, rc). In-worker heartbeat loop callsadk pr-queue heartbeatevery ~5 min so a long review’s lock doesn’t expire (the defaultTAKEN_LOCK_MAX_AGE_SECONDSceiling is 2 h)._busy()mutex blocksswhile 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>.jsonheartbeat 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). NewWorkersModel.gc()method deletes files older than 120 s (not wired to the poll loop yet — exposed for futureadk worker gc). Also lands the δ S-1 / M-3 / S-3 follow-ups: the worker’s_run_syncis replaced withasync _run_streamed(stdout streams line-by-line for claim/prepare/release); the asyncio SIGTERM handler is hoisted to the top of_driveand runs before claim, withsig_receivedchecked between steps; the SIGTERM test nowpytest.fails explicitly if the heartbeat file never appeared. The reviewer’s C-1 (non-dict JSON in workers_dir crashingsnapshot()/gc()) was patched inline before commit — both methods nowisinstance(raw, dict)guard. - ζ — Multi-select + ordered batch run (Session 4, 2026-05-22):
spacetoggles 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 viap). 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 checklen([t for t in _review_tasks.values() if not t.done()]) >= parallel_ncloses the spawn-race window inaction_review. Selection survives queue reloads — URLs that vanish from the snapshot are pruned. Footer showssel:N par:M.Rskips unready rows with a(skipping <url> — not ready)log line; logs(batch start — N rows, parallel=M)and(batch done — N rows). Mutex preserved:sblocked while any review is in flight,Rblocked 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 viaModalScreen[str | None]); submit spawnsadk pr-queue add <input> -yand streams output into the LogPane.bswitches to a RepoScreen (full-windowScreen) showing every repo under~/.agents-devkit/repos/with its tracked branches in aTree; 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.escapereturns to main. The 3 actions that usepush_screen_wait(action_add_pr,action_add_repo,action_add_branch) are@work-decorated — required by Textual becausepush_screen_waitneeds a worker context (caught by the code-reviewer and fixed inline before commit). Reviewer SHOULDs also applied inline:_refresh()now gates onRepoModel.has_changed()+ preserves cursor across reloads (was wiping the cursor every 5 s);action_add_prchecks busy state BEFORE opening the modal (was checking after);action_rebuildandaction_delete_auto_baseare now spawn-and-detach viaasyncio.create_taskso the screen pump stays responsive during a longrebuild-index(was blocking the screen for the subprocess duration). NewRepoModelreadsrepo-meta.json+branch-*/branch-meta.jsondirectly from disk (no CLI calls), mtime-gated via dir + per-meta signature. - ε — Phase-aware progress (Session 4, 2026-05-22):
tui/worker.pyparses 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 updatespayload["current_phase"]in place; the existing_file_looptask 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 aPhase: <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 matchedPhase Nin 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.pyexposes 5 default agents (claude,codex,cursor,opencode,headless) as a frozenAgentSpectuple withlist_agents/get_agent(case-insensitive, None-safe) /default_agenthelpers.aon the main screen opens an AgentPickerScreen (ModalScreen[str | None]with a TextualOptionList) that highlights the current agent on mount; submit persists inAdkApp._current_agent. Footer adds anagent:<name>chip. Worker now accepts--agent <name>(registry lookup) AND--agent-bin <path>(raw override; takes precedence when both set; heartbeatagentfield becomes"custom"to reflect the override). Theheadlessagent hasbin="__headless__"— a sentinel that the worker treats specially: it skipscreate_subprocess_execentirely, 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 withagent_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 viaescape/enter/q. Internal refactor:_run_reviewnow returns anoutcome dict(pr_url,rc,last_line,outcome) so_run_batchcan collect outcomes from each completed task.action_run_selectedalso captures the URLs it skipped upfront and seeds them into the recap withoutcome="skipped". Crashes fromtask.result()raising (CancelledError, etc.) are caught and surfaced asoutcome="crashed"with the exception repr. One non-trivial gotcha caught during smoke: the original method name was_renderwhich clashes with Textual’s internalWidget._render— renamed to_format_textto fix a confusingAttributeError: '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)
tcycles through 5 curated Textual themes (textual-dark→textual-light→nord→gruvbox→dracula→ 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_quitis now@work-decorated so it canpush_screen_waita ConfirmScreen when any subprocess is live — the user gets"N subprocesses still running. Quit anyway? (workers will be terminated)". Pressingycallsself.exit()which triggerson_unmount→ SIGTERM → 2 s grace → SIGKILL per δ. Pressingnstays in the TUI. (4) Reattach-on-restart banner: when the TUI mounts and finds pre-existing live<pid>.jsonheartbeat 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_streamedsubprocess. The handler setssig_received=Trueand terminatesagent_procif alive, but doesn’t track the claim/prepare/release child. Ifadk pr-task preparehangs (genuinely, not just slow), the worker waits for the call to complete before honoring the signal. The TUI’son_unmountescalates 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 intest_sync_plan_model.py, 6 intest_sync_plan_pane.py, 3 Pilot intest_sync_action.py). All green. - Session 4 (δ): 11 net-new tests (6 in
test_worker.py, 5 Pilot intest_review_action.py). All green. - Session 4 (θ): 19 net-new tests (10 in
test_workers_model.py, 6 intest_workers_pane.py, 3 intest_worker_async.py);test_worker.py::test_sigterm_releases_and_cleans_heartbeatunflaked with explicitpytest.failwhen the heartbeat file never appears. All green. - Session 4 (ζ): 10 net-new tests (5 in
test_selection.py, 5 intest_batch_run.py). All green. - Session 4 (η): 20 net-new tests (8 in
test_repo_model.py, 3 intest_prompt_screen.py, 4 intest_add_pr_action.py, 5 intest_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 intest_detail_pane_phase.py). All green. - Session 4 (κ): 18 net-new tests (8 in
test_agent_registry.py, 4 Pilot intest_agent_picker_screen.py, 6 intest_worker_agent_flag.py). All green; 2 conditionally skipped whenclaudeis 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_rowsflake (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-existingtest_quiet_hours_aborts_inside_windowred was fixed at the start of Session 4 (commita5b9968).
What got built
tui/__init__.py— version stub.tui/app.py—AdkApp(textual.App)+main()argparse entry; 9 bindings (q/?/f/S/j/k/g/G/escape); poll loop viaset_intervalcallsmodel.has_changed()thenmodel.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.py—HeaderBar(Static, markup=False).update_snapshot(snapshot); one-lineadk · queue · ready · in-review · platformsummary.tui/widgets/queue_table.py—QueueTable(DataTable).load(snapshot, ascii_only); cursor preserved across reloads bypr_url; empty-state row when queue missing/empty;_format_agerelative-time helper.tui/widgets/detail_pane.py—DetailPane(Static, markup=False).show(row); renders 8-line block (repo#N, title, author, branch, status+prep, lock, slack, last reviewed).tui/widgets/footer_bar.py—FooterBar(Static, markup=False).update_status(filter_mode, sort_mode).tui/widgets/help_screen.py—HelpScreen(ModalScreen[None])with?/escapedismiss.tui/model/queue_model.py—QueueModel+QueueRow+QueueSnapshot+ 5 filter modes + 3 sort modes + mtime-gatedhas_changed(). Reusesqueue_io.read_queue,queue_io.ready_for_review,queue_io.dedupe_key(no duplication of upstream helpers).tui/model/row_state.py—derive(row, ascii_only) → RowStatewith 13-icon set + ASCII fallback; precedence chain per SPEC §3.2.bin/adk— no-args (and--queue-path/--ascii/--poll-intervalalone) lazy-importtui.app:main;-h/--help/helpstill print CLI USAGE; ImportError ontui.appprints install hint and exits 2.skills/adk-cli/scripts/requirements.txt— appendedtextual>=0.86.tui/tests/conftest.py—frozen_now,fake_queue_path,missing_queue_path,tui_appfixtures; 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.py—SyncPlanWriterclass +plan_path()helper. Atomic write viatmp = 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 auditandauto-base cleanup)._run_stepgained aplankwarg + aplan_nameoverride so the canonical step name in the plan stays stable across--dry-run(which suffix-renames the display label). Skipped steps emitstep_done(status="skipped"). The existing CLI JSON summary stdout is untouched.tui/widgets/log_pane.py—LogPane(RichLog)withmarkup=False, wrap=False, auto_scroll=True;.announce()for meta-lines.tui/widgets/sync_plan_pane.py—SyncPlanPane(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.py—SyncPlanModel.has_changed()mtime-gated;snapshot()returns None on missing/corrupt/wrong-version file;SyncPlanStep+SyncPlanSnapshotfrozen dataclasses.tui/app.py— newsbinding;action_sync+_run_sync(asyncio subprocess driver:create_subprocess_execwithstdout=PIPE, stderr=STDOUT, env=os.environ + ADK_TUI_PLAN_PATH);_on_sync_task_donecallback surfaces unhandled exceptions to the LogPane so the footer never gets stuck on “(running…)”;on_unmountcleanup terminates the child with a 2 s grace + SIGKILL fallback.tui/widgets/footer_bar.py—update_status(filter, sort, *, sync_running=False)flips the[s] synclabel.skills/adk-cli/scripts/tests/conftest.py— autouse fixture setsADK_TUI_PLAN_PATHto 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 totui/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) → optionaladk pr-task prepare→ write heartbeat file + spawn 2 background tasks (5-minadk 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 errorif rc != 0 or sig_received). Exit codes: 0 / 1 / 2 / 130 per §2.4. Async-throughout for streaming + heartbeat; syncsubprocess.runfor claim/prepare/release (known SHOULD follow-up — see “Known δ follow-ups” above).tui/app.py— extended withrbinding,action_review,_run_review(asyncio subprocess driver for the worker),_busy()mutex helper,_on_review_task_donecallback,_resolve_worker_script(defaults toPath(__file__).parent / "worker.py"), new__init__kwargs (agent_bin,heartbeat_dir,worker_script).action_syncnow also blocks if a review is running;on_unmountextended to terminate both procs.tui/widgets/footer_bar.py— addedreview_runningkwarg;[r] run (disabled)legacy slot replaced with[r] review/[r] review (running…).tui/widgets/help_screen.py— added therline under thesline.tui/tests/test_worker.py(6 tests, real subprocess invocations): happy path, heartbeat file lifecycle, claim failure, agent failure with--status errorrelease, invalid URL, SIGTERM behavior.tui/tests/test_review_action.py(5 Pilot tests):rwith 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 satisfyingqueue_io.ready_for_review.tui/tests/conftest.py— addedfake_claude_script,worker_heartbeat_dir,eligible_queue_pathfixtures.
θ (Session 4)
tui/worker.py—_run_sync(syncsubprocess.run) replaced withasync _run_streamed(asynciocreate_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 afterparse_pr_url), so it’s installed before claim runs.sig_receivedchecked 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)withhas_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()(returnslist[WorkerRow]withage_sandis_stale),gc()(deletes files older thangc_after_s, returns count).now_fninjectable. C-1 patch: bothsnapshot()andgc()nowisinstance(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); otherwiseWorkers (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 tocompose()between the Horizontal queue/detail block and SyncPlanPane (matches v4 plan §8.4 layout);_workers_modelslot;_reload_workers(*, force=False)poll method called from_maybe_reload. Reuses the existingself._heartbeat_dirkwarg 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, missinglast_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.py—test_sigterm_releases_and_cleans_heartbeatnowpytest.fails explicitly when the heartbeat file never appears within 3 s instead of silently proceeding to SIGTERM (S-3 unflake).tui/tests/conftest.py— addedworkers_dir_with_two,stale_worker_file,fake_slow_adk_scriptfixtures.
ζ (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._reloadprunes vanished URLs from_selection_order+ passesselected_orderto QueueTable.load +selected_count/parallel_nto FooterBar._run_reviewmigrated to dict-based slot (_review_workers[pr_url] = procinserted right after spawn, popped infinallyon every exit path).on_unmountiterates_review_workers.values().tui/widgets/queue_table.py— first column widened 2 → 5 chars;selected_orderkwarg added toload(). 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.py—selected_count+parallel_nkwargs added; new text format· [R] run-sel [space] sel [p] par · sel:N par:M.tui/widgets/help_screen.py— added theR,space,plines under the existingrline.tui/tests/test_selection.py(5 Pilot tests):spacetoggles selection + renders[N]; selection survives filter cycle; pruned when URL vanishes;pcycles parallel; footer showssel:N.tui/tests/test_batch_run.py(5 Pilot tests):Rwith no selection logs the right message;Rwith one row spawns one worker;Rrespects parallel cap (usesparallel_n=1+ slow fake_claude; polls active worker count and asserts max ≤ 1);Rskips unready rows;Rblocked 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— addedeligible_multi_queuefixture.
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:
HeaderBar.update()/FooterBar.update()(per spec) → renamed toupdate_snapshot()/update_status()to avoid clobberingStatic.update. Agent C’s tests already worked againstrender()output rather than method names, so no test churn.?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 settingmarkup=Falsein theStaticconstructor 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.pywas clobbering the user’s real~/.agents-devkit/tui/workers/sync-plan.jsonon every test run (the new SyncPlanWriter ran unconditionally insidepr_sync.main()and the tests didn’t setADK_TUI_PLAN_PATH). Resolved with an autouse fixture inskills/adk-cli/scripts/tests/conftest.pythat 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_stepuses.get()fallback so an unknown status doesn’t crash the pane;SyncPlanModel.has_changed()simplified toself._last_mtime != 0.0with an explanatory comment;tui_plan.pyatomic write usespath.with_name(name + ".tmp")(the idiomatic form, not the suffix-stacking trick);action_syncwiresTask.add_done_callbackto surface unhandled exceptions so the footer never gets stuck on “(running…)”;_run_synccatchesOSErrorin addition toFileNotFoundError/PermissionError;_resolve_adk_binreturn type tightened toPath(the deadNonebranch was already unreachable). - Punted (May/Nit):
LogPane.announcekept (semantic clarity vs identical-to-write deletion);RichLog.linesaccessed viastr(Strip)in the test (works on Textual 8.x;.textproperty fix deferred); the integration test onpr-queue update --allagainst 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/shadk 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
# 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.0In-app:
qquit,?help,fcycle filter,Scycle sort,j/kor↑/↓move cursor,g/Gjump first/last.ssync — spawnsadk pr-syncand streams its stdout into the Log pane; the Sync-plan pane shows step-by-step progress live. Pressingsagain while a sync is in flight is a no-op.rreview — spawns a worker for the highlighted row that walks claim → prepare → claude/adk-pr-review→ release. Pressingrwhile 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.nnext — 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:
- 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 forv4.0.0-rc1. - Phase 7 — Release readiness report +
v4.0.0-rc1tag (Session 2’s #6). The TUI is in a state worth tagging. - Stabilise
test_batch_run.pyflake. Pre-existingtest_R_skips_unready_rows(and occasionallytest_R_respects_parallel_cap) intermittently fails withNoMatches: 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’sapp.query_one(QueueTable)query right after the firstpilot.pause(). Wrap the queries in a_poll_untilto mask it cheaply. - SKILL.md /
adk pr-task prepareworkflow cleanup (Session 2 leftover). - 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 batchRdoes. 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_texttruncation kicks in at 60 chars forlast_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 rapidapresses could both reachpush_screen_waitbefore the guard observes a non-empty screen_stack). Same pattern asaction_add_pr; fix systemically with@work(exclusive=True)if it ever bites. - κ-M-1:
OptionList.highlightedsilently 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_REdescription 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_subprocessonly surfaces the last line of output in the screen footer; multi-line errors fromrepo add/rebuild-indexget 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@workwith default policy may queue or replace the second invocation. - ζ:
test_R_respects_parallel_capsampling 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_streamedsubprocess 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_errortext — failed rows show⚠but no inline reason; a hover/tooltip story comes with η/θ.
Exact commands to inspect
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)