From 867977f299827b61349faa8e3c824aa67d9de77c Mon Sep 17 00:00:00 2001 From: agentforce314 Date: Thu, 11 Jun 2026 02:34:23 -0700 Subject: [PATCH] workflows: interactive two-pane TUI for the /workflows detail view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuild WorkflowDetailScreen as the Claude Code two-pane monitor: a Phases list (left, numbered with done/total progress) beside that phase's agents (right, with status icon · type · tokens · tools · duration). ↑↓ moves the phase selection and the right pane updates live; →/Tab focuses the agents pane (x/r stop/retry the selected agent); x on the phases pane stops the whole run; p/s surface "not yet supported" (the engine has no pause/save); Esc backs out. A 1s interval repaints so progress advances live. Reuses the shared progress formatters (format_agent_line) from the monitor PR, so the TUI and the REPL /workflows text stay consistent. Tests (Textual pilot): two-pane structure + rich icon, x-stops-the-run, and phase-navigation-updates-the-agents-pane. 688 TUI+workflow tests pass. Co-Authored-By: Claude Opus 4.8 --- src/tui/screens/workflow_dialog.py | 178 +++++++++++++++++++++++++---- tests/tui/test_workflow_dialog.py | 70 +++++++++++- 2 files changed, 218 insertions(+), 30 deletions(-) diff --git a/src/tui/screens/workflow_dialog.py b/src/tui/screens/workflow_dialog.py index 6f365af2d..71ed0d290 100644 --- a/src/tui/screens/workflow_dialog.py +++ b/src/tui/screens/workflow_dialog.py @@ -12,6 +12,7 @@ from typing import Any, Iterator from rich.text import Text +from textual.containers import Horizontal, Vertical from textual.widget import Widget from textual.widgets import Static @@ -20,6 +21,7 @@ retry_workflow_agent, skip_workflow_agent, ) +from src.workflow.progress import format_agent_line from ..widgets.select_list import SelectList, SelectOption from .dialog_base import DialogScreen @@ -60,48 +62,174 @@ def _agent_options(state: Any) -> list[SelectOption]: class WorkflowDetailScreen(DialogScreen[None]): - """Phases → agents for one run; x stops an agent, r retries it.""" + """Two-pane monitor for one run: Phases (left) ⟷ that phase's agents (right). - footer_hint = "x stop agent · r retry agent · Esc back" - BINDINGS = [("x", "stop_agent", "Stop agent"), ("r", "retry_agent", "Retry agent")] + Mirrors the Claude Code /workflows view. ``↑↓`` moves the phase selection and + the right pane updates live; ``→``/``Tab`` focuses the agents pane (where + ``x``/``r`` stop/retry the selected agent); ``x`` on the phases pane stops the + whole workflow; ``Esc`` backs out. The view repaints once a second so progress + advances live. + """ + + footer_hint = "↑↓ phase · → agents · x stop · r retry · p pause · s save · Esc back" + BINDINGS = [ + ("x", "stop", "Stop"), + ("r", "retry", "Retry agent"), + ("p", "pause", "Pause"), + ("s", "save", "Save"), + ("right", "focus_agents", "Agents"), + ("tab", "focus_agents", "Agents"), + ("left", "focus_phases", "Phases"), + ] + + DEFAULT_CSS = """ + WorkflowDetailScreen > #dialog-panel { width: 100; max-width: 96%; height: 90%; } + WorkflowDetailScreen #dialog-body { height: 1fr; } + WorkflowDetailScreen #wf-twopane { height: 1fr; } + WorkflowDetailScreen #wf-phases-pane { + width: 28; + border-right: solid $primary-darken-2; + padding: 0 1 0 0; + } + WorkflowDetailScreen #wf-agents-pane { width: 1fr; padding: 0 0 0 1; } + WorkflowDetailScreen .wf-pane-title { text-style: bold; color: $primary; } + """ def __init__(self, *, registry: Any, task_id: str) -> None: super().__init__() self._registry = registry self._task_id = task_id state = registry.get(task_id) - self.title_text = f"Workflow · {getattr(state, 'workflow_name', 'run')}" - self._select: SelectList | None = None + name = getattr(state, "workflow_name", "run") + self.title_text = f"Workflow · {name}" + desc = (getattr(state, "description", "") or "").strip() + progress = getattr(state, "progress", None) + summary = progress.summary() if progress is not None else "" + self.subtitle_text = f"{desc} · {summary}".strip(" ·") if desc else summary + self._phases_list: SelectList | None = None + self._agents_list: SelectList | None = None + self._agents_title: Static | None = None + + # ---- data ---- + def _phases(self) -> list[Any]: + progress = getattr(self._registry.get(self._task_id), "progress", None) + return list(getattr(progress, "phases", None) or []) + + def _phase_options(self) -> list[SelectOption]: + out: list[SelectOption] = [] + for i, p in enumerate(self._phases()): + total = len(p.agents) + out.append(SelectOption( + label=f"{i + 1} {p.title}", + value=str(i), + description=f"{p.done_count}/{total}" if total else "", + )) + return out or [SelectOption(label="(no phases yet)", value="", disabled=True)] + + def _agent_options_for(self, idx: int) -> list[SelectOption]: + phases = self._phases() + if not (0 <= idx < len(phases)): + return [] + return [ + SelectOption(label=format_agent_line(a, indent=""), value=a.key or "", disabled=not a.key) + for a in phases[idx].agents + ] + + def _agents_header(self, idx: int) -> str: + phases = self._phases() + if 0 <= idx < len(phases): + return f"{phases[idx].title} · {len(phases[idx].agents)} agents" + return "Agents" + # ---- composition ---- def build_body(self) -> Iterator[Widget]: - state = self._registry.get(self._task_id) - yield Static(Text("\n".join(format_workflow_detail(state)), style="none"), markup=False) - options = _agent_options(state) - if options: - self._select = SelectList(options, allow_cancel=True) - yield self._select + self._phases_list = SelectList(self._phase_options(), allow_cancel=True) + self._agents_title = Static( + Text(self._agents_header(0)), markup=False, classes="wf-pane-title" + ) + self._agents_list = SelectList(self._agent_options_for(0), allow_cancel=True) + yield Horizontal( + Vertical( + Static(Text("Phases"), markup=False, classes="wf-pane-title"), + self._phases_list, + id="wf-phases-pane", + ), + Vertical(self._agents_title, self._agents_list, id="wf-agents-pane"), + id="wf-twopane", + ) def _post_mount(self) -> None: - if self._select is not None: - self._select.focus() + if self._phases_list is not None: + self._phases_list.focus() + self.set_interval(1.0, self._refresh) + + # ---- live sync ---- + def _current_phase_idx(self) -> int: + if self._phases_list is None or self._phases_list.current is None: + return 0 + try: + return int(self._phases_list.current.value) + except (TypeError, ValueError): + return 0 + + def _sync_agents(self) -> None: + idx = self._current_phase_idx() + if self._agents_list is not None: + self._agents_list.set_options(self._agent_options_for(idx), keep_cursor=True) + if self._agents_title is not None: + self._agents_title.update(Text(self._agents_header(idx))) + + def _refresh(self) -> None: + if self._phases_list is not None: + self._phases_list.set_options(self._phase_options(), keep_cursor=True) + self._sync_agents() + + def on_select_list_option_highlighted(self, _: SelectList.OptionHighlighted) -> None: + # Right pane tracks the LEFT (phase) selection; ignore highlight events + # from the agents pane so navigating agents doesn't rebuild itself. + if self.focused is self._phases_list: + self._sync_agents() - def _current_key(self) -> str | None: - if self._select is None or self._select.current is None: - return None - return str(self._select.current.value) or None + def on_select_list_selection_cancelled(self, _: SelectList.SelectionCancelled) -> None: + self.dismiss(None) - def action_stop_agent(self) -> None: - key = self._current_key() - if key: - skip_workflow_agent(self._task_id, key, self._registry) + # ---- focus ---- + def action_focus_agents(self) -> None: + if self._agents_list is not None and self._agents_list.options: + self._agents_list.focus() - def action_retry_agent(self) -> None: - key = self._current_key() + def action_focus_phases(self) -> None: + if self._phases_list is not None: + self._phases_list.focus() + + # ---- control ---- + def _current_agent_key(self) -> str | None: + if self._agents_list is None or self._agents_list.current is None: + return None + return str(self._agents_list.current.value) or None + + def action_stop(self) -> None: + # On the agents pane, x stops the selected agent; otherwise the whole run. + if self.focused is self._agents_list: + key = self._current_agent_key() + if key: + skip_workflow_agent(self._task_id, key, self._registry) + self._refresh() + return + kill_workflow_task(self._task_id, self._registry) + self._refresh() + + def action_retry(self) -> None: + key = self._current_agent_key() if key: retry_workflow_agent(self._task_id, key, self._registry) + self._refresh() - def on_select_list_selection_cancelled(self, _: SelectList.SelectionCancelled) -> None: - self.dismiss(None) + def action_pause(self) -> None: + self.app.notify("Pause isn't supported by the engine yet.", severity="warning") + + def action_save(self) -> None: + self.app.notify("Saving a run as a reusable workflow isn't supported yet.", severity="warning") class WorkflowsScreen(DialogScreen[None]): diff --git a/tests/tui/test_workflow_dialog.py b/tests/tui/test_workflow_dialog.py index 85c03bae3..e9e4b01f6 100644 --- a/tests/tui/test_workflow_dialog.py +++ b/tests/tui/test_workflow_dialog.py @@ -129,7 +129,7 @@ def compose(self): assert status_line.workflows == 1 -async def test_detail_screen_opens_with_agents(): +async def test_detail_screen_two_pane(): reg = _registry_with_run() class _DetailHost(App): @@ -139,7 +139,67 @@ def on_mount(self): app = _DetailHost() async with app.run_test() as pilot: await pilot.pause() - assert isinstance(app.screen, WorkflowDetailScreen) - # the agent select lists the one finished agent - assert app.screen._select is not None - assert len(app.screen._select.options) == 1 + screen = app.screen + assert isinstance(screen, WorkflowDetailScreen) + # left pane lists the phase (Search) with done/total progress + assert screen._phases_list is not None + assert len(screen._phases_list.options) == 1 + assert "Search" in screen._phases_list.options[0].label + assert screen._phases_list.options[0].description == "1/1" + # right pane lists that phase's one finished agent + assert screen._agents_list is not None + assert len(screen._agents_list.options) == 1 + assert "finder" in screen._agents_list.options[0].label + assert "✔" in screen._agents_list.options[0].label # rich status icon + + +async def test_detail_screen_x_stops_workflow_from_phases_pane(): + reg = _registry_with_run() + + class _DetailHost(App): + def on_mount(self): + self.push_screen(WorkflowDetailScreen(registry=reg, task_id="wdlg1")) + + app = _DetailHost() + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("x") # phases pane focused -> stops the whole run + await pilot.pause() + assert reg.get("wdlg1").status == "killed" + + +def _registry_with_two_phases(task_id="wd2"): + reg = RuntimeTaskRegistry() + prog = WorkflowProgress([{"title": "Search"}, {"title": "Verify"}]) + prog.start_phase("Search") + r = prog.agent_started(0, "finder", "Search", "0") + prog.agent_finished(r, status="completed", tokens=5, tool_count=3) + prog.start_phase("Verify") + prog.agent_started(1, "checker-a", "Verify", "1") + prog.agent_started(2, "checker-b", "Verify", "2") + register_workflow_task( + task_id=task_id, run_id="r2", workflow_name="demo2", description="", + output_file="/tmp/y.json", progress=prog, run=_FakeRun(), registry=reg, + ) + return reg + + +async def test_detail_screen_phase_nav_updates_agents_pane(): + reg = _registry_with_two_phases() + + class _Host(App): + def on_mount(self): + self.push_screen(WorkflowDetailScreen(registry=reg, task_id="wd2")) + + app = _Host() + async with app.run_test() as pilot: + await pilot.pause() + screen = app.screen + # phase 0 (Search) selected -> right pane shows its 1 agent + assert len(screen._agents_list.options) == 1 + assert "finder" in screen._agents_list.options[0].label + # move to phase 2 (Verify) -> right pane now shows its 2 agents + await pilot.press("down") + await pilot.pause() + assert len(screen._agents_list.options) == 2 + assert "checker-a" in screen._agents_list.options[0].label