Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 153 additions & 25 deletions src/tui/screens/workflow_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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]):
Expand Down
70 changes: 65 additions & 5 deletions tests/tui/test_workflow_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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